satteri 0.6.3 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -163,6 +163,14 @@ heading(node) {
163
163
  }
164
164
  ```
165
165
 
166
+ ### How transforms compose
167
+
168
+ Unlike remark and rehype, which re-walk the tree until it stops changing, each Sätteri plugin walks the tree **once**. Within that single pass:
169
+
170
+ - **Passed-through children keep their identity.** When a visitor returns a replacement that reuses the original node's children (e.g. `{ ...node, children: [...node.children] }`), those children are spliced back unchanged — so a transform the same pass queues on a nested one still applies. This is what lets a `containerDirective` visitor turn both an outer `:::note` and a nested `:::tip` into asides in one go.
171
+ - **A plugin's own freshly-built nodes are not re-walked by that plugin.** If a visitor returns a brand-new node (one that didn't come from the tree), the same plugin won't visit it. Produce its final shape directly, or hand it off to a later plugin — every plugin runs over the fully materialized output of the ones before it.
172
+ - **Dropping a subtree drops transforms queued inside it.** If one visitor removes or replaces a node while another (in the same pass) had queued a transform on something inside that subtree, the orphaned transform is dropped and a warning is logged. This is usually intentional — you discarded that subtree on purpose — but the warning helps catch the cases where it isn't.
173
+
166
174
  ### Async plugins
167
175
 
168
176
  Visitors can optionally be async. When any visitor is async, `markdownToHtml` and `mdxToJs` return a `Promise<string>` instead of `string`. For performance reasons, it is typically best to avoid async visitors, especially if your visitor matches a large number of nodes.
@@ -289,7 +297,8 @@ Type-safe wrapper for HAST plugin definitions.
289
297
  interface CompileOptions {
290
298
  mdastPlugins?: MdastPluginDefinition[];
291
299
  hastPlugins?: HastPluginDefinition[];
292
- filename?: string;
300
+ features?: Features;
301
+ fileURL?: URL;
293
302
  }
294
303
 
295
304
  // mdxToJs accepts MdxCompileOptions, which extends CompileOptions
@@ -298,6 +307,10 @@ interface MdxCompileOptions extends CompileOptions {
298
307
  }
299
308
  ```
300
309
 
310
+ `fileURL` is the `URL` of the document being processed, surfaced to plugins as `ctx.fileURL` (a `URL`, or `undefined` when omitted). Pass a file URL such as Astro's `fileURL`, or convert a filesystem path with Node's `pathToFileURL`; read it back with `fileURLToPath(ctx.fileURL)`.
311
+
312
+ `Features` controls the Markdown extensions Sätteri's parser recognizes: `gfm`, `frontmatter`, `math`, `directive`, `smartPunctuation`, etc. `gfm`, `math`, and `smartPunctuation` also accept granular options. For example, `math: { singleDollarTextMath: false }` keeps single `$` as literal text. See the [Features reference](https://satteri.dev/docs/features/) for the full list.
313
+
301
314
  ## License
302
315
 
303
316
  MIT
package/dist/compile.d.ts CHANGED
@@ -16,15 +16,91 @@ export interface SmartPunctuationOptions {
16
16
  /** Replace `...` with ellipsis (`…`). Default: true. */
17
17
  ellipses?: boolean;
18
18
  }
