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.
- package/README.md +9 -8
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +45 -22
- package/dist/cli.js.map +1 -1
- package/dist/dashes.d.ts +4 -2
- package/dist/dashes.d.ts.map +1 -1
- package/dist/dashes.js +87 -56
- package/dist/dashes.js.map +1 -1
- package/dist/nbsp.d.ts.map +1 -1
- package/dist/nbsp.js +3 -7
- package/dist/nbsp.js.map +1 -1
- package/dist/prose-view.d.ts +18 -0
- package/dist/prose-view.d.ts.map +1 -1
- package/dist/prose-view.js +71 -26
- package/dist/prose-view.js.map +1 -1
- package/dist/quote-classifier.d.ts.map +1 -1
- package/dist/quote-classifier.js +103 -20
- package/dist/quote-classifier.js.map +1 -1
- package/dist/rehype.d.ts +23 -5
- package/dist/rehype.d.ts.map +1 -1
- package/dist/rehype.js +166 -62
- package/dist/rehype.js.map +1 -1
- package/dist/remark.d.ts.map +1 -1
- package/dist/remark.js +30 -12
- package/dist/remark.js.map +1 -1
- package/dist/symbols.d.ts.map +1 -1
- package/dist/symbols.js +74 -24
- package/dist/symbols.js.map +1 -1
- package/dist/utils.d.ts +0 -2
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +0 -4
- package/dist/utils.js.map +1 -1
- package/package.json +5 -5
- package/src/cli.ts +47 -19
- package/src/dashes.ts +88 -61
- package/src/nbsp.ts +2 -5
- package/src/prose-view.ts +78 -21
- package/src/quote-classifier.ts +110 -19
- package/src/rehype.ts +202 -72
- package/src/remark.ts +36 -20
- package/src/symbols.ts +65 -18
- package/src/utils.ts +0 -5
package/src/quote-classifier.ts
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
|
-
/**
|
|
678
|
-
|
|
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
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
const
|
|
734
|
-
if (
|
|
735
|
-
|
|
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 (!
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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"
|
|
107
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
)
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
277
|
-
//
|
|
278
|
-
|
|
279
|
-
|
|
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
|
|
516
|
+
return collectProseUnitsImpl(root, shouldSkip, 0, undefined, isTransformable)
|
|
517
|
+
.flatMap((unit) => (unit.kind === "element" ? [unit.element] : []))
|
|
445
518
|
}
|
|
446
519
|
|
|
447
|
-
function
|
|
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
|
-
):
|
|
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
|
-
|
|
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
|
-
|
|
560
|
+
!hasBlockChildren &&
|
|
561
|
+
(hasDirectText || hasTextDescendant(node, shouldSkip))
|
|
484
562
|
) {
|
|
485
|
-
|
|
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
|
-
//
|
|
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
|
|
492
|
-
|
|
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
|
|
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
|
|
551
|
-
const
|
|
552
|
-
for (const
|
|
553
|
-
if (
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
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
|
})
|