punctilio 5.1.4 → 5.2.1

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/src/rehype.ts CHANGED
@@ -1,21 +1,36 @@
1
- import type { Element, ElementContent, Parent, Root, RootContent, Text } from "hast"
2
- import type { Transformer } from "unified"
3
-
4
- import { SKIP, visitParents } from "unist-util-visit-parents"
5
-
6
- import { transformView } from "./index.js"
7
- import { TRANSFORM_OPTION_KEYS, type TransformOptions } from "./transform-options.js"
8
- import { MAX_RECURSION_DEPTH } from "./constants.js"
9
- import { assertKnownOptionKeys, formatErrorString } from "./utils.js"
10
- import { buildProseView, type ProsePass, type ProseView, splitAtIndices } from "./prose-view.js"
11
-
12
- export type ElementPredicate = (node: Element) => boolean
1
+ import type {
2
+ Element,
3
+ ElementContent,
4
+ Parent,
5
+ Root,
6
+ RootContent,
7
+ Text,
8
+ } from "hast";
9
+ import type { Transformer } from "unified";
10
+
11
+ import { SKIP, visitParents } from "unist-util-visit-parents";
12
+
13
+ import { transformView } from "./index.js";
14
+ import {
15
+ TRANSFORM_OPTION_KEYS,
16
+ type TransformOptions,
17
+ } from "./transform-options.js";
18
+ import { MAX_RECURSION_DEPTH } from "./constants.js";
19
+ import { assertKnownOptionKeys, formatErrorString } from "./utils.js";
20
+ import {
21
+ buildProseView,
22
+ type ProsePass,
23
+ type ProseView,
24
+ splitAtIndices,
25
+ } from "./prose-view.js";
26
+
27
+ export type ElementPredicate = (node: Element) => boolean;
13
28
 
14
29
  /** Per-text-node skip predicate, called after element-level `shouldSkip`. */
15
30
  export type TextNodeSkipPredicate = (
16
31
  textNode: Text,
17
32
  ancestors: readonly Element[],
18
- ) => boolean
33
+ ) => boolean;
19
34
 
20
35
  export interface ElementTransformOptions {
21
36
  /**
@@ -24,19 +39,18 @@ export interface ElementTransformOptions {
24
39
  * not passed to the transform function, and its `.value` is left
25
40
  * untouched. Applied after element-level `shouldSkip`.
26
41
  */
27
- shouldSkipText?: TextNodeSkipPredicate
42
+ shouldSkipText?: TextNodeSkipPredicate;
28
43
  }
29
44
 
30
45
  export interface RehypePunctilioOptions
31
- extends TransformOptions,
32
- ElementTransformOptions {
46
+ extends TransformOptions, ElementTransformOptions {
33
47
  /**
34
48
  * HTML tag names to skip when applying transformations.
35
49
  * Content inside these elements won't have formatting improvements applied.
36
50
  *
37
51
  * Default: ["code", "pre", "script", "style", "kbd", "var", "samp", "template", "math", "svg"]
38
52
  */
39
- skipTags?: string[]
53
+ skipTags?: string[];
40
54
 
41
55
  /**
42
56
  * CSS class names that indicate content should skip formatting.
@@ -45,7 +59,7 @@ export interface RehypePunctilioOptions
45
59
  *
46
60
  * Default: []
47
61
  */
48
- skipClasses?: string[]
62
+ skipClasses?: string[];
49
63
 
50
64
  /**
51
65
  * Invert the element model: transform text inside every element except the
@@ -56,95 +70,136 @@ export interface RehypePunctilioOptions
56
70
  *
57
71
  * Default: false
58
72
  */
59
- transformAllElements?: boolean
73
+ transformAllElements?: boolean;
60
74
  }
61
75
 
62
- const DEFAULT_SKIP_TAGS = ["code", "pre", "script", "style", "kbd", "var", "samp", "template", "math", "svg"]
76
+ const DEFAULT_SKIP_TAGS = [
77
+ "code",
78
+ "pre",
79
+ "script",
80
+ "style",
81
+ "kbd",
82
+ "var",
83
+ "samp",
84
+ "template",
85
+ "math",
86
+ "svg",
87
+ ];
63
88
 
64
89
  // Form elements whose text content is a literal control value, not prose.
65
90
  // Hard-skipped (whole subtree) under `transformAllElements`, on top of the
66
91
  // user skip-list, so their values are protected even when nested inside a
67
92
  // transformable element.
68
- const FORM_VALUE_TAGS = ["textarea", "input"]
93
+ const FORM_VALUE_TAGS = ["textarea", "input"];
69
94
 
70
95
  // `<select>` is not prose itself, but its `<option>` children are (and are
71
96
  // transformed under the default allowlist). Under `transformAllElements` we
72
97
  // exclude `select` as a transform *unit* while still recursing into — and
73
98
  // transforming — its options, so the inverted mode never transforms less than
74
99
  // the allowlist.
75
- const NON_PROSE_CONTAINER_TAGS = new Set(["select"])
100
+ const NON_PROSE_CONTAINER_TAGS = new Set(["select"]);
76
101
 
77
102
  /** Option keys handled by `rehypePunctilio` itself rather than `transform()`. */
78
- export const REHYPE_ONLY_OPTION_KEYS: readonly string[] = ["skipTags", "skipClasses", "shouldSkipText", "transformAllElements"]
103
+ export const REHYPE_ONLY_OPTION_KEYS: readonly string[] = [
104
+ "skipTags",
105
+ "skipClasses",
106
+ "shouldSkipText",
107
+ "transformAllElements",
108
+ ];
79
109
 
80
110
  /** Runtime list of valid `rehypePunctilio` option keys. */
81
- export const REHYPE_OPTION_KEYS: readonly string[] = [...TRANSFORM_OPTION_KEYS, ...REHYPE_ONLY_OPTION_KEYS]
111
+ export const REHYPE_OPTION_KEYS: readonly string[] = [
112
+ ...TRANSFORM_OPTION_KEYS,
113
+ ...REHYPE_ONLY_OPTION_KEYS,
114
+ ];
82
115
 
83
116
  // Void/replaced inline elements that render atomic content (or a hard break)
84
117
  // and carry no transformable text. When one sits between two text nodes it is a
85
118
  // real visual separator, so the flattener records an opaque gap there.
86
119
  const OPAQUE_VOID_TAGS = new Set([
87
- "img", "br", "wbr", "input", "hr", "embed",
88
- "area", "source", "track", "col", "param", "keygen",
89
- ])
120
+ "img",
121
+ "br",
122
+ "wbr",
123
+ "input",
124
+ "hr",
125
+ "embed",
126
+ "area",
127
+ "source",
128
+ "track",
129
+ "col",
130
+ "param",
131
+ "keygen",
132
+ ]);
90
133
 
