punctilio 5.4.9 → 5.5.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.
Files changed (46) hide show
  1. package/README.md +6 -6
  2. package/dist/constants.d.ts +2 -0
  3. package/dist/constants.d.ts.map +1 -1
  4. package/dist/constants.js +3 -1
  5. package/dist/constants.js.map +1 -1
  6. package/dist/dashes.d.ts.map +1 -1
  7. package/dist/dashes.js +3 -3
  8. package/dist/dashes.js.map +1 -1
  9. package/dist/nbsp.d.ts.map +1 -1
  10. package/dist/nbsp.js +26 -39
  11. package/dist/nbsp.js.map +1 -1
  12. package/dist/prettier-plugin.d.ts.map +1 -1
  13. package/dist/prettier-plugin.js +3 -2
  14. package/dist/prettier-plugin.js.map +1 -1
  15. package/dist/prose-view.d.ts +26 -0
  16. package/dist/prose-view.d.ts.map +1 -1
  17. package/dist/prose-view.js +87 -1
  18. package/dist/prose-view.js.map +1 -1
  19. package/dist/quote-classifier.d.ts.map +1 -1
  20. package/dist/quote-classifier.js +67 -20
  21. package/dist/quote-classifier.js.map +1 -1
  22. package/dist/rehype.d.ts +14 -8
  23. package/dist/rehype.d.ts.map +1 -1
  24. package/dist/rehype.js +36 -23
  25. package/dist/rehype.js.map +1 -1
  26. package/dist/remark.d.ts.map +1 -1
  27. package/dist/remark.js +71 -11
  28. package/dist/remark.js.map +1 -1
  29. package/dist/symbols.d.ts.map +1 -1
  30. package/dist/symbols.js +7 -3
  31. package/dist/symbols.js.map +1 -1
  32. package/dist/transform-options.d.ts +3 -2
  33. package/dist/transform-options.d.ts.map +1 -1
  34. package/dist/transform-options.js +6 -0
  35. package/dist/transform-options.js.map +1 -1
  36. package/package.json +3 -2
  37. package/src/constants.ts +4 -1
  38. package/src/dashes.ts +3 -2
  39. package/src/nbsp.ts +26 -40
  40. package/src/prettier-plugin.ts +3 -2
  41. package/src/prose-view.ts +99 -1
  42. package/src/quote-classifier.ts +68 -19
  43. package/src/rehype.ts +64 -36
  44. package/src/remark.ts +85 -12
  45. package/src/symbols.ts +8 -4
  46. package/src/transform-options.ts +11 -2
package/src/prose-view.ts CHANGED
@@ -35,6 +35,17 @@ function splitsSurrogatePair(text: string, offset: number): boolean {
35
35
  return high >= 0xd800 && high <= 0xdbff && low >= 0xdc00 && low <= 0xdfff;
36
36
  }
37
37
 