19
+ /**
20
+ * Per-backref callback. Invoked once per anchor in the footnotes section
21
+ * with 1-based `referenceNumber` and `rerunIndex` (1 on the first backref
22
+ * to that definition, 2 on the second, and so on). Must return the final
23
+ * string used as the backref content or `aria-label`.
24
+ */
25
+ export type FootnoteBackrefCallback = (referenceNumber: number, rerunIndex: number) => string;
26
+ /**
27
+ * i18n strings for the GFM footnotes section. Mirrors `footnoteLabel`,
28
+ * `footnoteBackLabel`, and `footnoteBackContent` from remark-rehype.
29
+ *
30
+ * `backContent` and `backLabel` each accept either a template string with
31
+ * the `{reference}` placeholder (substituted with `1` or `1-2` to match
32
+ * remark-rehype's default suffix), or a callback receiving the raw
33
+ * `(referenceNumber, rerunIndex)` pair.
34
+ *
35
+ * Passing this object enables footnotes. To turn them off, use
36
+ * `gfm: { footnotes: false }`.
37
+ */
38
+ export interface FootnoteOptions {
39
+ /** `<h2>` label opening the footnotes section. Default: `"Footnotes"`. */
40
+ label?: string;
41
+ /**
42
+ * Backref `<a>` content. Default: `"↩"`.
43
+ *
44
+ * Template form: the string is used as-is, and a `<sup>K</sup>` marker
45
+ * is auto-appended on reruns (k > 1). Callback form: returns the full
46
+ * content for each backref; no auto-sup is added.
47
+ */
48
+ backContent?: string | FootnoteBackrefCallback;
49
+ /**
50
+ * Backref `aria-label`. Default: `"Back to reference {reference}"`.
51
+ *
52
+ * Template form: `{reference}` becomes `n` for the first backref, `n-K`
53
+ * for subsequent ones. Callback form: returns the `aria-label` string
54
+ * for each backref.
55
+ */
56
+ backLabel?: string | FootnoteBackrefCallback;
57
+ }
58
+ /** Granular GFM toggles, nested under {@link Features.gfm}. */
59
+ export interface GfmOptions {
60
+ /**
61
+ * Enable GFM footnotes (`[^id]`). Default: true.
62
+ *
63
+ * Pass `false` to drop footnote parsing while keeping the rest of GFM;
64
+ * pass an object to enable footnotes with custom i18n strings (see
65
+ * {@link FootnoteOptions}).
66
+ */
67
+ footnotes?: boolean | FootnoteOptions;
68
+ }
69
+ /** Granular math toggles, nested under {@link Features.math}. */
70
+ export interface MathOptions {
71
+ /**
72
+ * Treat single-dollar runs (`$ ... $`) as inline math. Default: true.
73
+ *
74
+ * Set `false` to keep single `$` as literal text while still parsing
75
+ * `$$ ... $$` display math. Mirrors `singleDollarTextMath` from
76
+ * remark-math.
77
+ */
78
+ singleDollarTextMath?: boolean;
79
+ }
19
80
  /** Parser feature toggles. All default to their documented value when omitted. */
