diff --git a/.sampo/changesets/pompous-count-tuoni.md b/.sampo/changesets/pompous-count-tuoni.md new file mode 100644 index 00000000..dfb3a280 --- /dev/null +++ b/.sampo/changesets/pompous-count-tuoni.md @@ -0,0 +1,5 @@ +--- +npm/satteri: patch +--- + +Added `warnings` plugin option to allow opting out of warnings about dropped transforms. diff --git a/packages/satteri/src/plugin-pipeline.ts b/packages/satteri/src/plugin-pipeline.ts index 40ff1f70..036ea404 100644 --- a/packages/satteri/src/plugin-pipeline.ts +++ b/packages/satteri/src/plugin-pipeline.ts @@ -32,13 +32,14 @@ import { } from "./mdast/mdast-visitor.js"; import type { HastPluginDefinition, MdastPluginDefinition } from "./plugin.js"; import type { Data, SourceFormat } from "./types.js"; +import type { PluginOptions } from "./visitor-shared.js"; export type MdastPipelineResult = { handle: MdastHandle; // Deferring the final apply lets the caller fuse it with rendering or compilation. pendingCommands?: Uint8Array; // Attribute dropped-transform warnings to the plugin whose apply was deferred. - lastPlugin?: { name?: string }; + lastPlugin?: { name?: string; options?: PluginOptions }; }; // Invalidate retained child stubs before their arena is freed. @@ -49,10 +50,10 @@ export function releaseHandle(handle: AnyHandle, invalidateStubs: boolean): void export function warnIfDroppedTransforms( dropped: number | undefined, - plugin: { name?: string } | null | undefined, + plugin: { name?: string; options?: PluginOptions } | null | undefined, kind: "mdast" | "hast", ): void { - if (!dropped || !plugin) return; + if (!dropped || !plugin || (plugin.options && !plugin.options.warnings)) return; const name = plugin.name ?? ""; const noun = dropped === 1 ? "transform" : "transforms"; console.warn( @@ -153,7 +154,7 @@ export const EMPTY_COMMAND_BUFFER = new Uint8Array(0); export type CollectedHastCommands = { commands: Uint8Array; // Attribute dropped-transform warnings to the plugin whose apply was deferred. - lastPlugin: { name?: string } | null; + lastPlugin: { name?: string; options?: PluginOptions } | null; }; const NO_HAST_COMMANDS: CollectedHastCommands = { diff --git a/packages/satteri/src/visitor-shared.ts b/packages/satteri/src/visitor-shared.ts index 9ec600c1..1f4d5e02 100644 --- a/packages/satteri/src/visitor-shared.ts +++ b/packages/satteri/src/visitor-shared.ts @@ -72,6 +72,8 @@ export interface PluginOptions { * (~15% faster parse), and `node.position` is `undefined`. */ position?: boolean; + /** Display warnings about dropped transforms. Default: true. */ + warnings?: boolean; } const EMPTY_BYTES = new Uint8Array(0); diff --git a/packages/satteri/test/nested-transforms.test.ts b/packages/satteri/test/nested-transforms.test.ts index 93f9efff..9cab35c8 100644 --- a/packages/satteri/test/nested-transforms.test.ts +++ b/packages/satteri/test/nested-transforms.test.ts @@ -79,6 +79,7 @@ test("dropping a stranded transform warns, naming the plugin", () => { try { const plugin = defineMdastPlugin({ name: "remove-outer", + options: { warnings: false }, containerDirective(node, ctx) { if (node.name === "note") { ctx.removeNode(node); @@ -90,6 +91,11 @@ test("dropping a stranded transform warns, naming the plugin", () => { }, }); markdownToHtml(nestedDirectives, { features, mdastPlugins: [plugin] }); + expect(warn).toHaveBeenCalledTimes(0); + markdownToHtml(nestedDirectives, { + features, + mdastPlugins: [{ ...plugin, options: { warnings: true } }], + }); expect(warn).toHaveBeenCalledTimes(1); const message = warn.mock.calls[0]?.[0] as string; expect(message).toContain('plugin "remove-outer"'); diff --git a/website/content/docs/plugin-api.md b/website/content/docs/plugin-api.md index 945814de..68edc54c 100644 --- a/website/content/docs/plugin-api.md +++ b/website/content/docs/plugin-api.md @@ -503,6 +503,6 @@ Each Sätteri plugin walks the tree **once** — there is no re-walking until th - **Reused nodes keep their identity.** A node read from the tree and handed back to a context method is that node, not a snapshot of it, wherever it appears. When a visitor returns a replacement that reuses the original children (e.g. `{ ...node, children: [...node.children] }`), those children are spliced back unchanged, so a transform queued on a nested one in the same pass still applies. This is what lets a single `containerDirective` visitor turn both an outer `:::note` and a nested `:::tip` into asides in one go. The same holds for a node passed on its own, to `insertBefore`, `insertAfter`, `prependChild`, `appendChild`, `insertChildAt` or `replaceNode`, and for one a visitor returns: it arrives carrying every change the pass made to it. `wrapNode` is the exception: a node passed as the wrapper contributes its own fields as you read them, while its children still splice live. Pass `structuredClone(node)` when you want a detached copy frozen at the moment you read it. - **A plugin's own freshly-built nodes are not re-walked by that plugin.** A brand-new node a visitor returns isn't visited again by the same plugin. Produce its final shape directly, or hand it to a later plugin — every plugin runs over the fully materialized output of the ones before it. A `before` hook is the exception: it lands before the walk, so nodes it builds *are* visited. -- **Dropping a subtree drops the transforms queued inside it.** If one visitor removes or replaces a node while another queued a transform on something inside that subtree, the orphaned transform is dropped and a warning is logged. Usually that's intended; the warning catches the cases where it isn't. +- **Dropping a subtree drops the transforms queued inside it.** If one visitor removes or replaces a node while another queued a transform on something inside that subtree, the orphaned transform is dropped and a warning is logged. Usually that's intended; the warning catches the cases where it isn't. Warnings can be silenced with `options: { warnings: false }` for the plugin in question. - **Nodes from another document throw.** Handing a context method a node kept from a previous compile — or an mdast node inside a hast plugin — fails the compile. Keep nodes around within a document freely; don't carry them across. - **A few contradictory combinations throw.** Replacing a node with new content that reuses that same node while another plugin edits something inside it in the same pass, two replacements that each reuse the other's node, and inserting a sibling next to the root. Two more come from reuse having no answer: passing content that contains the node you are inserting it next to, as `insertAfter(node, ctx.parent(node))` does, and two inserts that each name the other's node. These reference errors are reported when Sätteri applies the queued edits, after the visitor returns; catch them around the processing call, not around an individual context method. Reordering siblings runs into the second one, so hand the parent the order you want with `setProperty(parent, "children", [...])` rather than shuffling by pairwise inserts. Replacing, removing, or wrapping the root itself — say, via `ctx.parent()` on a top-level node — works fine.