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.
- package/README.md +6 -6
- package/dist/constants.d.ts +2 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +3 -1
- package/dist/constants.js.map +1 -1
- package/dist/dashes.d.ts.map +1 -1
- package/dist/dashes.js +3 -3
- package/dist/dashes.js.map +1 -1
- package/dist/nbsp.d.ts.map +1 -1
- package/dist/nbsp.js +26 -39
- package/dist/nbsp.js.map +1 -1
- package/dist/prettier-plugin.d.ts.map +1 -1
- package/dist/prettier-plugin.js +3 -2
- package/dist/prettier-plugin.js.map +1 -1
- package/dist/prose-view.d.ts +26 -0
- package/dist/prose-view.d.ts.map +1 -1
- package/dist/prose-view.js +87 -1
- package/dist/prose-view.js.map +1 -1
- package/dist/quote-classifier.d.ts.map +1 -1
- package/dist/quote-classifier.js +67 -20
- package/dist/quote-classifier.js.map +1 -1
- package/dist/rehype.d.ts +14 -8
- package/dist/rehype.d.ts.map +1 -1
- package/dist/rehype.js +36 -23
- package/dist/rehype.js.map +1 -1
- package/dist/remark.d.ts.map +1 -1
- package/dist/remark.js +71 -11
- package/dist/remark.js.map +1 -1
- package/dist/symbols.d.ts.map +1 -1
- package/dist/symbols.js +7 -3
- package/dist/symbols.js.map +1 -1
- package/dist/transform-options.d.ts +3 -2
- package/dist/transform-options.d.ts.map +1 -1
- package/dist/transform-options.js +6 -0
- package/dist/transform-options.js.map +1 -1
- package/package.json +3 -2
- package/src/constants.ts +4 -1
- package/src/dashes.ts +3 -2
- package/src/nbsp.ts +26 -40
- package/src/prettier-plugin.ts +3 -2
- package/src/prose-view.ts +99 -1
- package/src/quote-classifier.ts +68 -19
- package/src/rehype.ts +64 -36
- package/src/remark.ts +85 -12
- package/src/symbols.ts +8 -4
- 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
|
|
483
|
+
regex.lastIndex = advanceScanIndex(text, regex.lastIndex, regex);
|
|
386
484
|
}
|
|
387
485
|
|
|
388
486
|
if (matchContainsBoundary(match, view) && !allowBoundaries?.(match, view)) {
|
package/src/quote-classifier.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
350
|
-
* opener later in this same pass, which puts the candidate in
|
|
351
|
-
* nested-opener position the `“` exclusion covers, and deciding
|
|
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
|
-
|
|
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 (
|
|
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
|
-
*
|
|
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: [
|
|
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
|
-
|
|
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.
|
|
315
|
-
* view spans opaque gaps; the transform pipeline
|
|
316
|
-
*
|
|
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
|
|
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 =
|
|
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
|
-
|
|
625
|
-
|
|
626
|
-
|
|
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
|
-
|
|
878
|
-
run,
|
|
879
|
-
|
|
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
|
|
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
|
-
|
|
978
|
-
|
|
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
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
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);
|