20
81
  export interface Features {
21
- /** GFM: tables, footnotes, strikethrough, task lists. Default: true. */
22
- gfm?: boolean;
82
+ /**
83
+ * GFM: tables, footnotes, strikethrough, task lists. Default: true.
84
+ *
85
+ * Pass an options object for granular control:
86
+ * ```ts
87
+ * gfm: { footnotes: false } // skip footnotes only
88
+ * gfm: { footnotes: { label: "Notes" } } // localize footnotes
89
+ * ```
90
+ */
91
+ gfm?: boolean | GfmOptions;
23
92
  /** Frontmatter: YAML (`--- ... ---`) and TOML (`+++ ... +++`). Default: true. */
24
93
  frontmatter?: boolean;
25
- /** Math blocks and inline math. Default: true. */
26
- math?: boolean;
27
- /** Heading attributes (`# text { #id .class }`). Default: true. */
94
+ /**
95
+ * Math blocks and inline math. Default: false.
96
+ *
97
+ * Pass an options object for granular control:
98
+ * ```ts
99
+ * math: { singleDollarTextMath: false } // $$..$$ only, $..$ literal
100
+ * ```
101
+ */
102
+ math?: boolean | MathOptions;
103
+ /** Heading attributes (`# text { #id .class }`). Default: false. */
28
104
  headingAttributes?: boolean;
29
105
  /** Colon-delimited container directive blocks (`:::`). Default: false. */
30
106
  directive?: boolean;
@@ -48,7 +124,12 @@ export interface CompileOptions {
48
124
  mdastPlugins?: MdastPluginInput[];
49
125
  hastPlugins?: HastPluginInput[];
50
126
  features?: Features;
51
- filename?: string;
127
+ /**
128
+ * The document being processed, surfaced to plugins as `ctx.fileURL`. Must
129
+ * be a `URL` (e.g. Astro's `fileURL`); convert a filesystem path with Node's
130
+ * `pathToFileURL` before passing it.
131
+ */
132
+ fileURL?: URL;
52
133
  }
53
134
  /**
54
135
  * MDX-only compile options.
package/dist/compile.js CHANGED
@@ -5,16 +5,55 @@ import { MdastReader } from "./mdast/mdast-reader.js";
5
5
  import { materializeMdastTree } from "./mdast/mdast-materializer.js";
6
6
  import { HastReader } from "./hast/hast-reader.js";
7
7
  import { materializeHastTree } from "./hast/hast-materializer.js";
8
+ /**
9
+ * Split the user-facing `Features` (with nested unions) into the flat napi
10
+ * `JsFeatures` shape plus the conversion-side `JsConvertOptions` carrying
11
+ * the footnote i18n strings. The public API only exposes `features`; the
12
+ * footnote strings are routed to napi internally.
13
+ */
8
14
  function featuresToNative(features) {
9
15
  if (!features)
10
- return undefined;
16
+ return { features: undefined, convertOptions: undefined };
11
17
  const result = {};
12
- if (features.gfm !== undefined)
13
- result.gfm = features.gfm;
18
+ let convertOptions;
19
+ if (features.gfm !== undefined) {
20
+ if (typeof features.gfm === "object") {
21
+ const g = features.gfm;
22
+ const gfmOpts = {};
23
+ if (g.footnotes !== undefined) {
24
+ if (typeof g.footnotes === "object") {
25
+ gfmOpts.footnotes = true;
26
+ convertOptions = convertOptions ?? {};
27
+ if (g.footnotes.label !== undefined)
28
+ convertOptions.footnoteLabel = g.footnotes.label;
29
+ if (g.footnotes.backContent !== undefined)
30
+ convertOptions.footnoteBackContent = g.footnotes.backContent;
31
+ if (g.footnotes.backLabel !== undefined)
32
+ convertOptions.footnoteBackLabel = g.footnotes.backLabel;
33
+ }
34
+ else {
35
+ gfmOpts.footnotes = g.footnotes;
36
+ }
37
+ }
38
+ result.gfmOptions = gfmOpts;
39
+ }
40
+ else {
41
+ result.gfm = features.gfm;
42
+ }
43
+ }
14
44
  if (features.frontmatter !== undefined)
15
45
  result.frontmatter = features.frontmatter;
16
- if (features.math !== undefined)
17
- result.math = features.math;
46
+ if (features.math !== undefined) {
47
+ if (typeof features.math === "object") {
48
+ const mathOpts = {};
49
+ if (features.math.singleDollarTextMath !== undefined)
50
+ mathOpts.singleDollarTextMath = features.math.singleDollarTextMath;
51
+ result.mathOptions = mathOpts;
52
+ }
53
+ else {
54
+ result.math = features.math;
55
+ }
56
+ }
18
57
  if (features.headingAttributes !== undefined)
19
58
  result.headingAttributes = features.headingAttributes;
20
59
  if (features.directive !== undefined)
@@ -33,41 +72,46 @@ function featuresToNative(features) {
33
72
  result.smartPunctuation = features.smartPunctuation;
34
73
  }
35
74
  }
36
- return result;
75
+ return { features: result, convertOptions };
76
+ }
77
+ function warnDroppedTransforms(plugin, dropped, kind) {
78
+ const name = plugin.name ?? "<anonymous>";
79
+ const noun = dropped === 1 ? "transform" : "transforms";
80
+ console.warn(`satteri: plugin "${name}" queued ${dropped} ${kind} ${noun} on node(s) that were removed or ` +
81
+ `replaced earlier in the same pass; ${dropped === 1 ? "it was" : "they were"} dropped.`);
37
82
  }
