Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .sampo/changesets/pompous-count-tuoni.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
npm/satteri: patch
---

Added `warnings` plugin option to allow opting out of warnings about dropped transforms.
9 changes: 5 additions & 4 deletions packages/satteri/src/plugin-pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 ?? "<anonymous>";
const noun = dropped === 1 ? "transform" : "transforms";
console.warn(
Expand Down Expand Up @@ -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 = {
Expand Down
2 changes: 2 additions & 0 deletions packages/satteri/src/visitor-shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
6 changes: 6 additions & 0 deletions packages/satteri/test/nested-transforms.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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"');
Expand Down
2 changes: 1 addition & 1 deletion website/content/docs/plugin-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Loading