punctilio 5.0.10 → 5.1.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.
@@ -92,6 +92,17 @@ interface Item {
92
92
  end: number
93
93
  /** Set when punctuation placement relocates this item. */
94
94
  moved: boolean
95
+ /**
96
+ * Set on a CLOSE_SINGLE that has no matching opener, so punctuation
97
+ * placement must not move around it. Two cases qualify: a word-final quote
98
+ * directly after a letter, which reads as elision ("keep on truckin'") at
99
+ * least as plausibly as quotation — pulling the period into "truckin'."
100
+ * corrupts the word to "truckin.'" — and, in German, any orphan closer,
101
+ * because German re-derives its rendered glyph (U+2018) back to a straight
102
+ * quote by position on the next run, so relocating punctuation around it
103
+ * changes that re-derived shape and breaks the fixed point ("'06,'").
104
+ */
105
+ frozenCloser?: boolean
95
106
  /** Insertion anchor (clean-text offset and bind side) once moved. */
96
107
  anchorOffset: number
97
108
  anchorBind: "left" | "right"
@@ -126,7 +137,7 @@ const SINGLE_QUOTE_FAMILY = new Set<string>(["'", LEFT_SINGLE_QUOTE, RIGHT_SINGL
126
137
  /** Ending-context chars for single quotes (plus any \s char). The curly
127
138
  * doubles accompany the straight one: `"` curls into one of them in this same
128
139
  * pass, and an ending decision must survive the curl. */
129
- const SINGLE_ENDING_SET = new Set<string>([".", "!", "?", ";", ",", ")", EM_DASH, "-", "]", '"', LEFT_DOUBLE_QUOTE, RIGHT_DOUBLE_QUOTE, ELLIPSIS, ...FOLDED_TERMINALS])
140
+ const SINGLE_ENDING_SET = new Set<string>([".", "!", "?", ";", ":", ",", ")", EM_DASH, "-", "]", '"', LEFT_DOUBLE_QUOTE, RIGHT_DOUBLE_QUOTE, ELLIPSIS, ...FOLDED_TERMINALS])
130
141
 
131
142
  /** Boundary chars before an opening single quote (plus any \s char). A
132
143
  * straight double quote belongs with the curly pair: whichever way it curls
@@ -152,7 +163,7 @@ const DOUBLE_EMPTY_BEFORE_SET = new Set<string>(["(", "[", "{"])
152
163
  const DOUBLE_EMPTY_AFTER_SET = new Set<string>([")", "]", "}", ".", "!", "?", ",", ";", ":", "/", "-", "s", EM_DASH, ELLIPSIS, ...FOLDED_TERMINALS])
153
164
 
154
165
  /** Ending-context chars after a closing double quote (plus any \s char). */
155
- const DOUBLE_CLOSE_AFTER_SET = new Set<string>(["/", ")", ".", ",", ";", EM_DASH, ":", "-", "}", "!", "?", "s", ELLIPSIS, ...FOLDED_TERMINALS])
166
+ const DOUBLE_CLOSE_AFTER_SET = new Set<string>(["/", ")", "]", ".", ",", ";", EM_DASH, ":", "-", "}", "!", "?", "s", ELLIPSIS, ...FOLDED_TERMINALS])
156
167
 
157
168
  /** Builds an `(items, index)` predicate: in range, non-boundary, and `test(ch)`. */
158
169
  function makeItemTester(test: (ch: string) => boolean): (items: Item[], index: number) => boolean {
@@ -278,8 +289,13 @@ function buildItems(view: ProseView, style: ActiveQuoteStyle): Item[] {
278
289
  mapped = '"'
279
290
  } else if (ch === RIGHT_SINGLE_QUOTE || ch === MODIFIER_LETTER_APOSTROPHE) {
280
291
  // U+02BC renders as U+2019, which the next run re-derives by position;
281
- // re-derive it the same way now so both runs agree.
282
- mapped = "'"
292
+ // re-derive it the same way now so both runs agree. Directly after a
293
+ // digit the mark is a separator (Swiss "5’000"), never a German quote
294
+ // glyph: keep the apostrophe role, which renders U+2019 unchanged.
295
+ // Re-deriving to a straight quote there hands correct typography to
296
+ // the next run's prime pass ("5'000" → "5′000") — a destroyed
297
+ // separator and a broken fixed point.
298
+ mapped = DIGIT_RE.test(text[i - 1] ?? "") ? MODIFIER_LETTER_APOSTROPHE : "'"
283
299
  }
284
300
  if (mapped !== null) {
285
301
  items.push(makeItem(false, mapped, i, i + 1))
@@ -517,9 +533,13 @@ function classifyOpeningSingles(items: Item[]): void {
517
533
 
518
534
  /**
519
535
  * Unmatched CLOSE_SINGLE after s/S → APOSTROPHE (plural possessives), via the
520
- * advisory open/close balance scan. Boundaries are fully transparent here.
536
+ * advisory open/close balance scan. An unmatched closer keeps its closer role
537
+ * but is flagged {@link Item.frozenCloser} so punctuation placement leaves it
538
+ * alone: after any other letter in every style, and after any character in
539
+ * German (which re-derives the rendered glyph by position on re-run).
540
+ * Boundaries are fully transparent here.
521
541
  */
522
- function classifyPluralPossessives(items: Item[]): void {
542
+ function classifyPluralPossessives(items: Item[], style: ActiveQuoteStyle): void {
523
543
  let balance = 0
524
544
  for (let i = 0; i < items.length; i++) {
525
545
  const item = items[i]
@@ -537,6 +557,8 @@ function classifyPluralPossessives(items: Item[]): void {
537
557
  while (j >= 0 && items[j].boundary) j--
538
558
  if (j >= 0 && (items[j].ch === "s" || items[j].ch === "S")) {
539
559
  item.ch = MODIFIER_LETTER_APOSTROPHE
560
+ } else if (j >= 0 && (isLetterItem(items, j) || style === "german")) {
561
+ item.frozenCloser = true
540
562
  }
541
563
  }
542
564
  }
@@ -558,7 +580,7 @@ function classifySingles(items: Item[], style: ActiveQuoteStyle): void {
558
580
  if (style !== "german") {
559
581
  while (classifyPossessivesAndClosers(items)) { /* to fixpoint */ }
560
582
  }
561
- classifyPluralPossessives(items)
583
+ classifyPluralPossessives(items, style)
562
584
  }
563
585
 
564
586
  // ---------------------------------------------------------------------------
@@ -674,8 +696,15 @@ function foldsToNotEqual(items: Item[], index: number): boolean {
674
696
  return !isCharItem(items, nextIndex(items, eq, 1), "=")
675
697
  }
676
698
 
677
- /** Opening double quote by following context. */
678
- function classifyOpeningDoubles(items: Item[]): void {
699
+ /**
700
+ * Opening double quote by following context.
701
+ *
702
+ * French: a plain space directly before a pre-labeled closer merges into the
703
+ * closer's rendered NNBSP padding once spaces collapse, so a re-run reads the
704
+ * closer itself directly after the candidate — read it that way now (the
705
+ * mirror of {@link classifyClosingDoubles}'s opener-side absorption).
706
+ */
707
+ function classifyOpeningDoubles(items: Item[], style: ActiveQuoteStyle): void {
679
708
  for (let i = 0; i < items.length; i++) {
680
709
  const item = items[i]
681
710
  if (item.boundary || item.ch !== '"') continue
@@ -693,8 +722,10 @@ function classifyOpeningDoubles(items: Item[]): void {
693
722
  const j = nextIndex(items, i, 1)
694
723
  if (j < items.length && !items[j].boundary) {
695
724
  const ch = items[j].ch
725
+ const absorbedIntoCloser = style === "french" && ch === " " && isCharItem(items, j + 1, RIGHT_DOUBLE_QUOTE)
696
726
  opens = isEllipsisDots(items, j) || foldsToNotEqual(items, j)
697
727
  || (!SPACE_RE.test(ch) && !DOUBLE_OPEN_ENDING_SET.has(ch))
728
+ || absorbedIntoCloser
698
729
  }
699
730
  }
700
731
  if (opens) item.ch = LEFT_DOUBLE_QUOTE
@@ -721,19 +752,37 @@ function quotedPunctuationPrefixOk(items: Item[], index: number): boolean {
721
752
  * Quoted-punctuation openers like `"?"`: an opener-prefixed straight double
722
753
  * quote with a closing straight double quote anywhere ahead (at least one
723
754
  * item between them). Decisions use the pass-start set of straight quotes.
755
+ *
756
+ * Inside an already-open double quote the same shape is that quote's closer,
757
+ * not a new opener: in `"un-" or "non-"` the quote after `un-` is
758
+ * opener-prefixed (a hyphen) with a straight quote ahead, but it closes the
759
+ * open `“un-`. A running depth of classified doubles — updated as this pass
760
+ * assigns roles, so each decision sees the ones before it — picks the reading.
724
761
  */
725
762
  function classifyQuotedPunctuationOpeners(items: Item[]): void {
726
763
  const straightIndices: number[] = []
727
764
  for (let i = 0; i < items.length; i++) {
728
765
  if (isCharItem(items, i, '"')) straightIndices.push(i)
729
766
  }
730
- for (let k = 0; k < straightIndices.length; k++) {
731
- const i = straightIndices[k]
732
- if (!quotedPunctuationPrefixOk(items, i)) continue
733
- const closer = straightIndices[k + 1]
734
- if (closer !== undefined && closer >= i + 2) {
735
- items[i].ch = LEFT_DOUBLE_QUOTE
767
+ let openDepth = 0
768
+ let k = 0
769
+ for (let i = 0; i < items.length; i++) {
770
+ const item = items[i]
771
+ if (item.boundary) continue
772
+ if (k < straightIndices.length && straightIndices[k] === i) {
773
+ // `i` is a pass-start straight quote; after k++, `straightIndices[k]`
774
+ // is the nearest pass-start straight quote ahead of it.
775
+ k++
776
+ const closer = straightIndices[k]
777
+ if (quotedPunctuationPrefixOk(items, i) && closer !== undefined && closer >= i + 2) {
778
+ item.ch = openDepth > 0 ? RIGHT_DOUBLE_QUOTE : LEFT_DOUBLE_QUOTE
779
+ }
736
780
  }
781
+ // Depth over classified doubles, including the role just assigned above.
782
+ // A still-straight double quote below an open `“` counts as its closer:
783
+ // the closing rules classify it that way after this pass ends.
784
+ if (item.ch === LEFT_DOUBLE_QUOTE) openDepth++
785
+ else if ((item.ch === RIGHT_DOUBLE_QUOTE || item.ch === '"') && openDepth > 0) openDepth--
737
786
  }
738
787
  }
739
788
 
@@ -804,7 +853,7 @@ function classifySinglesBeforeClosingDoubles(items: Item[]): void {
804
853
 
805
854
  function classifyDoubles(items: Item[], style: ActiveQuoteStyle): void {
806
855
  classifyEmptyDoublePairs(items)
807
- classifyOpeningDoubles(items)
856
+ classifyOpeningDoubles(items, style)
808
857
  classifyQuotedPunctuationOpeners(items)
809
858
  classifyBraceOpeners(items)
810
859
  classifyClosingDoubles(items, style)
@@ -826,6 +875,9 @@ function classifyDoubles(items: Item[], style: ActiveQuoteStyle): void {
826
875
  */
827
876
  function closingRunMemberAt(items: Item[], index: number, closingSet: ReadonlySet<string>): boolean {
828
877
  if (index >= items.length || items[index].boundary || !closingSet.has(items[index].ch)) return false
878
+ // A word-final closer with no opener behind it reads as elision; moving
879
+ // punctuation around it corrupts the word ("truckin'." → "truckin.'").
880
+ if (items[index].frozenCloser) return false
829
881
  if (items[index].ch !== MODIFIER_LETTER_APOSTROPHE) return true
830
882
  const prev = prevIndex(items, index, 1)
831
883
  return !isCharItem(items, prev, "s") && !isCharItem(items, prev, "S")
@@ -960,6 +1012,13 @@ function movePunctuationInside(order: Item[], isMovable: (items: Item[], index:
960
1012
  position++
961
1013
  continue
962
1014
  }
1015
+ // A space directly before the run marks an exact-string quote (`"New ",`):
1016
+ // its content ends in whitespace on purpose, and pulling the punctuation
1017
+ // inside would attach it to that verbatim trailing space.
1018
+ if (beforeIndex >= 0 && !order[beforeIndex].boundary && SPACE_RE.test(order[beforeIndex].ch)) {
1019
+ position++
1020
+ continue
1021
+ }
963
1022
  // Moving the punctuation inside strips the run of its ending context: an
964
1023
  // unresolved straight single quote earlier in the text whose closer-ahead
965
1024
  // scan reads a single closer in this run would stop seeing it as a closer
@@ -1381,7 +1440,7 @@ function quotePassClosesCandidate(items: Item[], index: number, quote: string):
1381
1440
  * a prime only when the running quote balance is open-free; contractions are
1382
1441
  * skipped and trailing apostrophes consume balance without converting.
1383
1442
  */
1384
- function convertPrimes(items: Item[]): void {
1443
+ function convertPrimes(items: Item[], swissSeparators: ReadonlySet<number>): void {
1385
1444
  const passes: [string, string][] = [
1386
1445
  ["'", PRIME],
1387
1446
  ['"', DOUBLE_PRIME],
@@ -1391,6 +1450,12 @@ function convertPrimes(items: Item[]): void {
1391
1450
  for (let i = 0; i < items.length; i++) {
1392
1451
  const item = items[i]
1393
1452
  if (item.boundary || item.ch !== quote) continue
1453
+ // A Swiss/Liechtenstein digit-group separator (5'000) is an apostrophe,
1454
+ // not a foot/minute prime; curl it and leave the quote balance untouched.
1455
+ if (quote === "'" && swissSeparators.has(item.start)) {
1456
+ item.ch = RIGHT_SINGLE_QUOTE
1457
+ continue
1458
+ }
1394
1459
  const digitIndex = prevIndex(items, i, 1)
1395
1460
  if (isDigitItem(items, digitIndex) && !isLetterItem(items, i + 1)) {
1396
1461
  // Prime candidate. The after-context check is boundary-blind: a
@@ -1426,12 +1491,38 @@ function convertPrimes(items: Item[]): void {
1426
1491
  }
1427
1492
  }
1428
1493
 
1494
+ /**
1495
+ * Every conversion in {@link convertPrimes} keys on a digit item directly
1496
+ * before the quote, and boundary items are zero-width, so clean text without
1497
+ * a digit-quote pair can never change. Checking that up front skips the full
1498
+ * per-character item build on the (overwhelmingly common) digit-free prose.
1499
+ */
1500
+ const PRIME_CANDIDATE_RE = /\d['"]/
1501
+
1502
+ // Swiss/Liechtenstein thousands grouping: 5'000, 1'000'000, 5'000.50. Leading
1503
+ // group of 1-3 digits, then one or more apostrophe-separated groups of exactly
1504
+ // three, not embedded in a longer digit run. Groups of exactly three rule out
1505
+ // feet-inches (5'10", 6'2"), whose inch value is one or two digits.
1506
+ const SWISS_THOUSANDS_RE = /(?<![\d'])\d{1,3}(?:'\d{3})+(?!\d)/g
1507
+
1508
+ /** Offsets of the apostrophes that separate Swiss thousands groups in `text`. */
1509
+ function swissThousandsSeparators(text: string): ReadonlySet<number> {
1510
+ const offsets = new Set<number>()
1511
+ if (!text.includes("'")) return offsets
1512
+ for (const match of text.matchAll(SWISS_THOUSANDS_RE)) {
1513
+ for (let i = 0; i < match[0].length; i++) {
1514
+ if (match[0][i] === "'") offsets.add(match.index + i)
1515
+ }
1516
+ }
1517
+ return offsets
1518
+ }
1519
+
1429
1520
  /** primeMarks engine: converts `5'10"` to `5′10″` with quote-balance guards. Commits its edits. */
1430
1521
  export function convertPrimeMarks(view: ProseView): void {
1431
1522
  const text = view.text
1432
- if (!text.includes("'") && !text.includes('"')) return
1523
+ if (!PRIME_CANDIDATE_RE.test(text)) return
1433
1524
  const items = buildItems(view, "american")
1434
- convertPrimes(items)
1525
+ convertPrimes(items, swissThousandsSeparators(text))
1435
1526
  for (const item of items) {
1436
1527
  if (!item.boundary && item.ch !== text[item.start]) {
1437
1528
  view.replace(item.start, item.end, item.ch)
package/src/rehype.ts CHANGED
@@ -7,7 +7,7 @@ import { transformView } from "./index.js"
7
7
  import { TRANSFORM_OPTION_KEYS, type TransformOptions } from "./transform-options.js"
8
8
  import { MAX_RECURSION_DEPTH } from "./constants.js"
9
9
  import { assertKnownOptionKeys, formatErrorString } from "./utils.js"
10
- import { buildProseView, type ProsePass, type ProseView } from "./prose-view.js"
10
+ import { buildProseView, type ProsePass, type ProseView, splitAtIndices } from "./prose-view.js"
11
11
 
12
12
  export type ElementPredicate = (node: Element) => boolean
13
13
 
@@ -80,53 +80,100 @@ export const REHYPE_ONLY_OPTION_KEYS: readonly string[] = ["skipTags", "skipClas
80
80
  /** Runtime list of valid `rehypePunctilio` option keys. */
81
81
  export const REHYPE_OPTION_KEYS: readonly string[] = [...TRANSFORM_OPTION_KEYS, ...REHYPE_ONLY_OPTION_KEYS]
82
82
 
83
- export function flattenTextNodes(
84
- node: Element | ElementContent,
85
- shouldSkip: ElementPredicate,
86
- options?: ElementTransformOptions
87
- ): Text[] {
88
- const shouldSkipText = options?.shouldSkipText
89
- // Only track ancestors when shouldSkipText needs them, to avoid the
90
- // O(n²) allocation cost of copying the ancestor chain at every level.
91
- const ancestors: Element[] | null = shouldSkipText ? [] : null
92
- return flattenTextNodesImpl(node, shouldSkip, shouldSkipText, ancestors, 0)
83
+ // Void/replaced inline elements that render atomic content (or a hard break)
84
+ // and carry no transformable text. When one sits between two text nodes it is a
85
+ // real visual separator, so the flattener records an opaque gap there.
86
+ const OPAQUE_VOID_TAGS = new Set([
87
+ "img", "br", "wbr", "input", "hr", "embed",
88
+ "area", "source", "track", "col", "param", "keygen",
89
+ ])
90
+
91
+ /** Flattened text nodes plus the opaque gaps that fell between adjacent ones. */
92
+ export interface FlattenedProse {
93
+ nodes: Text[]
94
+ /** Indices into `nodes` (1..n-1) with removed opaque content immediately before them. */
95
+ opaqueBefore: Set<number>
93
96
  }
94
97
 
95
- function flattenTextNodesImpl(
96
- node: Element | ElementContent,
97
- shouldSkip: ElementPredicate,
98
- shouldSkipText: TextNodeSkipPredicate | undefined,
99
- ancestors: Element[] | null,
100
- depth: number
101
- ): Text[] {
98
+ interface ProseCollector {
99
+ nodes: Text[]
100
+ opaqueBefore: Set<number>
101
+ shouldSkip: ElementPredicate
102
+ shouldSkipText: TextNodeSkipPredicate | undefined
103
+ ancestors: Element[] | null
104
+ // An opaque element was dropped since the last emitted text node; the next
105
+ // emitted node records an opaque gap before it.
106
+ pendingOpaque: boolean
107
+ }
108
+
109
+ function collectProse(node: Element | ElementContent, depth: number, c: ProseCollector): void {
102
110
  if (depth > MAX_RECURSION_DEPTH) {
103
- return []
111
+ return
104
112
  }
105
113
 
106
- if (node.type === "element" && shouldSkip(node)) {
107
- return []
114
+ if (node.type === "element") {
115
+ if (c.shouldSkip(node)) {
116
+ // A skipped element with content is a visual separator; an empty one
117
+ // renders nothing and leaves its neighbors genuinely adjacent.
118
+ if (node.children.length > 0) c.pendingOpaque = true
119
+ return
120
+ }
121
+ if (OPAQUE_VOID_TAGS.has(node.tagName)) {
122
+ c.pendingOpaque = true
123
+ return
124
+ }
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
108
129
  }
109
130
 
110
131
  if (node.type === "text") {
111
132
  // Snapshot ancestors at the callback boundary — the walker mutates the
112
133
  // shared array as it recurses, so handing it out directly would let a
113
134
  // caller observe a stale view if they captured the reference.
114
- if (shouldSkipText && ancestors && shouldSkipText(node, ancestors.slice())) {
115
- return []
135
+ if (c.shouldSkipText && c.ancestors && c.shouldSkipText(node, c.ancestors.slice())) {
136
+ // Preserved-but-untransformed text is a separator between its neighbors.
137
+ c.pendingOpaque = true
138
+ return
116
139
  }
117
- return [node]
140
+ if (c.pendingOpaque && c.nodes.length > 0) c.opaqueBefore.add(c.nodes.length)
141
+ c.pendingOpaque = false
142
+ c.nodes.push(node)
118
143
  }
144
+ }
119
145
 
120
- if (node.type === "element") {
121
- if (ancestors) ancestors.push(node)
122
- const result = node.children.flatMap((child) =>
123
- flattenTextNodesImpl(child, shouldSkip, shouldSkipText, ancestors, depth + 1)
124
- )
125
- if (ancestors) ancestors.pop()
126
- return result
146
+ function newCollector(shouldSkip: ElementPredicate, options?: ElementTransformOptions): ProseCollector {
147
+ const shouldSkipText = options?.shouldSkipText
148
+ return {
149
+ nodes: [],
150
+ opaqueBefore: new Set(),
151
+ shouldSkip,
152
+ shouldSkipText,
153
+ // Only track ancestors when shouldSkipText needs them, to avoid the
154
+ // O(n²) allocation cost of copying the ancestor chain at every level.
155
+ ancestors: shouldSkipText ? [] : null,
156
+ pendingOpaque: false,
127
157
  }
158
+ }
128
159
 
129
- return []
160
+ /** Flattens an element's transformable text nodes, tracking opaque gaps. */
161
+ export function flattenProse(
162
+ node: Element | ElementContent,
163
+ shouldSkip: ElementPredicate,
164
+ options?: ElementTransformOptions
165
+ ): FlattenedProse {
166
+ const c = newCollector(shouldSkip, options)
167
+ collectProse(node, 0, c)
168
+ return { nodes: c.nodes, opaqueBefore: c.opaqueBefore }
169
+ }
170
+
171
+ export function flattenTextNodes(
172
+ node: Element | ElementContent,
173
+ shouldSkip: ElementPredicate,
174
+ options?: ElementTransformOptions
175
+ ): Text[] {
176
+ return flattenProse(node, shouldSkip, options).nodes
130
177
  }
131
178
 
132
179
  export function getTextContent(
@@ -191,9 +238,11 @@ export interface ProseViewOfOptions extends ElementTransformOptions {
191
238
  }
192
239
 
193
240
  /**
194
- * Builds a ProseView over the element's transformable text nodes, honoring
195
- * the element-level `shouldSkip` and per-text-node `shouldSkipText`
196
- * predicates. Returns null when the element holds no transformable text.
241
+ * Builds a single ProseView over the element's transformable text nodes,
242
+ * honoring the element-level `shouldSkip` and per-text-node `shouldSkipText`
243
+ * predicates. Returns null when the element holds no transformable text. This
244
+ * view spans opaque gaps; the transform pipeline uses {@link proseViewsOf},
245
+ * which splits on them so no pass rewrites across removed atomic content.
197
246
  */
198
247
  export function proseViewOf(element: Element, options: ProseViewOfOptions = {}): ProseView | null {
199
248
  /* istanbul ignore if -- defensive: elements should always have children array */
@@ -210,6 +259,22 @@ export function proseViewOf(element: Element, options: ProseViewOfOptions = {}):
210
259
  return buildProseView(textNodes)
211
260
  }
212
261
 
262
+ /**
263
+ * Builds one ProseView per opaque-delimited segment of the element's text.
264
+ * Splitting at opaque gaps (removed skipped elements, images, and other atomic
265
+ * inline content) keeps their surrounding text in separate views, so a pass can
266
+ * never treat text as adjacent across content that visually separates it.
267
+ */
268
+ export function proseViewsOf(element: Element, options: ProseViewOfOptions = {}): ProseView[] {
269
+ /* istanbul ignore if -- defensive: elements should always have children array */
270
+ if (!element?.children) {
271
+ return []
272
+ }
273
+ const shouldSkip = options.shouldSkip ?? (() => false)
274
+ const { nodes, opaqueBefore } = flattenProse(element, shouldSkip, options)
275
+ return splitAtIndices(nodes, opaqueBefore).map((group) => buildProseView(group))
276
+ }
277
+
213
278
  /**
214
279
  * One entry in an `applyPasses` sequence: a bare pass, or a pass with its own
215
280
  * skip predicates layered on top of the base options (the per-transform
@@ -255,7 +320,7 @@ export function applyPasses(
255
320
  passes: readonly PassEntry[],
256
321
  options: ProseViewOfOptions = {},
257
322
  ): void {
258
- let currentView: ProseView | null = null
323
+ let currentViews: ProseView[] = []
259
324
  let currentPredicates: Pick<ResolvedPassEntry, "shouldSkip" | "shouldSkipText"> | null = null
260
325
 
261
326
  for (const entry of passes) {
@@ -266,20 +331,18 @@ export function applyPasses(
266
331
  currentPredicates.shouldSkip === shouldSkip &&
267
332
  currentPredicates.shouldSkipText === shouldSkipText
268
333
  if (!samePredicates) {
269
- currentView = proseViewOf(element, {
334
+ currentViews = proseViewsOf(element, {
270
335
  shouldSkip: mergePredicates(options.shouldSkip, shouldSkip),
271
336
  shouldSkipText: mergePredicates(options.shouldSkipText, shouldSkipText),
272
337
  })
273
338
  currentPredicates = { shouldSkip, shouldSkipText }
274
339
  }
275
340
 
276
- // No transformable text under this entry's predicates; later entries may
277
- // still see text (their skip sets differ), so keep going.
278
- if (currentView === null) {
279
- continue
341
+ // Each opaque-delimited segment is transformed independently; an entry with
342
+ // no transformable text simply has no views to run over.
343
+ for (const view of currentViews) {
344
+ pass(view)
280
345
  }
281
-
282
- pass(currentView)
283
346
  }
284
347
  }
285
348
 
@@ -433,31 +496,47 @@ function resolveCollectOptions(options: CollectProseBlocksOptions): ResolvedColl
433
496
  }
434
497
  }
435
498
 
499
+ // A prose unit is either a whole transformable element (an inline-only leaf) or
500
+ // a "run" of consecutive inline siblings that sit alongside block-level
501
+ // children. Runs let a container's loose inline text transform independently of
502
+ // its block children instead of merging across the block boundary.
503
+ type ProseUnit =
504
+ | { kind: "element"; element: Element }
505
+ | { kind: "run"; container: Element; children: ElementContent[] }
506
+
436
507
  /**
437
- * Collects the elements under `root` (inclusive) whose text should be
508
+ * Collects the leaf elements under `root` (inclusive) whose text should be
438
509
  * transformed as one prose block: transformable elements with direct text or
439
510
  * inline-only text descendants. Elements with block-level children recurse so
440
- * each block transforms independently.
511
+ * each block transforms independently. Loose inline text mixed among block
512
+ * children is handled by the plugin as its own run and is not represented here.
441
513
  */
442
514
  export function collectProseBlocks(root: Element, options: CollectProseBlocksOptions = {}): Element[] {
443
515
  const { shouldSkip, isTransformable } = resolveCollectOptions(options)
444
- return collectProseBlocksImpl(root, shouldSkip, 0, undefined, isTransformable)
516
+ return collectProseUnitsImpl(root, shouldSkip, 0, undefined, isTransformable)
517
+ .flatMap((unit) => (unit.kind === "element" ? [unit.element] : []))
445
518
  }
446
519
 
447
- function collectProseBlocksImpl(
520
+ function runHasProse(children: readonly ElementContent[], shouldSkip: ElementPredicate): boolean {
521
+ return children.some(
522
+ (child) =>
523
+ (child.type === "text" && child.value.trim() !== "") ||
524
+ (child.type === "element" && hasTextDescendant(child, shouldSkip))
525
+ )
526
+ }
527
+
528
+ function collectProseUnitsImpl(
448
529
  node: Element,
449
530
  shouldSkip: ElementPredicate,
450
531
  depth: number,
451
532
  alreadyTransformed: ReadonlySet<Element> | undefined,
452
533
  isTransformable: (tagName: string) => boolean
453
- ): Element[] {
534
+ ): ProseUnit[] {
454
535
  /* istanbul ignore if -- defensive: prevents stack overflow from malicious HTML */
455
536
  if (depth > MAX_RECURSION_DEPTH) {
456
537
  return []
457
538
  }
458
539
 
459
- const results: Element[] = []
460
-
461
540
  if (shouldSkip(node) || alreadyTransformed?.has(node)) {
462
541
  return []
463
542
  }
@@ -470,31 +549,67 @@ function collectProseBlocksImpl(
470
549
  const hasDirectText = node.children.some(
471
550
  (child) => child.type === "text" && child.value.trim() !== ""
472
551
  )
473
- // Only check for block children and text descendants when there's no direct text
474
- // (short-circuit avoids unnecessary tree traversal)
475
- const hasBlockChildren = !hasDirectText && node.children.some(
552
+ const hasBlockChildren = node.children.some(
476
553
  (child) => child.type === "element" && BLOCK_ELEMENTS.has(child.tagName)
477
554
  )
478
- const hasTextDescendants = !hasDirectText && !hasBlockChildren &&
479
- hasTextDescendant(node, shouldSkip)
480
555
 
556
+ // Inline-only transformable node: a single leaf unit. `hasTextDescendant` is
557
+ // only walked when there's no direct text (short-circuit avoids the traversal).
481
558
  if (
482
559
  isTransformable(node.tagName) &&
483
- (hasDirectText || hasTextDescendants)
560
+ !hasBlockChildren &&
561
+ (hasDirectText || hasTextDescendant(node, shouldSkip))
484
562
  ) {
485
- results.push(node)
563
+ return [{ kind: "element", element: node }]
564
+ }
565
+
566
+ const units: ProseUnit[] = []
567
+ if (isTransformable(node.tagName) && hasBlockChildren) {
568
+ // Mixed content: split each maximal run of inline siblings from the block
569
+ // children so loose text transforms on its own, then recurse per block.
570
+ let run: ElementContent[] = []
571
+ const flushRun = () => {
572
+ if (runHasProse(run, shouldSkip)) units.push({ kind: "run", container: node, children: run })
573
+ run = []
574
+ }
575
+ for (const child of node.children) {
576
+ if (child.type === "element" && BLOCK_ELEMENTS.has(child.tagName)) {
577
+ flushRun()
578
+ for (const u of collectProseUnitsImpl(child, shouldSkip, depth + 1, alreadyTransformed, isTransformable)) {
579
+ units.push(u)
580
+ }
581
+ } else {
582
+ run.push(child)
583
+ }
584
+ }
585
+ flushRun()
486
586
  } else {
487
- // Recurse into children: either the node isn't transformable, has block
488
- // children that should be independent, or has no text to transform.
587
+ // Non-transformable, or transformable but textless: recurse into elements.
489
588
  for (const child of node.children) {
490
589
  if (child.type === "element") {
491
- const childResults = collectProseBlocksImpl(child, shouldSkip, depth + 1, alreadyTransformed, isTransformable)
492
- for (const r of childResults) results.push(r)
590
+ for (const u of collectProseUnitsImpl(child, shouldSkip, depth + 1, alreadyTransformed, isTransformable)) {
591
+ units.push(u)
592
+ }
493
593
  }
494
594
  }
495
595
  }
496
596
 
497
- return results
597
+ return units
598
+ }
599
+
600
+ // Flatten a run's inline siblings to their text nodes (with opaque gaps),
601
+ // seeding the ancestor chain with `container` so `shouldSkipText` sees the same
602
+ // ancestors it would when the whole element is flattened as a leaf.
603
+ function flattenRunProse(
604
+ container: Element,
605
+ children: readonly ElementContent[],
606
+ shouldSkip: ElementPredicate,
607
+ options: ProseViewOfOptions
608
+ ): FlattenedProse {
609
+ const c = newCollector(shouldSkip, options)
610
+ if (c.ancestors) c.ancestors.push(container)
611
+ for (const child of children) collectProse(child, 1, c)
612
+ return { nodes: c.nodes, opaqueBefore: c.opaqueBefore }
498
613
  }
499
614
 
500
615
  function markDescendants(node: Element, set: Set<Element>, depth: number = 0): void {
@@ -547,17 +662,32 @@ export function rehypePunctilio(
547
662
  return SKIP
548
663
  }
549
664
 
550
- // Collect and transform elements with text content
551
- const elementsToTransform = collectProseBlocksImpl(node, shouldSkip, 0, transformed, isTransformable)
552
- for (const elt of elementsToTransform) {
553
- if (!transformed.has(elt)) {
554
- const view = proseViewOf(elt, viewOptions)
555
- if (view !== null) {
556
- transformView(view, transformOptions)
665
+ // Collect and transform every prose unit in this subtree.
666
+ const units = collectProseUnitsImpl(node, shouldSkip, 0, transformed, isTransformable)
667
+ for (const unit of units) {
668
+ if (unit.kind === "element") {
669
+ if (!transformed.has(unit.element)) {
670
+ for (const view of proseViewsOf(unit.element, viewOptions)) {
671
+ transformView(view, transformOptions)
672
+ }
673
+ transformed.add(unit.element)
674
+ // Mark all descendants as processed since their text was included
675
+ markDescendants(unit.element, transformed)
676
+ }
677
+ } else {
678
+ const { nodes, opaqueBefore } = flattenRunProse(unit.container, unit.children, shouldSkip, viewOptions)
679
+ for (const group of splitAtIndices(nodes, opaqueBefore)) {
680
+ transformView(buildProseView(group), transformOptions)
681
+ }
682
+ // Mark the container so a later visit doesn't re-collect its runs,
683
+ // and mark the run's inline-element members' subtrees.
684
+ transformed.add(unit.container)
685
+ for (const child of unit.children) {
686
+ if (child.type === "element") {
687
+ transformed.add(child)
688
+ markDescendants(child, transformed)
689
+ }
557
690
  }
558
- transformed.add(elt)
559
- // Mark all descendants as processed since their text was included
560
- markDescendants(elt, transformed)
561
691
  }
562
692
  }
563
693
  })