38
- function runMdastPluginsOnHandle(handle, plugins, filename) {
39
- let pendingCommands = null;
83
+ function runMdastPluginsOnHandle(handle, plugins, fileURL) {
84
+ // Each plugin runs once over the tree. A transform that passes a child
85
+ // through (returning it inside the replacement) keeps that child's identity,
86
+ // so a patch the same pass queued on it still applies — nesting composes in
87
+ // one pass. A plugin's own freshly-built nodes are not re-walked; transform
88
+ // them up front, or hand off to a later plugin that sees the materialized tree.
89
+ const runPlugin = (plugin) => {
90
+ const subs = resolveMdastSubscriptions(plugin);
91
+ const result = visitMdastHandle(handle, plugin, subs, () => getHandleSource(handle), fileURL);
92
+ const apply = (r) => {
93
+ if (!r.hasMutations)
94
+ return;
95
+ const dropped = applyCommandsToMdastHandle(handle, r.commandBuffer);
96
+ if (dropped)
97
+ warnDroppedTransforms(plugin, dropped, "mdast");
98
+ };
99
+ return result instanceof Promise ? result.then(apply) : apply(result);
100
+ };
40
101
  let i = 0;
41
102
  const runNext = () => {
42
103
  while (i < plugins.length) {
43
- const idx = i++;
44
- const raw = plugins[idx];
104
+ const raw = plugins[i++];
45
105
  const plugin = typeof raw === "function" ? raw() : raw;
46
- const subs = resolveMdastSubscriptions(plugin);
47
- const result = visitMdastHandle(handle, plugin, subs, () => getHandleSource(handle), filename);
48
- if (result instanceof Promise) {
49
- return result.then((r) => {
50
- applyMdastResult(r, idx, plugins.length, handle);
51
- return runNext();
52
- });
53
- }
54
- applyMdastResult(result, idx, plugins.length, handle);
106
+ const r = runPlugin(plugin);
107
+ if (r instanceof Promise)
108
+ return r.then(runNext);
55
109
  }
56
- return { handle, pendingCommands };
110
+ return { handle };
57
111
  };
58
- function applyMdastResult(result, idx, total, h) {
59
- if (result.hasMutations) {
60
- if (idx === total - 1) {
61
- pendingCommands = result.commandBuffer;
62
- }
63
- else {
64
- applyCommandsToMdastHandle(h, result.commandBuffer);
65
- }
66
- }
67
- }
68
112
  return runNext();
69
113
  }
70
- function runHastPluginsOnHandle(handle, plugins, source, filename) {
114
+ function runHastPluginsOnHandle(handle, plugins, source, fileURL) {
71
115
  if (plugins.length === 0)
72
116
  return;
73
117
  let i = 0;
@@ -77,10 +121,18 @@ function runHastPluginsOnHandle(handle, plugins, source, filename) {
77
121
  i++;
78
122
  const plugin = typeof raw === "function" ? raw() : raw;
79
123
  const subs = resolveSubscriptions(plugin);
80
- const result = visitHastHandle(handle, plugin, subs, source, filename);
124
+ const result = visitHastHandle(handle, plugin, subs, source, fileURL);
125
+ const warnIfDropped = (dropped) => {
126
+ if (dropped)
127
+ warnDroppedTransforms(plugin, dropped, "hast");
128
+ };
81
129
  if (result instanceof Promise) {
82
- return result.then(runNext);
130
+ return result.then((dropped) => {
131
+ warnIfDropped(dropped);
132
+ return runNext();
133
+ });
83
134
  }
135
+ warnIfDropped(result);
84
136
  }
85
137
  };
86
138
  return runNext();
@@ -129,9 +181,9 @@ function mdxOptionsToNative(opts) {
129
181
  return result;
130
182
  }
131
183
  export function markdownToHtml(source, options = {}) {
132
- const { mdastPlugins = [], hastPlugins = [], features, filename = "<unknown>" } = options;
133
- const nativeFeatures = featuresToNative(features);
134
- const result = createHastHandleFromMdast(source, mdastPlugins, false, filename, nativeFeatures);
184
+ const { mdastPlugins = [], hastPlugins = [], features, fileURL } = options;
185
+ const { features: nativeFeatures, convertOptions: nativeConvertOptions } = featuresToNative(features);
186
+ const result = createHastHandleFromMdast(source, mdastPlugins, false, fileURL, nativeFeatures, nativeConvertOptions);
135
187
  const renderAndDrop = (h, frontmatter) => {
136
188
  try {
137
189
  const html = renderHandle(h);
@@ -144,7 +196,7 @@ export function markdownToHtml(source, options = {}) {
144
196
  const runHastThenRender = (r) => {
145
197
  let hastResult;
146
198
  try {
147
- hastResult = runHastPluginsOnHandle(r.hastHandle, hastPlugins, source, filename);
199
+ hastResult = runHastPluginsOnHandle(r.hastHandle, hastPlugins, source, fileURL);
148
200
  }
149
201
  catch (err) {
150
202
  dropHandle(r.hastHandle);
@@ -163,10 +215,10 @@ export function markdownToHtml(source, options = {}) {
163
215
  return runHastThenRender(result);
164
216
  }
165
217
  export function mdxToJs(source, options = {}) {
166
- const { mdastPlugins = [], hastPlugins = [], features, filename = "<unknown>", ...mdxFields } = options;
218
+ const { mdastPlugins = [], hastPlugins = [], features, fileURL, ...mdxFields } = options;
167
219
  const mdxOptions = mdxOptionsToNative(mdxFields);
168
- const nativeFeatures = featuresToNative(features);
169
- const result = createHastHandleFromMdast(source, mdastPlugins, true, filename, nativeFeatures);
220
+ const { features: nativeFeatures, convertOptions: nativeConvertOptions } = featuresToNative(features);
221
+ const result = createHastHandleFromMdast(source, mdastPlugins, true, fileURL, nativeFeatures, nativeConvertOptions);
170
222
  const compileAndDrop = (h, frontmatter) => {
171
223
  try {
172
224
  const code = compileHandle(h, mdxOptions);
@@ -179,7 +231,7 @@ export function mdxToJs(source, options = {}) {
179
231
  const runHastThenCompile = (r) => {
180
232
  let hastResult;
181
233
  try {
182
- hastResult = runHastPluginsOnHandle(r.hastHandle, hastPlugins, source, filename);
234
+ hastResult = runHastPluginsOnHandle(r.hastHandle, hastPlugins, source, fileURL);
183
235
  }
184
236
  catch (err) {
185
237
  dropHandle(r.hastHandle);
@@ -224,9 +276,11 @@ function readFrontmatter(handle) {
224
276
  /** Parse, run mdast plugins, capture frontmatter, then convert to HAST.
225
277
  * Frontmatter is read from the post-plugin MDAST so visitor mutations to
226
278
  * the yaml/toml node are reflected in the returned value. */
227
- function createHastHandleFromMdast(source, mdastPlugins, mdx, filename,
279
+ function createHastHandleFromMdast(source, mdastPlugins, mdx, fileURL,
280
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
281
+ nativeFeatures,
228
282
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
229
- nativeFeatures) {
283
+ nativeConvertOptions) {
230
284
  const mdastHandle = mdx
231
285
  ? createMdxMdastHandle(source, nativeFeatures)
232
286
  : createMdastHandle(source, nativeFeatures);
@@ -234,11 +288,8 @@ nativeFeatures) {
234
288
  // on success, but if any step here throws the handle would otherwise leak.
235
289
  const finalize = (r) => {
236
290
  try {
237
- if (r.pendingCommands) {
238
- applyCommandsToMdastHandle(r.handle, r.pendingCommands);
239
- }
240
291
  const frontmatter = readFrontmatter(r.handle);
241
- const hastHandle = convertMdastToHastHandle(r.handle);
292
+ const hastHandle = convertMdastToHastHandle(r.handle, nativeConvertOptions);
242
293
  return { hastHandle, frontmatter };
243
294
  }
244
295
  finally {
@@ -247,9 +298,9 @@ nativeFeatures) {
247
298
  };
248
299
  try {
249
300
  if (mdastPlugins.length === 0) {
250
- return finalize({ handle: mdastHandle, pendingCommands: null });
301
+ return finalize({ handle: mdastHandle });
251
302
  }
252
- const mdastResult = runMdastPluginsOnHandle(mdastHandle, mdastPlugins, filename);
303
+ const mdastResult = runMdastPluginsOnHandle(mdastHandle, mdastPlugins, fileURL);
253
304
  if (mdastResult instanceof Promise) {
254
305
  return mdastResult.then(finalize, (err) => {
255
306
  dropHandle(mdastHandle);
@@ -266,7 +317,7 @@ nativeFeatures) {
266
317
  // Step-by-step API: individual pipeline stages with materialized trees
267
318
  /** Parse Markdown source into a materialized mdast tree. */
268
319
  export function markdownToMdast(source, options = {}) {
269
- const handle = createMdastHandle(source, featuresToNative(options.features));
320
+ const handle = createMdastHandle(source, featuresToNative(options.features).features);
270
321
  try {
271
322
  return materializeMdastTree(new MdastReader(serializeHandle(handle)));
272
323
  }
@@ -276,7 +327,7 @@ export function markdownToMdast(source, options = {}) {
276
327
  }
277
328
  /** Parse MDX source into a materialized mdast tree. */
278
329
  export function mdxToMdast(source, options = {}) {
279
- const handle = createMdxMdastHandle(source, featuresToNative(options.features));
330
+ const handle = createMdxMdastHandle(source, featuresToNative(options.features).features);
280
331
  try {
281
332
  return materializeMdastTree(new MdastReader(serializeHandle(handle)));
282
333
  }
@@ -286,7 +337,8 @@ export function mdxToMdast(source, options = {}) {
286
337
  }
287
338
  /** Convert Markdown source to a materialized hast tree. */
288
339
  export function markdownToHast(source, options = {}) {
289
- const handle = createHastHandle(source, featuresToNative(options.features));
340
+ const { features: nativeFeatures, convertOptions } = featuresToNative(options.features);
341
+ const handle = createHastHandle(source, nativeFeatures, convertOptions);
290
342
  try {
291
343
  return materializeHastTree(new HastReader(serializeHandle(handle)));
292
344
  }
@@ -296,7 +348,8 @@ export function markdownToHast(source, options = {}) {
296
348
  }
297
349
  /** Convert MDX source to a materialized hast tree. */
298
350
  export function mdxToHast(source, options = {}) {
299
- const handle = createMdxHastHandle(source, featuresToNative(options.features));
351
+ const { features: nativeFeatures, convertOptions } = featuresToNative(options.features);
352
+ const handle = createMdxHastHandle(source, nativeFeatures, convertOptions);
300
353
  try {
301
354
  return materializeHastTree(new HastReader(serializeHandle(handle)));
302
355
  }
@@ -15,11 +15,21 @@ export interface HastDiagnostic {
15
15
  }
16
16
  export interface HastVisitorContext {
17
17
  readonly source: string;
18
- readonly filename: string;
18
+ /**
19
+ * The URL of the document being processed (the compile `fileURL` option),
20
+ * or `undefined` when none was given. Use `fileURLToPath(ctx.fileURL)` for a
21
+ * decoded filesystem path.
22
+ */
23
+ readonly fileURL: URL | undefined;
19
24
  removeNode(node: Readonly<HastNode>): void;
20
25
  replaceNode(node: Readonly<HastNode>, newNode: HastNode): void;
21
26
  insertBefore(node: Readonly<HastNode>, newNode: HastNode): void;
22
27
  insertAfter(node: Readonly<HastNode>, newNode: HastNode): void;
28
+ /**
29
+ * Wrap `node` in `parentNode`, making it `parentNode`'s first child. Any
30
+ * children `parentNode` declares are kept after it, so a `div` with an anchor
31
+ * child wraps a heading as `div > [heading, anchor]`.
32
+ */
23
33
  wrapNode(node: Readonly<HastNode>, parentNode: HastNode): void;
24
34
  prependChild(node: Readonly<HastNode>, childNode: HastNode): void;
25
35
  appendChild(node: Readonly<HastNode>, childNode: HastNode): void;
@@ -68,7 +78,9 @@ export declare function resolveSubscriptions(plugin: HastVisitorInstance): Resol
68
78
  * Walk a handle's arena in Rust, dispatch matched nodes to JS visitor functions,
69
79
  * and apply mutations back to the handle. No arena buffers cross NAPI.
70
80
  *
71
- * Returns void if all visitors are sync, or a Promise if any visitor is async.
81
+ * Returns the number of patches dropped because their target was removed or
82
+ * replaced earlier in the same pass (the caller warns when non-zero), or a
83
+ * Promise of that count if any visitor is async.
72
84
  */
73
- export declare function visitHastHandle(handle: HastHandle, plugin: HastVisitorInstance, subs: ResolvedSubscription[], source: string | (() => string), filename: string): void | Promise<void>;
85
+ export declare function visitHastHandle(handle: HastHandle, plugin: HastVisitorInstance, subs: ResolvedSubscription[], source: string | (() => string), fileURL: URL | undefined): number | Promise<number>;
74
86
  export {};