38
+ /**
39
+ * The scan index one position past `index`: one code point for a unicode-mode
40
+ * (u/v) regex, else one code unit. Resuming a unicode-mode regex inside a
41
+ * surrogate pair makes `exec` back up to the pair's start and re-match there
42
+ * forever.
43
+ */
44
+ export function advanceScanIndex(text: string, index: number, regex: RegExp): number {
45
+ const unicodeMode = regex.unicode || regex.flags.includes("v");
46
+ return index + (unicodeMode && splitsSurrogatePair(text, index + 1) ? 2 : 1);
47
+ }
48
+
38
49
  class ProseViewImpl implements ProseView {
39
50
  private readonly nodes: ProseNode[];
40
51
  private cachedText: string;
@@ -262,6 +273,93 @@ export function splitAtIndices<T>(
262
273
  return groups;
263
274
  }
264
275
 
276
+ /**
277
+ * Stand-in for removed opaque inline content (a skipped element, an image,
278
+ * inline code) inside a spanning view. U+FFFC OBJECT REPLACEMENT CHARACTER is
279
+ * Unicode's placeholder for an embedded object: it is neither space, word
280
+ * character, nor punctuation, so a pass needing adjacent text never matches
281
+ * across it (`5<code>c</code>x 3` stays put), while the quote classifier still
282
+ * sees real content between a quote and its partner (`"<kbd>q</kbd>"` pairs).
283
+ */
284
+ export const OPAQUE_PLACEHOLDER = "\uFFFC";
285
+
286
+ /**
287
+ * Single-quote edge stand-ins. The classifier reads end of text as an ending
288
+ * context after a closing single quote and as an opening context before an
289
+ * opening one, but reads {@link OPAQUE_PLACEHOLDER} as neither, so `'hi'` or
290
+ * `boys'` directly before a gap, or `'hi'` directly after one, would lose the
291
+ * role the segment edge gives it. A `)` after the quote and a `(` before it
292
+ * restore those contexts without blocking a pair across the gap
293
+ * (`'<code>x</code>'` still pairs). They are added only beside a single quote:
294
+ * double quotes read the placeholder correctly, and `)` would block a double
295
+ * opener.
296
+ */
297
+ const SEGMENT_END_STAND_IN = ")";
298
+ const SEGMENT_START_STAND_IN = "(";
299
+
300
+ /** Straight, curly, modifier-letter and low-9 single-quote characters. */
301
+ const SINGLE_QUOTE_CHARS: ReadonlySet<string> = new Set([
302
+ "'",
303
+ "\u2018",
304
+ "\u2019",
305
+ "\u02BC",
306
+ "\u201A",
307
+ ]);
308
+
309
+ /** Gap stand-in for the text between `before` and `after`. */
310
+ function gapPlaceholder(before: string, after: string): string {
311
+ const afterQuote = SINGLE_QUOTE_CHARS.has(before.slice(-1));
312
+ const beforeQuote = SINGLE_QUOTE_CHARS.has(after.slice(0, 1));
313
+ return (
314
+ (afterQuote ? SEGMENT_END_STAND_IN : "") +
315
+ OPAQUE_PLACEHOLDER +
316
+ (beforeQuote ? SEGMENT_START_STAND_IN : "")
317
+ );
318
+ }
319
+
320
+ /**
321
+ * Runs `run` over `nodes` as one view with a placeholder node (see
322
+ * {@link OPAQUE_PLACEHOLDER}) at each opaque gap (`breakBefore`, as for
323
+ * {@link splitAtIndices}), so context-sensitive passes such as quote pairing
324
+ * read across the gap. The view is committed after `run`. If any edit lands in
325
+ * a placeholder (one that spans, replaces, or is inserted into the gap), the
326
+ * nodes are restored and `run` goes over each opaque-delimited segment on its
327
+ * own instead, so no edit ever lands in or through a gap.
328
+ */
329
+ export function runAcrossOpaqueGaps(
330
+ nodes: readonly ProseNode[],
331
+ breakBefore: ReadonlySet<number>,
332
+ run: (view: ProseView) => void,
333
+ ): void {
334
+ if (nodes.length === 0) return;
335
+ const originalValues = nodes.map((node) => node.value);
336
+ const placeholders: { node: ProseNode; value: string }[] = [];
337
+ const spanning: ProseNode[] = [];
338
+ nodes.forEach((node, index) => {
339
+ if (breakBefore.has(index)) {
340
+ const value = gapPlaceholder(nodes[index - 1].value, node.value);
341
+ const placeholder = { value };
342
+ placeholders.push({ node: placeholder, value });
343
+ spanning.push(placeholder);
344
+ }
345
+ spanning.push(node);
346
+ });
347
+
348
+ const spanningView = buildProseView(spanning);
349
+ run(spanningView);
350
+ spanningView.commit();
351
+ if (placeholders.every(({ node, value }) => node.value === value)) return;
352
+
353
+ nodes.forEach((node, index) => {
354
+ node.value = originalValues[index];
355
+ });
356
+ for (const group of splitAtIndices(nodes, breakBefore)) {
357
+ const segmentView = buildProseView(group);
358
+ run(segmentView);
359
+ segmentView.commit();
360
+ }
361
+ }
362
+
265
363
  export interface ReplaceAllOptions {
266
364
  /** Opt-in: allow a match that contains an interior node boundary. Default: such matches are skipped. */
267
365
  allowBoundaries?: (match: RegExpExecArray, view: ProseView) => boolean;
@@ -382,7 +480,7 @@ export function replaceAllInView(
382
480
  let match: RegExpExecArray | null;
383
481
  while ((match = regex.exec(text)) !== null) {
384
482
  if (match[0].length === 0) {
385
- regex.lastIndex += 1;
483
+ regex.lastIndex = advanceScanIndex(text, regex.lastIndex, regex);
386
484
  }
387
485
 
388
486
  if (matchContainsBoundary(match, view) && !allowBoundaries?.(match, view)) {
@@ -1,4 +1,4 @@
1
- import { FOLDED_WORD_CHARS, LATIN_LETTER_RE, SPACE_CHAR_RE, TERMINAL_PUNCTUATION, UNICODE_SYMBOLS, WORD_RE } from "./constants.js"
1
+ import { CLOSING_BRACKETS, FOLDED_WORD_CHARS, LATIN_LETTER_RE, OPENING_BRACKETS, SPACE_CHAR_RE, TERMINAL_PUNCTUATION, UNICODE_SYMBOLS, WORD_RE } from "./constants.js"
2
2
  import type { ProseView } from "./prose-view.js"
3
3
 
4
4
  const {
@@ -14,6 +14,8 @@ const {
14
14
  SINGLE_LOW_9_QUOTE,
15
15
  LEFT_GUILLEMET,
16
16
  RIGHT_GUILLEMET,
17
+ INVERTED_EXCLAMATION,
18
+ INVERTED_QUESTION,
17
19
  NBSP,
18
20
  NNBSP,
19
21
  PRIME,
@@ -103,6 +105,12 @@ interface Item {
103
105
  * changes that re-derived shape and breaks the fixed point ("'06,'").
104
106
  */
105
107
  frozenCloser?: boolean
108
+ /**
109
+ * Set on a German item whose curly source glyph was re-derived to a straight
110
+ * candidate by position. If classification assigns it no role, it renders as
111
+ * this glyph: the style never downgrades curly text to straight.
112
+ */
113
+ curlySource?: string
106
114
  /** Insertion anchor (clean-text offset and bind side) once moved. */
107
115
  anchorOffset: number
108
116
  anchorBind: "left" | "right"
@@ -143,10 +151,11 @@ const SINGLE_ENDING_SET = new Set<string>([".", "!", "?", ";", ":", ",", ")", EM
143
151
  * straight double quote belongs with the curly pair: whichever way it curls
144
152
  * lands in this set, so deciding as if it already had keeps re-runs (which
145
153
  * see the curled glyph) consistent. */
146
- const SINGLE_OPEN_BEFORE_SET = new Set<string>([LEFT_DOUBLE_QUOTE, RIGHT_DOUBLE_QUOTE, '"', EM_DASH, "-", "("])
154
+ const SINGLE_OPEN_BEFORE_SET = new Set<string>([LEFT_DOUBLE_QUOTE, RIGHT_DOUBLE_QUOTE, '"', EM_DASH, "-", "(", "/", "[", "{", INVERTED_QUESTION, INVERTED_EXCLAMATION])
147
155
 
148
- /** Boundary chars before an opening double quote (plus any \s char). */
149
- const DOUBLE_OPEN_BEFORE_SET = new Set<string>(["(", "/", "[", "{", "-", EM_DASH])
156
+ /** Boundary chars before an opening double quote (plus any \s char). A
157
+ * single opener qualifies: a double quoted directly inside a single. */
158
+ const DOUBLE_OPEN_BEFORE_SET = new Set<string>(["(", "/", "[", "{", "-", EM_DASH, LEFT_SINGLE_QUOTE, INVERTED_QUESTION, INVERTED_EXCLAMATION])
150
159
 
151
160
  /** Ending-context chars that block the opener rule's arm 2 (plus any \s char).
152
161
  * ELLIPSIS is deliberately absent: the rule's `...` arm opens, so `…` must too. */
@@ -160,10 +169,10 @@ const DOUBLE_EMPTY_BEFORE_SET = new Set<string>(["(", "[", "{"])
160
169
  * rule rejects falls through to opener/closer rules whose after-contexts are
161
170
  * the closer set, and the two routes must accept the same shapes or a re-run
162
171
  * (which sees the rendered opener, not the whitespace) classifies anew. */
163
- const DOUBLE_EMPTY_AFTER_SET = new Set<string>([")", "]", "}", ".", "!", "?", ",", ";", ":", "/", "-", "s", EM_DASH, ELLIPSIS, ...FOLDED_TERMINALS])
172
+ const DOUBLE_EMPTY_AFTER_SET = new Set<string>([")", "]", "}", ".", "!", "?", ",", ";", ":", "/", "-", "s", EM_DASH, ELLIPSIS, RIGHT_SINGLE_QUOTE, ...FOLDED_TERMINALS])
164
173
 
165
174
  /** Ending-context chars after a closing double quote (plus any \s char). */
166
- const DOUBLE_CLOSE_AFTER_SET = new Set<string>(["/", ")", "]", ".", ",", ";", EM_DASH, ":", "-", "}", "!", "?", "s", ELLIPSIS, ...FOLDED_TERMINALS])
175
+ const DOUBLE_CLOSE_AFTER_SET = new Set<string>(["/", ")", "]", ".", ",", ";", EM_DASH, ":", "-", "}", "!", "?", "s", ELLIPSIS, RIGHT_SINGLE_QUOTE, ...FOLDED_TERMINALS])
167
176
 
168
177
  /** Builds an `(items, index)` predicate: in range, non-boundary, and `test(ch)`. */
169
178
  function makeItemTester(test: (ch: string) => boolean): (items: Item[], index: number) => boolean {
@@ -298,7 +307,9 @@ function buildItems(view: ProseView, style: ActiveQuoteStyle): Item[] {
298
307
  mapped = DIGIT_RE.test(text[i - 1] ?? "") ? MODIFIER_LETTER_APOSTROPHE : "'"
299
308
  }
300
309
  if (mapped !== null) {
301
- items.push(makeItem(false, mapped, i, i + 1))
310
+ const item = makeItem(false, mapped, i, i + 1)
311
+ if (mapped === '"' || mapped === "'") item.curlySource = ch
312
+ items.push(item)
302
313
  i++
303
314
  continue
304
315
  }
@@ -346,16 +357,19 @@ function possessiveAfter(items: Item[], index: number): boolean {
346
357
  /**
347
358
  * `(?<=[^\s“'"])` — the closing/possessive lookbehind. A boundary directly
348
359
  * before the quote satisfies it (a boundary is an ordinary non-space char).
349
- * A straight double quote is excluded along with `“`: it may curl into an
350
- * opener later in this same pass, which puts the candidate in exactly the
351
- * nested-opener position the `“` exclusion covers, and deciding against it
352
- * now keeps re-runs (which see the curled glyph) consistent.
360
+ * A straight double quote in opener position is excluded along with `“`: it
361
+ * curls into an opener later in this same pass, which puts the candidate in
362
+ * exactly the nested-opener position the `“` exclusion covers, and deciding
363
+ * against it now keeps re-runs (which see the curled glyph) consistent. A
364
+ * straight double quote outside opener position curls into a closer (`”`,
365
+ * which passes), so it passes too (`'"Hello"'`).
353
366
  */
354
367
  function singleCloserBefore(items: Item[], index: number): boolean {
355
368
  if (index === 0) return false
356
369
  const prev = items[index - 1]
357
370
  if (prev.boundary) return true
358
- return !SPACE_RE.test(prev.ch) && prev.ch !== LEFT_DOUBLE_QUOTE && prev.ch !== "'" && prev.ch !== '"'
371
+ if (prev.ch === '"') return !doubleOpenerPrefixOk(items, index - 1)
372
+ return !SPACE_RE.test(prev.ch) && prev.ch !== LEFT_DOUBLE_QUOTE && prev.ch !== "'"
359
373
  }
360
374
 
361
375
  /** Letter directly before (no boundary) and letter after (one boundary transparent). */
@@ -588,7 +602,7 @@ function classifySingles(items: Item[], style: ActiveQuoteStyle): void {
588
602
  // ---------------------------------------------------------------------------
589
603
 
590
604
  /** Empty (`""`) and whitespace-only (`" "`) double-quote pairs (no boundaries allowed anywhere in or around the match). */
591
- function classifyEmptyDoublePairs(items: Item[]): void {
605
+ function classifyEmptyDoublePairs(items: Item[], style: ActiveQuoteStyle): void {
592
606
  const beforeOk = (index: number): boolean => {
593
607
  if (index < 0) return true
594
608
  const item = items[index]
@@ -597,7 +611,7 @@ function classifyEmptyDoublePairs(items: Item[]): void {
597
611
  const afterOk = (index: number): boolean => {
598
612
  if (index >= items.length) return true
599
613
  const item = items[index]
600
- return !item.boundary && (SPACE_RE.test(item.ch) || DOUBLE_EMPTY_AFTER_SET.has(item.ch))
614
+ return !item.boundary && (SPACE_RE.test(item.ch) || DOUBLE_EMPTY_AFTER_SET.has(item.ch) || apostropheEndsDouble(item.ch, style))
601
615
  }
602
616
  for (let i = 0; i < items.length - 1; i++) {
603
617
  if (!isCharItem(items, i, '"') || !isCharItem(items, i + 1, '"')) continue
@@ -839,6 +853,16 @@ function isDoubleCloseAfterChar(ch: string): boolean {
839
853
  return SPACE_RE.test(ch) || DOUBLE_CLOSE_AFTER_SET.has(ch)
840
854
  }
841
855
 
856
+ /**
857
+ * An apostrophe directly after a double quote ends it, as the closing single
858
+ * quote it renders as does. German is the exception: it re-derives the
859
+ * rendered apostrophe (U+2019) by position, so a re-run can read it as a
860
+ * closing single quote that the first pass, which saw an apostrophe, did not.
861
+ */
862
+ function apostropheEndsDouble(ch: string, style: ActiveQuoteStyle): boolean {
863
+ return ch === MODIFIER_LETTER_APOSTROPHE && style !== "german"
864
+ }
865
+
842
866
  /**
843
867
  * Closing double quote by ending context: a non-space/non-paren character (or
844
868
  * a boundary) before it, and an ending character, a boundary, or end of input
@@ -861,7 +885,7 @@ function classifyClosingDoubles(items: Item[], style: ActiveQuoteStyle): void {
861
885
  }
862
886
  const next = items[i + 1]
863
887
  if (foldsToNotEqual(items, i + 1)) continue
864
- if (next === undefined || next.boundary || isDoubleCloseAfterChar(next.ch)) {
888
+ if (next === undefined || next.boundary || isDoubleCloseAfterChar(next.ch) || apostropheEndsDouble(next.ch, style)) {
865
889
  item.ch = RIGHT_DOUBLE_QUOTE
866
890
  }
867
891
  }
@@ -889,8 +913,31 @@ function classifySinglesBeforeClosingDoubles(items: Item[]): void {
889
913
  }
890
914
  }
891
915
 
916
+ /**
917
+ * German reads its rendered glyphs by depth: a U+201C below an open „ closes
918
+ * it. A curly orphan that kept its source glyph (see {@link Item.curlySource})
919
+ * below a quote opened in this same pass is therefore that quote's closer on
920
+ * the next run; label it one now so both runs agree. Singles need no
921
+ * counterpart: a single opener is only recognized when a closer follows it
922
+ * with no straight single in between, and an orphan U+2018 is one.
923
+ */
924
+ function closeCurlyOrphansBelowOpeners(items: Item[]): void {
925
+ let depth = 0
926
+ for (const item of items) {
927
+ if (item.boundary) continue
928
+ if (item.ch === LEFT_DOUBLE_QUOTE) {
929
+ depth++
930
+ } else if (item.ch === RIGHT_DOUBLE_QUOTE) {
931
+ if (depth > 0) depth--
932
+ } else if (item.ch === '"' && item.curlySource === LEFT_DOUBLE_QUOTE && depth > 0) {
933
+ item.ch = RIGHT_DOUBLE_QUOTE
934
+ depth--
935
+ }
936
+ }
937
+ }
938
+
892
939
  function classifyDoubles(items: Item[], style: ActiveQuoteStyle): void {
893
- classifyEmptyDoublePairs(items)
940
+ classifyEmptyDoublePairs(items, style)
894
941
  classifyOpeningDoubles(items, style)
895
942
  classifyQuotedPunctuationOpeners(items)
896
943
  classifyBraceOpeners(items)
@@ -987,11 +1034,11 @@ function applyRelocations(order: Item[], moves: Relocation[]): Item[] {
987
1034
  return result
988
1035
  }
989
1036
 
990
- const OPEN_BRACKETS = new Set<string>(["(", "[", "{"])
991
- const CLOSE_BRACKETS = new Set<string>([")", "]", "}"])
1037
+ const OPEN_BRACKETS = new Set<string>(OPENING_BRACKETS)
1038
+ const CLOSE_BRACKETS = new Set<string>(CLOSING_BRACKETS)
992
1039
 
993
1040
  /**
994
- * Unmatched open brackets (`(`, `[`, `{`) strictly before each item — a
1041
+ * Unmatched open brackets (see {@link OPENING_BRACKETS}) strictly before each item — a
995
1042
  * position is inside a bracket pair when its entry is > 0. Brackets never move
996
1043
  * during placement, so the depths hold across the fixpoint's rounds. Boundary
997
1044
  * items are transparent; a closer with no opener above it floors the depth at 0.
@@ -1463,6 +1510,7 @@ function queueRenderEdits(view: ProseView, items: Item[], table: Record<QuoteRol
1463
1510
  }
1464
1511
  continue
1465
1512
  }
1513
+ if (role === undefined && item.curlySource !== undefined) continue
1466
1514
  // Fast path for ordinary characters; pre-labeled items may have changed
1467
1515
  // `ch` without gaining a role (German orphan closers) and must still diff.
1468
1516
  if (role === undefined && item.ch === text[item.start] && item.end - item.start === 1) continue
@@ -1492,6 +1540,7 @@ export function classifyAndRenderQuotes(
1492
1540
  const items = buildItems(view, style)
1493
1541
  classifySingles(items, style)
1494
1542
  classifyDoubles(items, style)
1543
+ if (style === "german") closeCurlyOrphansBelowOpeners(items)
1495
1544
  const placed = applyPunctuationPlacement(items, style, apostrophe)
1496
1545
  // Placement can slide a closer past punctuation until it sits directly after
1497
1546
  // an s/S (british/french move the closer left, out of "dogs.'" → "dogs'."),
package/src/rehype.ts CHANGED
@@ -21,6 +21,7 @@ import {
21
21
  buildProseView,
22
22
  type ProsePass,
23
23
  type ProseView,
24
+ runAcrossOpaqueGaps,
24
25
  splitAtIndices,
25
26
  } from "./prose-view.js";
26
27
 
@@ -45,10 +46,12 @@ export interface ElementTransformOptions {
45
46
  export interface RehypePunctilioOptions
46
47
  extends TransformOptions, ElementTransformOptions {
47
48
  /**
48
- * HTML tag names to skip when applying transformations.
49
- * Content inside these elements won't have formatting improvements applied.
49
+ * Additional HTML tag names to skip when applying transformations, on top of
50
+ * the always-skipped defaults ("code", "pre", "script", "style", "kbd",
51
+ * "var", "samp", "template", "math", "svg", "iframe"). Content inside these
52
+ * elements won't have formatting improvements applied.
50
53
  *
51
- * Default: ["code", "pre", "script", "style", "kbd", "var", "samp", "template", "math", "svg"]
54
+ * Default: []
52
55
  */
53
56
  skipTags?: string[];
54
57
 
@@ -73,7 +76,8 @@ export interface RehypePunctilioOptions
73
76
  transformAllElements?: boolean;
74
77
  }
75
78
 
76
- const DEFAULT_SKIP_TAGS = [
79
+ /** Tag names whose content is always left untransformed; user `skipTags` extend this list. */
80
+ export const DEFAULT_SKIP_TAGS: readonly string[] = [
77
81
  "code",
78
82
  "pre",
79
83
  "script",
@@ -84,6 +88,7 @@ const DEFAULT_SKIP_TAGS = [
84
88
  "template",
85
89
  "math",
86
90
  "svg",
91
+ "iframe",
87
92
  ];
88
93
 
89
94
  // Form elements whose text content is a literal control value, not prose.
@@ -99,7 +104,7 @@ const FORM_VALUE_TAGS = ["textarea", "input"];
99
104
  // Skipped in BOTH modes — the allowlist default does not reach it as its own
100
105
  // unit, but a transformable ancestor would otherwise pull its text into a
101
106
  // shared view and transform it.
102
- const RAW_TEXT_FORM_TAG = "textarea";
107
+ export const RAW_TEXT_FORM_TAG = "textarea";
103
108
 
104
109
  // `<select>` is not prose itself, but its `<option>` children are (and are
105
110
  // transformed under the default allowlist). Under `transformAllElements` we
@@ -311,9 +316,10 @@ export interface ProseViewOfOptions extends ElementTransformOptions {
311
316
  /**
312
317
  * Builds a single ProseView over the element's transformable text nodes,
313
318
  * honoring the element-level `shouldSkip` and per-text-node `shouldSkipText`
314
- * predicates. Returns null when the element holds no transformable text. This
315
- * view spans opaque gaps; the transform pipeline uses {@link proseViewsOf},
316
- * which splits on them so no pass rewrites across removed atomic content.
319
+ * predicates. Returns null when the element holds no transformable text. The
320
+ * view spans opaque gaps with only a boundary at each; the transform pipeline
321
+ * instead runs {@link runAcrossOpaqueGaps}, which holds each gap with a
322
+ * placeholder, and {@link proseViewsOf} gives one view per gap-free segment.
317
323
  */
318
324
  export function proseViewOf(
319
325
  element: Element,
@@ -588,8 +594,8 @@ function hasTextDescendant(
588
594
 
589
595
  export interface CollectProseBlocksOptions {
590
596
  /**
591
- * HTML tag names to skip. Default: the plugin's default skip list
592
- * ("code", "pre", "script", ...).
597
+ * HTML tag names to skip in addition to the plugin's default skip list
598
+ * ("code", "pre", "script", ...). Default: []
593
599
  */
594
600
  skipTags?: string[];
595
601
  /** CSS class names whose elements (and their subtrees) are skipped. Default: [] */
@@ -600,6 +606,16 @@ export interface CollectProseBlocksOptions {
600
606
  transformAllElements?: boolean;
601
607
  }
602
608
 
609
+ // An `<option>` with no `value` attribute submits its text as the value, so
610
+ // the text is a literal control value, not prose. HAST marks an absent
611
+ // property as either `undefined` or `null`.
612
+ function isValueOption(node: Element): boolean {
613
+ return (
614
+ node.tagName === "option" &&
615
+ (node.properties?.value === undefined || node.properties.value === null)
616
+ );
617
+ }
618
+
603
619
  /** Skip predicate and transformable-tag test resolved from the options. */
604
620
  interface ResolvedCollectOptions {
605
621
  shouldSkip: ElementPredicate;
@@ -610,7 +626,7 @@ function resolveCollectOptions(
610
626
  options: CollectProseBlocksOptions,
611
627
  ): ResolvedCollectOptions {
612
628
  const {
613
- skipTags = DEFAULT_SKIP_TAGS,
629
+ skipTags = [],
614
630
  skipClasses = [],
615
631
  shouldSkip,
616
632
  transformAllElements = false,
@@ -620,11 +636,11 @@ function resolveCollectOptions(
620
636
  // flattens its literal value; inverted mode additionally hard-skips the whole
621
637
  // form-value set (void `input`, which is opaque otherwise) on top of the user
622
638
  // skip-list.
623
- const skipTagSet = new Set(
624
- transformAllElements
625
- ? [...skipTags, ...FORM_VALUE_TAGS]
626
- : [...skipTags, RAW_TEXT_FORM_TAG],
627
- );
639
+ const skipTagSet = new Set([
640
+ ...DEFAULT_SKIP_TAGS,
641
+ ...skipTags,
642
+ ...(transformAllElements ? FORM_VALUE_TAGS : [RAW_TEXT_FORM_TAG]),
643
+ ]);
628
644
  const skipClassSet = new Set(skipClasses);
629
645
 
630
646
  const hasSkipClass = (node: Element): boolean => {
@@ -641,6 +657,7 @@ function resolveCollectOptions(
641
657
  return {
642
658
  shouldSkip: (node) =>
643
659
  skipTagSet.has(node.tagName) ||
660
+ isValueOption(node) ||
644
661
  hasSkipClass(node) ||
645
662
  (shouldSkip?.(node) ?? false),
646
663
  // Inverted mode transforms every non-skipped element except non-prose
@@ -837,6 +854,17 @@ function runViewOf(
837
854
  return nodes.length === 0 ? null : buildProseView(nodes);
838
855
  }
839
856
 
857
+ // Runs the full pipeline over flattened prose as one view, with each opaque gap
858
+ // held by a placeholder so quotes pair across it (see `runAcrossOpaqueGaps`).
859
+ function transformProse(
860
+ { nodes, opaqueBefore }: FlattenedProse,
861
+ transformOptions: TransformOptions,
862
+ ): void {
863
+ runAcrossOpaqueGaps(nodes, opaqueBefore, (view) =>
864
+ transformView(view, transformOptions),
865
+ );
866
+ }
867
+
840
868
  function markDescendants(
841
869
  node: Element,
842
870
  set: Set<Element>,
@@ -874,14 +902,10 @@ function transformRootProse(
874
902
  let run: ElementContent[] = [];
875
903
  const flushRun = () => {
876
904
  if (runHasProse(run, shouldSkip)) {
877
- const { nodes, opaqueBefore } = flattenRootRunProse(
878
- run,
879
- shouldSkip,
880
- viewOptions,
905
+ transformProse(
906
+ flattenRootRunProse(run, shouldSkip, viewOptions),
907
+ transformOptions,
881
908
  );
882
- for (const group of splitAtIndices(nodes, opaqueBefore)) {
883
- transformView(buildProseView(group), transformOptions);
884
- }
885
909
  for (const child of run) {
886
910
  if (child.type === "element") {
887
911
  transformed.add(child);
@@ -892,7 +916,8 @@ function transformRootProse(
892
916
  run = [];
893
917
  };
894
918
 
895
- // Only bare text and inline prose elements merge into a run. Block elements
919
+ // Only bare text and inline elements merge into a run: prose elements, plus
920
+ // opaque ones (`<kbd>`, `<img>`) that quotes must pair across. Block elements
896
921
  // and non-transformable structural wrappers (`<html>`/`<head>`/`<body>` under
897
922
  // a full-document parse, or a non-allowlisted element in default mode) are
898
923
  // boundaries left to the element visitor, so their subtrees keep the same
@@ -904,7 +929,9 @@ function transformRootProse(
904
929
  child.type === "comment" ||
905
930
  (child.type === "element" &&
906
931
  !BLOCK_ELEMENTS.has(child.tagName) &&
907
- isTransformable(child.tagName));
932
+ (isTransformable(child.tagName) ||
933
+ OPAQUE_VOID_TAGS.has(child.tagName) ||
934
+ shouldSkip(child)));
908
935
  if (isRunMember) {
909
936
  run.push(child);
910
937
  } else {
@@ -974,23 +1001,24 @@ export function rehypePunctilio(
974
1001
  for (const unit of units) {
975
1002
  if (unit.kind === "element") {
976
1003
  if (!transformed.has(unit.element)) {
977
- for (const view of proseViewsOf(unit.element, viewOptions)) {
978
- transformView(view, transformOptions);
979
- }
1004
+ transformProse(
1005
+ flattenProse(unit.element, shouldSkip, viewOptions),
1006
+ transformOptions,
1007
+ );
980
1008
  transformed.add(unit.element);
981
1009
  // Mark all descendants as processed since their text was included
982
1010
  markDescendants(unit.element, transformed);
983
1011
  }
984
1012
  } else {
985
- const { nodes, opaqueBefore } = flattenRunProse(
986
- unit.container,
987
- unit.children,
988
- shouldSkip,
989
- viewOptions,
1013
+ transformProse(
1014
+ flattenRunProse(
1015
+ unit.container,
1016
+ unit.children,
1017
+ shouldSkip,
1018
+ viewOptions,
1019
+ ),
1020
+ transformOptions,
990
1021
  );
991
- for (const group of splitAtIndices(nodes, opaqueBefore)) {
992
- transformView(buildProseView(group), transformOptions);
993
- }
994
1022
  // Mark the container so a later visit doesn't re-collect its runs,
995
1023
  // and mark the run's inline-element members' subtrees.
996
1024
  transformed.add(unit.container);