91
134
  /** Flattened text nodes plus the opaque gaps that fell between adjacent ones. */
92
135
  export interface FlattenedProse {
93
- nodes: Text[]
136
+ nodes: Text[];
94
137
  /** Indices into `nodes` (1..n-1) with removed opaque content immediately before them. */
95
- opaqueBefore: Set<number>
138
+ opaqueBefore: Set<number>;
96
139
  }
97
140
 
98
141
  interface ProseCollector {
99
- nodes: Text[]
100
- opaqueBefore: Set<number>
101
- shouldSkip: ElementPredicate
102
- shouldSkipText: TextNodeSkipPredicate | undefined
103
- ancestors: Element[] | null
142
+ nodes: Text[];
143
+ opaqueBefore: Set<number>;
144
+ shouldSkip: ElementPredicate;
145
+ shouldSkipText: TextNodeSkipPredicate | undefined;
146
+ ancestors: Element[] | null;
104
147
  // An opaque element was dropped since the last emitted text node; the next
105
148
  // emitted node records an opaque gap before it.
106
- pendingOpaque: boolean
149
+ pendingOpaque: boolean;
107
150
  }
108
151
 
109
- function collectProse(node: Element | ElementContent, depth: number, c: ProseCollector): void {
152
+ function collectProse(
153
+ node: Element | ElementContent,
154
+ depth: number,
155
+ c: ProseCollector,
156
+ ): void {
110
157
  if (depth > MAX_RECURSION_DEPTH) {
111
- return
158
+ return;
112
159
  }
113
160
 
114
161
  if (node.type === "element") {
115
162
  if (c.shouldSkip(node)) {
116
163
  // A skipped element with content is a visual separator; an empty one
117
164
  // renders nothing and leaves its neighbors genuinely adjacent.
118
- if (node.children.length > 0) c.pendingOpaque = true
119
- return
165
+ if (node.children.length > 0) c.pendingOpaque = true;
166
+ return;
120
167
  }
121
168
  if (OPAQUE_VOID_TAGS.has(node.tagName)) {
122
- c.pendingOpaque = true
123
- return
169
+ c.pendingOpaque = true;
170
+ return;
124
171
  }
125
- if (c.ancestors) c.ancestors.push(node)
126
- for (const child of node.children) collectProse(child, depth + 1, c)
127
- if (c.ancestors) c.ancestors.pop()
128
- return
172
+ if (c.ancestors) c.ancestors.push(node);
173
+ for (const child of node.children) collectProse(child, depth + 1, c);
174
+ if (c.ancestors) c.ancestors.pop();
175
+ return;
129
176
  }
130
177
 
131
178
  if (node.type === "text") {
132
179
  // Snapshot ancestors at the callback boundary — the walker mutates the
133
180
  // shared array as it recurses, so handing it out directly would let a
134
181
  // caller observe a stale view if they captured the reference.
135
- if (c.shouldSkipText && c.ancestors && c.shouldSkipText(node, c.ancestors.slice())) {
182
+ if (
183
+ c.shouldSkipText &&
184
+ c.ancestors &&
185
+ c.shouldSkipText(node, c.ancestors.slice())
186
+ ) {
136
187
  // Preserved-but-untransformed text is a separator between its neighbors.
137
- c.pendingOpaque = true
138
- return
188
+ c.pendingOpaque = true;
189
+ return;
139
190
  }
140
- if (c.pendingOpaque && c.nodes.length > 0) c.opaqueBefore.add(c.nodes.length)
141
- c.pendingOpaque = false
142
- c.nodes.push(node)
191
+ if (c.pendingOpaque && c.nodes.length > 0)
192
+ c.opaqueBefore.add(c.nodes.length);
193
+ c.pendingOpaque = false;
194
+ c.nodes.push(node);
143
195
  }
144
196
  }
145
197
 
146
- function newCollector(shouldSkip: ElementPredicate, options?: ElementTransformOptions): ProseCollector {
147
- const shouldSkipText = options?.shouldSkipText
198
+ function newCollector(
199
+ shouldSkip: ElementPredicate,
200
+ options?: ElementTransformOptions,
201
+ ): ProseCollector {
202
+ const shouldSkipText = options?.shouldSkipText;
148
203
  return {
149
204
  nodes: [],
150
205
  opaqueBefore: new Set(),
@@ -154,87 +209,94 @@ function newCollector(shouldSkip: ElementPredicate, options?: ElementTransformOp
154
209
  // O(n²) allocation cost of copying the ancestor chain at every level.
155
210
  ancestors: shouldSkipText ? [] : null,
156
211
  pendingOpaque: false,
157
- }
212
+ };
158
213
  }
159
214
 
160
215
  /** Flattens an element's transformable text nodes, tracking opaque gaps. */
161
216
  export function flattenProse(
162
217
  node: Element | ElementContent,
163
218
  shouldSkip: ElementPredicate,
164
- options?: ElementTransformOptions
219
+ options?: ElementTransformOptions,
165
220
  ): FlattenedProse {
166
- const c = newCollector(shouldSkip, options)
167
- collectProse(node, 0, c)
168
- return { nodes: c.nodes, opaqueBefore: c.opaqueBefore }
221
+ const c = newCollector(shouldSkip, options);
222
+ collectProse(node, 0, c);
223
+ return { nodes: c.nodes, opaqueBefore: c.opaqueBefore };
169
224
  }
170
225
 
171
226
  export function flattenTextNodes(
172
227
  node: Element | ElementContent,
173
228
  shouldSkip: ElementPredicate,
174
- options?: ElementTransformOptions
229
+ options?: ElementTransformOptions,
175
230
  ): Text[] {
176
- return flattenProse(node, shouldSkip, options).nodes
231
+ return flattenProse(node, shouldSkip, options).nodes;
177
232
  }
178
233
 
179
234
  export function getTextContent(
180
235
  node: Element,
181
- shouldSkip: ElementPredicate = () => false
236
+ shouldSkip: ElementPredicate = () => false,
182
237
  ): string {
183
238
  return flattenTextNodes(node, shouldSkip)
184
239
  .map((n) => n.value)
185
- .join("")
240
+ .join("");
186
241
  }
187
242
 
188
243
  export function getFirstTextNode(
189
244
  node: Parent | RootContent,
190
- depth: number = 0
245
+ depth: number = 0,
191
246
  ): Text | null {
192
- if (!node || depth > MAX_RECURSION_DEPTH) return null
247
+ if (!node || depth > MAX_RECURSION_DEPTH) return null;
193
248
 
194
249
  if (node.type === "text") {
195
- return node as Text
250
+ return node as Text;
196
251
  }
197
252
 
198
253
  if ("children" in node && node.children.length > 0) {
199
254
  for (const child of node.children) {
200
- const textNode = getFirstTextNode(child as Parent, depth + 1)
201
- if (textNode) return textNode
255
+ const textNode = getFirstTextNode(child as Parent, depth + 1);
256
+ if (textNode) return textNode;
202
257
  }
203
258
  }
204
259
 
205
- return null
260
+ return null;
206
261
  }
207
262
 
208
- const QUOTE_OPENERS = new Set(["\u201C"])
209
- const CLOSER_TO_OPENER: Record<string, string> = { "\u201D": "\u201C" }
263
+ const QUOTE_OPENERS = new Set(["\u201C"]);
264
+ const CLOSER_TO_OPENER: Record<string, string> = { "\u201D": "\u201C" };
210
265
 
211
266
  // Only checks double quotes \u2014 single quotes are excluded because
212
267
  // U+2019 doubles as an apostrophe, making balance-checking unreliable.
213
268
  export function assertSmartQuotesMatch(input: string): void {
214
- if (!input) return
269
+ if (!input) return;
215
270
 
216
- const stack: string[] = []
271
+ const stack: string[] = [];
217
272
 
218
273
  for (const char of input) {
219
274
  if (QUOTE_OPENERS.has(char)) {
220
- stack.push(char)
275
+ stack.push(char);
221
276
  } else if (char in CLOSER_TO_OPENER) {
222
- if (stack.length > 0 && stack[stack.length - 1] === CLOSER_TO_OPENER[char]) {
223
- stack.pop()
277
+ if (
278
+ stack.length > 0 &&
279
+ stack[stack.length - 1] === CLOSER_TO_OPENER[char]
280
+ ) {
281
+ stack.pop();
224
282
  } else {
225
- throw new Error(`Mismatched quotes in ${formatErrorString(input, "input")}`)
283
+ throw new Error(
284
+ `Mismatched quotes in ${formatErrorString(input, "input")}`,
285
+ );
226
286
  }
227
287
  }
228
288
  }
229
289
 
230
290
  if (stack.length > 0) {
231
- throw new Error(`Mismatched quotes in ${formatErrorString(input, "input")}`)
291
+ throw new Error(
292
+ `Mismatched quotes in ${formatErrorString(input, "input")}`,
293
+ );
232
294
  }
233
295
  }
234
296
 
235
297
  export interface ProseViewOfOptions extends ElementTransformOptions {
236
298
  /** Element-level skip predicate; skipped subtrees contribute no text nodes. */
237
- shouldSkip?: ElementPredicate
299
+ shouldSkip?: ElementPredicate;
238
300
  }
239
301
 
240
302
  /**
@@ -244,19 +306,22 @@ export interface ProseViewOfOptions extends ElementTransformOptions {
244
306
  * view spans opaque gaps; the transform pipeline uses {@link proseViewsOf},
245
307
  * which splits on them so no pass rewrites across removed atomic content.
246
308
  */
247
- export function proseViewOf(element: Element, options: ProseViewOfOptions = {}): ProseView | null {
309
+ export function proseViewOf(
310
+ element: Element,
311
+ options: ProseViewOfOptions = {},
312
+ ): ProseView | null {
248
313
  /* istanbul ignore if -- defensive: elements should always have children array */
249
314
  if (!element?.children) {
250
- return null
315
+ return null;
251
316
  }
252
317
 
253
- const shouldSkip = options.shouldSkip ?? (() => false)
254
- const textNodes = flattenTextNodes(element, shouldSkip, options)
318
+ const shouldSkip = options.shouldSkip ?? (() => false);
319
+ const textNodes = flattenTextNodes(element, shouldSkip, options);
255
320
  if (textNodes.length === 0) {
256
- return null
321
+ return null;
257
322
  }
258
323
 
259
- return buildProseView(textNodes)
324
+ return buildProseView(textNodes);
260
325
  }
261
326
 
262
327
  /**
@@ -265,14 +330,19 @@ export function proseViewOf(element: Element, options: ProseViewOfOptions = {}):
265
330
  * inline content) keeps their surrounding text in separate views, so a pass can
266
331
  * never treat text as adjacent across content that visually separates it.
267
332
  */
268
- export function proseViewsOf(element: Element, options: ProseViewOfOptions = {}): ProseView[] {
333
+ export function proseViewsOf(
334
+ element: Element,
335
+ options: ProseViewOfOptions = {},
336
+ ): ProseView[] {
269
337
  /* istanbul ignore if -- defensive: elements should always have children array */
270
338
  if (!element?.children) {
271
- return []
339
+ return [];
272
340
  }
273
- const shouldSkip = options.shouldSkip ?? (() => false)
274
- const { nodes, opaqueBefore } = flattenProse(element, shouldSkip, options)
275
- return splitAtIndices(nodes, opaqueBefore).map((group) => buildProseView(group))
341
+ const shouldSkip = options.shouldSkip ?? (() => false);
342
+ const { nodes, opaqueBefore } = flattenProse(element, shouldSkip, options);
343
+ return splitAtIndices(nodes, opaqueBefore).map((group) =>
344
+ buildProseView(group),
345
+ );
276
346
  }
277
347
 
278
348
  /**
@@ -282,16 +352,20 @@ export function proseViewsOf(element: Element, options: ProseViewOfOptions = {})
282
352
  */
283
353
  export type PassEntry =
284
354
  | ProsePass
285
- | { pass: ProsePass; shouldSkip?: ElementPredicate; shouldSkipText?: TextNodeSkipPredicate }
355
+ | {
356
+ pass: ProsePass;
357
+ shouldSkip?: ElementPredicate;
358
+ shouldSkipText?: TextNodeSkipPredicate;
359
+ };
286
360
 
287
361
  interface ResolvedPassEntry {
288
- pass: ProsePass
289
- shouldSkip?: ElementPredicate
290
- shouldSkipText?: TextNodeSkipPredicate
362
+ pass: ProsePass;
363
+ shouldSkip?: ElementPredicate;
364
+ shouldSkipText?: TextNodeSkipPredicate;
291
365
  }
292
366
 
293
367
  function resolvePassEntry(entry: PassEntry): ResolvedPassEntry {
294
- return typeof entry === "function" ? { pass: entry } : entry
368
+ return typeof entry === "function" ? { pass: entry } : entry;
295
369
  }
296
370
 
297
371
  /** OR-combines an optional entry predicate onto an optional base predicate. */
@@ -299,9 +373,9 @@ function mergePredicates<Args extends unknown[]>(
299
373
  base: ((...args: Args) => boolean) | undefined,
300
374
  extra: ((...args: Args) => boolean) | undefined,
301
375
  ): ((...args: Args) => boolean) | undefined {
302
- if (!base) return extra
303
- if (!extra) return base
304
- return (...args) => base(...args) || extra(...args)
376
+ if (!base) return extra;
377
+ if (!extra) return base;
378
+ return (...args) => base(...args) || extra(...args);
305
379
  }
306
380
 
307
381
  /**
@@ -315,55 +389,100 @@ function mergePredicates<Args extends unknown[]>(
315
389
  * fresh view (built after the previous pass committed) over the text nodes
316
390
  * that survive both the base `options` predicates and its own.
317
391
  *
318
- * The view is a single {@link proseViewOf} spanning the whole element, with a
319
- * boundary recorded at every opaque gap (removed skipped element, image, …).
320
- * Caller passes stay boundary-aware via `view.hasBoundary`, so each pass
321
- * decides for itself whether to act across a gap — spacing a slash between two
322
- * skipped `<code>` runs, gluing a dash after a skipped element — rather than
323
- * being blocked wholesale. Passes that must not cross a gap consult the same
324
- * boundary marks; use {@link proseViewsOf} directly for hard per-segment views.
392
+ * The view is a single spanning view (over the whole element, or a run's loose
393
+ * inline children), with a boundary recorded at every opaque gap (removed
394
+ * skipped element, image, …). Caller passes stay boundary-aware via
395
+ * `view.hasBoundary`, so each pass decides for itself whether to act across a
396
+ * gap — spacing a slash between two skipped `<code>` runs, gluing a dash after a
397
+ * skipped element — rather than being blocked wholesale. Passes that must not
398
+ * cross a gap consult the same boundary marks; use {@link proseViewsOf} directly
399
+ * for hard per-segment views.
400
+ *
401
+ * Accepts a bare element (formatted whole) or a {@link ProseUnit} from
402
+ * {@link collectProseUnits}; a "run" unit formats a container's loose inline
403
+ * text without pulling in its block children.
325
404
  */
326
405
  export function applyPasses(
327
- element: Element,
406
+ target: Element | ProseUnit,
328
407
  passes: readonly PassEntry[],
329
408
  options: ProseViewOfOptions = {},
330
409
  ): void {
331
- let currentView: ProseView | null = null
332
- let currentPredicates: Pick<ResolvedPassEntry, "shouldSkip" | "shouldSkipText"> | null = null
410
+ const makeView = (viewOptions: ProseViewOfOptions): ProseView | null => {
411
+ if ("kind" in target) {
412
+ return target.kind === "element"
413
+ ? proseViewOf(target.element, viewOptions)
414
+ : runViewOf(target.container, target.children, viewOptions);
415
+ }
416
+ return proseViewOf(target, viewOptions);
417
+ };
418
+
419
+ let currentView: ProseView | null = null;
420
+ let currentPredicates: Pick<
421
+ ResolvedPassEntry,
422
+ "shouldSkip" | "shouldSkipText"
423
+ > | null = null;
333
424
 
334
425
  for (const entry of passes) {
335
- const { pass, shouldSkip, shouldSkipText } = resolvePassEntry(entry)
426
+ const { pass, shouldSkip, shouldSkipText } = resolvePassEntry(entry);
336
427
 
337
428
  const samePredicates =
338
429
  currentPredicates !== null &&
339
430
  currentPredicates.shouldSkip === shouldSkip &&
340
- currentPredicates.shouldSkipText === shouldSkipText
431
+ currentPredicates.shouldSkipText === shouldSkipText;
341
432
  if (!samePredicates) {
342
- currentView = proseViewOf(element, {
433
+ currentView = makeView({
343
434
  shouldSkip: mergePredicates(options.shouldSkip, shouldSkip),
344
435
  shouldSkipText: mergePredicates(options.shouldSkipText, shouldSkipText),
345
- })
346
- currentPredicates = { shouldSkip, shouldSkipText }
436
+ });
437
+ currentPredicates = { shouldSkip, shouldSkipText };
347
438
  }
348
439
 
349
440
  // No transformable text under this entry's predicates; a later entry may
350
441
  // still see text (its skip set differs), so keep going.
351
442
  if (currentView === null) {
352
- continue
443
+ continue;
353
444
  }
354
- pass(currentView)
445
+ pass(currentView);
355
446
  }
356
447
  }
357
448
 
358
449
  // Block children cause per-block processing to avoid merging text across
359
450
  // semantically independent blocks (e.g., separate <p>s inside a <div>).
360
451
  const BLOCK_ELEMENTS = new Set([
361
- "address", "article", "aside", "blockquote", "details", "dialog",
362
- "dd", "div", "dl", "dt", "fieldset", "figcaption", "figure",
363
- "footer", "form", "h1", "h2", "h3", "h4", "h5", "h6",
364
- "header", "hgroup", "hr", "li", "main", "nav", "ol",
365
- "p", "pre", "section", "table", "ul",
366
- ])
452
+ "address",
453
+ "article",
454
+ "aside",
455
+ "blockquote",
456
+ "details",
457
+ "dialog",
458
+ "dd",
459
+ "div",
460
+ "dl",
461
+ "dt",
462
+ "fieldset",
463
+ "figcaption",
464
+ "figure",
465
+ "footer",
466
+ "form",
467
+ "h1",
468
+ "h2",
469
+ "h3",
470
+ "h4",
471
+ "h5",
472
+ "h6",
473
+ "header",
474
+ "hgroup",
475
+ "hr",
476
+ "li",
477
+ "main",
478
+ "nav",
479
+ "ol",
480
+ "p",
481
+ "pre",
482
+ "section",
483
+ "table",
484
+ "ul",
485
+ ]);
367
486
 
368
487
  const TRANSFORMABLE_ELEMENTS = new Set([
369
488
  "p",
@@ -424,36 +543,38 @@ const TRANSFORMABLE_ELEMENTS = new Set([
424
543
  "button",
425
544
  "option",
426
545
  "output",
427
- ])
546
+ ]);
428
547
 
429
548
  // A tag name containing "-" is a custom element per the HTML custom-element
430
549
  // naming rule; assume those hold transformable prose like built-in elements.
431
550
  function isTransformableElement(tagName: string): boolean {
432
- return TRANSFORMABLE_ELEMENTS.has(tagName) || tagName.includes("-")
551
+ return TRANSFORMABLE_ELEMENTS.has(tagName) || tagName.includes("-");
433
552
  }
434
553
 
435
554
  function hasTextDescendant(
436
555
  node: Element | ElementContent,
437
556
  shouldSkip: ElementPredicate,
438
- depth: number = 0
557
+ depth: number = 0,
439
558
  ): boolean {
440
559
  if (depth > MAX_RECURSION_DEPTH) {
441
- return false
560
+ return false;
442
561
  }
443
562
 
444
563
  if (node.type === "element" && shouldSkip(node)) {
445
- return false
564
+ return false;
446
565
  }
447
566
 
448
567
  if (node.type === "text") {
449
- return true
568
+ return true;
450
569
  }
451
570
 
452
571
  if (node.type === "element") {
453
- return node.children.some((child) => hasTextDescendant(child, shouldSkip, depth + 1))
572
+ return node.children.some((child) =>
573
+ hasTextDescendant(child, shouldSkip, depth + 1),
574
+ );
454
575
  }
455
576
 
456
- return false
577
+ return false;
457
578
  }
458
579
 
459
580
  export interface CollectProseBlocksOptions {
@@ -461,77 +582,117 @@ export interface CollectProseBlocksOptions {
461
582
  * HTML tag names to skip. Default: the plugin's default skip list
462
583
  * ("code", "pre", "script", ...).
463
584
  */
464
- skipTags?: string[]
585
+ skipTags?: string[];
465
586
  /** CSS class names whose elements (and their subtrees) are skipped. Default: [] */
466
- skipClasses?: string[]
587
+ skipClasses?: string[];
467
588
  /** Additional element-level skip predicate, OR-ed with the tag/class skips. */
468
- shouldSkip?: ElementPredicate
589
+ shouldSkip?: ElementPredicate;
469
590
  /** Invert the element model as in {@link RehypePunctilioOptions.transformAllElements}. Default: false */
470
- transformAllElements?: boolean
591
+ transformAllElements?: boolean;
471
592
  }
472
593
 
473
594
  /** Skip predicate and transformable-tag test resolved from the options. */
474
595
  interface ResolvedCollectOptions {
475
- shouldSkip: ElementPredicate
476
- isTransformable: (tagName: string) => boolean
596
+ shouldSkip: ElementPredicate;
597
+ isTransformable: (tagName: string) => boolean;
477
598
  }
478
599
 
479
- function resolveCollectOptions(options: CollectProseBlocksOptions): ResolvedCollectOptions {
480
- const { skipTags = DEFAULT_SKIP_TAGS, skipClasses = [], shouldSkip, transformAllElements = false } = options
600
+ function resolveCollectOptions(
601
+ options: CollectProseBlocksOptions,
602
+ ): ResolvedCollectOptions {
603
+ const {
604
+ skipTags = DEFAULT_SKIP_TAGS,
605
+ skipClasses = [],
606
+ shouldSkip,
607
+ transformAllElements = false,
608
+ } = options;
481
609
 
482
610
  // In inverted mode, form-value elements join the skip-list so their literal
483
611
  // values are neither transformed nor flattened into a transformable ancestor.
484
- const skipTagSet = new Set(transformAllElements ? [...skipTags, ...FORM_VALUE_TAGS] : skipTags)
485
- const skipClassSet = new Set(skipClasses)
612
+ const skipTagSet = new Set(
613
+ transformAllElements ? [...skipTags, ...FORM_VALUE_TAGS] : skipTags,
614
+ );
615
+ const skipClassSet = new Set(skipClasses);
486
616
 
487
617
  const hasSkipClass = (node: Element): boolean => {
488
- if (skipClassSet.size === 0) return false
489
- const classNames = node.properties?.className
618
+ if (skipClassSet.size === 0) return false;
619
+ const classNames = node.properties?.className;
490
620
  const classes = Array.isArray(classNames)
491
621
  ? classNames.filter((c): c is string => typeof c === "string")
492
- : typeof classNames === "string" ? classNames.split(/\s+/) : []
493
- return classes.some((cls) => skipClassSet.has(cls))
494
- }
622
+ : typeof classNames === "string"
623
+ ? classNames.split(/\s+/)
624
+ : [];
625
+ return classes.some((cls) => skipClassSet.has(cls));
626
+ };
495
627
 
496
628
  return {
497
629
  shouldSkip: (node) =>
498
- skipTagSet.has(node.tagName) || hasSkipClass(node) || (shouldSkip?.(node) ?? false),
630
+ skipTagSet.has(node.tagName) ||
631
+ hasSkipClass(node) ||
632
+ (shouldSkip?.(node) ?? false),
499
633
  // Inverted mode transforms every non-skipped element except non-prose
500
634
  // containers; the default keeps the `TRANSFORMABLE_ELEMENTS` allowlist
501
635
  // (plus custom elements).
502
636
  isTransformable: transformAllElements
503
637
  ? (tagName) => !NON_PROSE_CONTAINER_TAGS.has(tagName)
504
638
  : isTransformableElement,
505
- }
639
+ };
506
640
  }
507
641
 
508
642
  // A prose unit is either a whole transformable element (an inline-only leaf) or
509
643
  // a "run" of consecutive inline siblings that sit alongside block-level
510
644
  // children. Runs let a container's loose inline text transform independently of
511
645
  // its block children instead of merging across the block boundary.
512
- type ProseUnit =
646
+ export type ProseUnit =
513
647
  | { kind: "element"; element: Element }
514
- | { kind: "run"; container: Element; children: ElementContent[] }
648
+ | { kind: "run"; container: Element; children: ElementContent[] };
515
649
 
516
650
  /**
517
651
  * Collects the leaf elements under `root` (inclusive) whose text should be
518
652
  * transformed as one prose block: transformable elements with direct text or
519
653
  * inline-only text descendants. Elements with block-level children recurse so
520
654
  * each block transforms independently. Loose inline text mixed among block
521
- * children is handled by the plugin as its own run and is not represented here.
655
+ * children is NOT represented here — use {@link collectProseUnits} to reach it.
656
+ */
657
+ export function collectProseBlocks(
658
+ root: Element,
659
+ options: CollectProseBlocksOptions = {},
660
+ ): Element[] {
661
+ const { shouldSkip, isTransformable } = resolveCollectOptions(options);
662
+ return collectProseUnitsImpl(
663
+ root,
664
+ shouldSkip,
665
+ 0,
666
+ undefined,
667
+ isTransformable,
668
+ ).flatMap((unit) => (unit.kind === "element" ? [unit.element] : []));
669
+ }
670
+
671
+ /**
672
+ * Like {@link collectProseBlocks}, but also returns "run" units — maximal groups
673
+ * of loose inline siblings sitting beside block children (e.g. the bare text in
674
+ * `<blockquote><p>…</p>loose text</blockquote>`). A consumer that walks elements
675
+ * cannot otherwise reach that text (it belongs to no element of its own); pass
676
+ * each unit to {@link applyPasses}, which formats a run without merging its
677
+ * container's block children.
522
678
  */
523
- export function collectProseBlocks(root: Element, options: CollectProseBlocksOptions = {}): Element[] {
524
- const { shouldSkip, isTransformable } = resolveCollectOptions(options)
525
- return collectProseUnitsImpl(root, shouldSkip, 0, undefined, isTransformable)
526
- .flatMap((unit) => (unit.kind === "element" ? [unit.element] : []))
679
+ export function collectProseUnits(
680
+ root: Element,
681
+ options: CollectProseBlocksOptions = {},
682
+ ): ProseUnit[] {
683
+ const { shouldSkip, isTransformable } = resolveCollectOptions(options);
684
+ return collectProseUnitsImpl(root, shouldSkip, 0, undefined, isTransformable);
527
685
  }
528
686
 
529
- function runHasProse(children: readonly ElementContent[], shouldSkip: ElementPredicate): boolean {
687
+ function runHasProse(
688
+ children: readonly ElementContent[],
689
+ shouldSkip: ElementPredicate,
690
+ ): boolean {
530
691
  return children.some(
531
692
  (child) =>
532
693
  (child.type === "text" && child.value.trim() !== "") ||
533
- (child.type === "element" && hasTextDescendant(child, shouldSkip))
534
- )
694
+ (child.type === "element" && hasTextDescendant(child, shouldSkip)),
695
+ );
535
696
  }
536
697
 
537
698
  function collectProseUnitsImpl(
@@ -539,15 +700,15 @@ function collectProseUnitsImpl(
539
700
  shouldSkip: ElementPredicate,
540
701
  depth: number,
541
702
  alreadyTransformed: ReadonlySet<Element> | undefined,
542
- isTransformable: (tagName: string) => boolean
703
+ isTransformable: (tagName: string) => boolean,
543
704
  ): ProseUnit[] {
544
705
  /* istanbul ignore if -- defensive: prevents stack overflow from malicious HTML */
545
706
  if (depth > MAX_RECURSION_DEPTH) {
546
- return []
707
+ return [];
547
708
  }
548
709
 
549
710
  if (shouldSkip(node) || alreadyTransformed?.has(node)) {
550
- return []
711
+ return [];
551
712
  }
552
713
 
553
714
  // Whitespace-only text nodes between block siblings (the newlines a parser
@@ -556,11 +717,11 @@ function collectProseUnitsImpl(
556
717
  // and merge its blocks into one transform unit, pairing quotes across the
557
718
  // paragraph boundary. Real (non-whitespace) direct text still marks a leaf.
558
719
  const hasDirectText = node.children.some(
559
- (child) => child.type === "text" && child.value.trim() !== ""
560
- )
720
+ (child) => child.type === "text" && child.value.trim() !== "",
721
+ );
561
722
  const hasBlockChildren = node.children.some(
562
- (child) => child.type === "element" && BLOCK_ELEMENTS.has(child.tagName)
563
- )
723
+ (child) => child.type === "element" && BLOCK_ELEMENTS.has(child.tagName),
724
+ );
564
725
 
565
726
  // Inline-only transformable node: a single leaf unit. `hasTextDescendant` is
566
727
  // only walked when there's no direct text (short-circuit avoids the traversal).
@@ -569,41 +730,54 @@ function collectProseUnitsImpl(
569
730
  !hasBlockChildren &&
570
731
  (hasDirectText || hasTextDescendant(node, shouldSkip))
571
732
  ) {
572
- return [{ kind: "element", element: node }]
733
+ return [{ kind: "element", element: node }];
573
734
  }
574
735
 
575
- const units: ProseUnit[] = []
736
+ const units: ProseUnit[] = [];
576
737
  if (isTransformable(node.tagName) && hasBlockChildren) {
577
738
  // Mixed content: split each maximal run of inline siblings from the block
578
739
  // children so loose text transforms on its own, then recurse per block.
579
- let run: ElementContent[] = []
740
+ let run: ElementContent[] = [];
580
741
  const flushRun = () => {
581
- if (runHasProse(run, shouldSkip)) units.push({ kind: "run", container: node, children: run })
582
- run = []
583
- }
742
+ if (runHasProse(run, shouldSkip))
743
+ units.push({ kind: "run", container: node, children: run });
744
+ run = [];
745
+ };
584
746
  for (const child of node.children) {
585
747
  if (child.type === "element" && BLOCK_ELEMENTS.has(child.tagName)) {
586
- flushRun()
587
- for (const u of collectProseUnitsImpl(child, shouldSkip, depth + 1, alreadyTransformed, isTransformable)) {
588
- units.push(u)
748
+ flushRun();
749
+ for (const u of collectProseUnitsImpl(
750
+ child,
751
+ shouldSkip,
752
+ depth + 1,
753
+ alreadyTransformed,
754
+ isTransformable,
755
+ )) {
756
+ units.push(u);
589
757
  }
590
758
  } else {
591
- run.push(child)
759
+ run.push(child);
592
760
  }
593
761
  }
594
- flushRun()
762
+ flushRun();
595
763
  } else {
596
764
  // Non-transformable, or transformable but textless: recurse into elements.
597
765
  for (const child of node.children) {
598
766
  if (child.type === "element") {
599
- for (const u of collectProseUnitsImpl(child, shouldSkip, depth + 1, alreadyTransformed, isTransformable)) {
600
- units.push(u)
767
+ for (const u of collectProseUnitsImpl(
768
+ child,
769
+ shouldSkip,
770
+ depth + 1,
771
+ alreadyTransformed,
772
+ isTransformable,
773
+ )) {
774
+ units.push(u);
601
775
  }
602
776
  }
603
777
  }
604
778
  }
605
779
 
606
- return units
780
+ return units;
607
781
  }
608
782
 
609
783
  // Flatten a run's inline siblings to their text nodes (with opaque gaps),
@@ -613,32 +787,51 @@ function flattenRunProse(
613
787
  container: Element,
614
788
  children: readonly ElementContent[],
615
789
  shouldSkip: ElementPredicate,
616
- options: ProseViewOfOptions
790
+ options: ProseViewOfOptions,
617
791
  ): FlattenedProse {
618
- const c = newCollector(shouldSkip, options)
619
- if (c.ancestors) c.ancestors.push(container)
620
- for (const child of children) collectProse(child, 1, c)
621
- return { nodes: c.nodes, opaqueBefore: c.opaqueBefore }
792
+ const c = newCollector(shouldSkip, options);
793
+ if (c.ancestors) c.ancestors.push(container);
794
+ for (const child of children) collectProse(child, 1, c);
795
+ return { nodes: c.nodes, opaqueBefore: c.opaqueBefore };
796
+ }
797
+
798
+ /**
799
+ * A single spanning ProseView over a run's loose inline children, with a
800
+ * boundary at every opaque gap — the run analogue of {@link proseViewOf}. Null
801
+ * when the run holds no transformable text.
802
+ */
803
+ function runViewOf(
804
+ container: Element,
805
+ children: readonly ElementContent[],
806
+ options: ProseViewOfOptions,
807
+ ): ProseView | null {
808
+ const shouldSkip = options.shouldSkip ?? (() => false);
809
+ const { nodes } = flattenRunProse(container, children, shouldSkip, options);
810
+ return nodes.length === 0 ? null : buildProseView(nodes);
622
811
  }
623
812
 
624
- function markDescendants(node: Element, set: Set<Element>, depth: number = 0): void {
813
+ function markDescendants(
814
+ node: Element,
815
+ set: Set<Element>,
816
+ depth: number = 0,
817
+ ): void {
625
818
  /* istanbul ignore if -- defensive: prevents stack overflow from malicious HTML */
626
819
  if (depth > MAX_RECURSION_DEPTH) {
627
- return
820
+ return;
628
821
  }
629
822
 
630
823
  for (const child of node.children) {
631
824
  if (child.type === "element") {
632
- set.add(child)
633
- markDescendants(child, set, depth + 1)
825
+ set.add(child);
826
+ markDescendants(child, set, depth + 1);
634
827
  }
635
828
  }
636
829
  }
637
830
 
638
831
  export function rehypePunctilio(
639
- options: RehypePunctilioOptions = {}
832
+ options: RehypePunctilioOptions = {},
640
833
  ): Transformer<Root, Root> {
641
- assertKnownOptionKeys(options, REHYPE_OPTION_KEYS, "rehypePunctilio")
834
+ assertKnownOptionKeys(options, REHYPE_OPTION_KEYS, "rehypePunctilio");
642
835
 
643
836
  const {
644
837
  skipTags,
@@ -646,61 +839,76 @@ export function rehypePunctilio(
646
839
  shouldSkipText,
647
840
  transformAllElements,
648
841
  ...transformOptions
649
- } = options
842
+ } = options;
650
843
 
651
- const { shouldSkip, isTransformable } = resolveCollectOptions({ skipTags, skipClasses, transformAllElements })
844
+ const { shouldSkip, isTransformable } = resolveCollectOptions({
845
+ skipTags,
846
+ skipClasses,
847
+ transformAllElements,
848
+ });
652
849
 
653
850
  // Allocate the per-element options object once; it's read-only inside
654
851
  // proseViewOf, so sharing across calls is safe and avoids a fresh
655
852
  // allocation per transformable element.
656
853
  const viewOptions: ProseViewOfOptions = shouldSkipText
657
854
  ? { shouldSkip, shouldSkipText }
658
- : { shouldSkip }
855
+ : { shouldSkip };
659
856
 
660
857
  return (tree: Root) => {
661
858
  // Track transformed elements to avoid double-processing
662
- const transformed = new Set<Element>()
859
+ const transformed = new Set<Element>();
663
860
 
664
861
  visitParents(tree, "element", (node) => {
665
862
  // Skip subtree if already transformed
666
863
  if (transformed.has(node)) {
667
- return SKIP
864
+ return SKIP;
668
865
  }
669
866
 
670
867
  if (shouldSkip(node)) {
671
- return SKIP
868
+ return SKIP;
672
869
  }
673
870
 
674
871
  // Collect and transform every prose unit in this subtree.
675
- const units = collectProseUnitsImpl(node, shouldSkip, 0, transformed, isTransformable)
872
+ const units = collectProseUnitsImpl(
873
+ node,
874
+ shouldSkip,
875
+ 0,
876
+ transformed,
877
+ isTransformable,
878
+ );
676
879
  for (const unit of units) {
677
880
  if (unit.kind === "element") {
678
881
  if (!transformed.has(unit.element)) {
679
882
  for (const view of proseViewsOf(unit.element, viewOptions)) {
680
- transformView(view, transformOptions)
883
+ transformView(view, transformOptions);
681
884
  }
682
- transformed.add(unit.element)
885
+ transformed.add(unit.element);
683
886
  // Mark all descendants as processed since their text was included
684
- markDescendants(unit.element, transformed)
887
+ markDescendants(unit.element, transformed);
685
888
  }
686
889
  } else {
687
- const { nodes, opaqueBefore } = flattenRunProse(unit.container, unit.children, shouldSkip, viewOptions)
890
+ const { nodes, opaqueBefore } = flattenRunProse(
891
+ unit.container,
892
+ unit.children,
893
+ shouldSkip,
894
+ viewOptions,
895
+ );
688
896
  for (const group of splitAtIndices(nodes, opaqueBefore)) {
689
- transformView(buildProseView(group), transformOptions)
897
+ transformView(buildProseView(group), transformOptions);
690
898
  }
691
899
  // Mark the container so a later visit doesn't re-collect its runs,
692
900
  // and mark the run's inline-element members' subtrees.
693
- transformed.add(unit.container)
901
+ transformed.add(unit.container);
694
902
  for (const child of unit.children) {
695
903
  if (child.type === "element") {
696
- transformed.add(child)
697
- markDescendants(child, transformed)
904
+ transformed.add(child);
905
+ markDescendants(child, transformed);
698
906
  }
699
907
  }
700
908
  }
701
909
  }
702
- })
703
- }
910
+ });
911
+ };
704
912
  }
705
913
 
706
- export default rehypePunctilio
914
+ export default rehypePunctilio;