punctilio 5.4.8 → 5.4.10
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 +12 -2
- package/dist/cli.d.ts +5 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +156 -48
- package/dist/cli.js.map +1 -1
- 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/html.d.ts +4 -3
- package/dist/html.d.ts.map +1 -1
- package/dist/html.js +55 -17
- package/dist/html.js.map +1 -1
- package/dist/markdown.d.ts +1 -2
- package/dist/markdown.d.ts.map +1 -1
- package/dist/markdown.js +52 -5
- package/dist/markdown.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/peer-dependencies.d.ts +21 -0
- package/dist/peer-dependencies.d.ts.map +1 -0
- package/dist/peer-dependencies.js +40 -0
- package/dist/peer-dependencies.js.map +1 -0
- package/dist/prettier-plugin.d.ts.map +1 -1
- package/dist/prettier-plugin.js +1 -2
- package/dist/prettier-plugin.js.map +1 -1
- package/dist/prose-view.d.ts +7 -0
- package/dist/prose-view.d.ts.map +1 -1
- package/dist/prose-view.js +11 -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/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/dist/utils.d.ts +5 -0
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +10 -0
- package/dist/utils.js.map +1 -1
- package/package.json +4 -2
- package/src/cli.ts +180 -48
- package/src/constants.ts +4 -1
- package/src/dashes.ts +3 -2
- package/src/html.ts +60 -19
- package/src/markdown.ts +58 -9
- package/src/nbsp.ts +26 -40
- package/src/peer-dependencies.ts +58 -0
- package/src/prettier-plugin.ts +1 -2
- package/src/prose-view.ts +12 -1
- package/src/quote-classifier.ts +68 -19
- package/src/symbols.ts +8 -4
- package/src/transform-options.ts +11 -2
- package/src/utils.ts +14 -0
package/src/nbsp.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { cachedRegExp, LATIN_LETTER_RE, LATIN_LETTERS, NBSP_CHARS, SPACE_CHAR_RE, SPACE_CHARS, UNICODE_SYMBOLS } from "./constants.js"
|
|
2
|
-
import { boundaryCountAt, exceedsSingleBoundary, interiorBoundariesWithin, makeProsePass, overInput, type ProseView, replaceAllInView } from "./prose-view.js"
|
|
2
|
+
import { advanceScanIndex, boundaryCountAt, exceedsSingleBoundary, interiorBoundariesWithin, makeProsePass, overInput, type ProseView, replaceAllInView } from "./prose-view.js"
|
|
3
3
|
|
|
4
4
|
const {
|
|
5
5
|
NBSP,
|
|
@@ -19,7 +19,8 @@ const UNICODE_UPPERCASE = "\\p{Lu}"
|
|
|
19
19
|
// A word-forming character (any Unicode letter or number). Used for the
|
|
20
20
|
// abbreviation-family word-start guard, which must span the same alphabets the
|
|
21
21
|
// initial/honorific rules glue on (they key off `\p{Lu}`), so a non-Latin
|
|
22
|
-
// acronym like "СССР." is guarded exactly as "NASA." is
|
|
22
|
+
// acronym like "СССР." is guarded exactly as "NASA." is, and for the last-word
|
|
23
|
+
// rule's check that a word precedes the space.
|
|
23
24
|
const WORD_CHAR_RE = /[\p{L}\p{N}]/u
|
|
24
25
|
|
|
25
26
|
/** Only specific abbreviations are matched to avoid false positives. */
|
|
@@ -168,7 +169,10 @@ function nextWordGluesForward(text: string, start: number): boolean {
|
|
|
168
169
|
// glued onward.
|
|
169
170
|
export const nbspAfterShortWords = makeProsePass(nbspAfterShortWordsOverView)
|
|
170
171
|
|
|
171
|
-
|
|
172
|
+
// Returns the offsets of the spaces this pass turned into no-break spaces, so
|
|
173
|
+
// the last-word rule can tell its own pipeline's glue from the author's.
|
|
174
|
+
function nbspAfterShortWordsOverView(view: ProseView): ReadonlySet<number> {
|
|
175
|
+
const insertedGlue = new Set<number>()
|
|
172
176
|
const pattern = cachedRegExp(
|
|
173
177
|
`(?<=^|${SPACE}|${PUNCTUATION_OR_QUOTE}|>)(?<shortWord>[${LATIN_LETTERS}]{1,2})${SPACE}`,
|
|
174
178
|
"gmu"
|
|
@@ -191,7 +195,9 @@ function nbspAfterShortWordsOverView(view: ProseView): void {
|
|
|
191
195
|
if (nextWordGluesForward(clean, offset + match[0].length)) return null
|
|
192
196
|
previousMatchEnd = offset + match[0].length
|
|
193
197
|
// Edit only the space so any boundary keeps its side of the NBSP.
|
|
194
|
-
|
|
198
|
+
const spaceOffset = offset + match[0].length - 1
|
|
199
|
+
if (!NBSP_CHARS.includes(clean[spaceOffset])) insertedGlue.add(spaceOffset)
|
|
200
|
+
v.replace(spaceOffset, spaceOffset + 1, NBSP)
|
|
195
201
|
return null
|
|
196
202
|
},
|
|
197
203
|
{
|
|
@@ -203,6 +209,7 @@ function nbspAfterShortWordsOverView(view: ProseView): void {
|
|
|
203
209
|
)
|
|
204
210
|
}
|
|
205
211
|
view.commit()
|
|
212
|
+
return insertedGlue
|
|
206
213
|
}
|
|
207
214
|
|
|
208
215
|
export const nbspBetweenNumberAndUnit = makeProsePass(nbspBetweenNumberAndUnitOverView)
|
|
@@ -260,35 +267,14 @@ function precedingGlueNbsp(view: ProseView, spaceOffset: number): number {
|
|
|
260
267
|
return j >= 0 && NBSP_CHARS.includes(text[j]) ? j : -1
|
|
261
268
|
}
|
|
262
269
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
* the backward letter scan finds zero letters and returns false. The scan is
|
|
269
|
-
* capped at three letters so it stays bounded on a long preceding word.
|
|
270
|
-
*/
|
|
271
|
-
function isShortWordGlue(view: ProseView, nbspIndex: number): boolean {
|
|
272
|
-
const text = view.text
|
|
273
|
-
let k = nbspIndex - 1
|
|
274
|
-
let letters = 0
|
|
275
|
-
while (k >= 0 && letters <= 2 && !view.hasBoundary(k + 1) && LATIN_LETTER_RE.test(text[k])) {
|
|
276
|
-
k--
|
|
277
|
-
letters++
|
|
278
|
-
}
|
|
279
|
-
if (letters === 0 || letters > 2 || view.hasBoundary(k + 1)) return false
|
|
280
|
-
// The short word's left edge must be a real word boundary, matching
|
|
281
|
-
// nbspAfterShortWords' lookbehind — not another NBSP.
|
|
282
|
-
return !(k >= 0 && NBSP_CHARS.includes(text[k]))
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
// When the second-to-last word is already glued backwards, an aesthetic
|
|
286
|
-
// short-word glue yields (reverts to a space) so the last word binds instead,
|
|
287
|
-
// keeping the non-breaking run to two words; a semantic glue there blocks.
|
|
288
|
-
export const nbspBeforeLastWord = makeProsePass(nbspBeforeLastWordOverView)
|
|
270
|
+
// When the second-to-last word is already glued backwards, a short-word glue
|
|
271
|
+
// that `nbspAfterShortWords` inserted yields (reverts to a space) so the last
|
|
272
|
+
// word binds instead, keeping the non-breaking run to two words. Any other
|
|
273
|
+
// glue there (a semantic glue, or a no-break space the author wrote) blocks.
|
|
274
|
+
export const nbspBeforeLastWord = makeProsePass((view) => nbspBeforeLastWordOverView(view, new Set()))
|
|
289
275
|
|
|
290
|
-
function nbspBeforeLastWordOverView(view: ProseView): void {
|
|
291
|
-
// The
|
|
276
|
+
function nbspBeforeLastWordOverView(view: ProseView, insertedShortWordGlue: ReadonlySet<number>): void {
|
|
277
|
+
// The word-char-or-boundary lookbehind and the preceding-glue resolution are
|
|
292
278
|
// enforced in the replacer, where boundaries are visible.
|
|
293
279
|
const pattern = cachedRegExp(
|
|
294
280
|
`${SPACE}(?<lastWord>(?:(?!\\s).){1,${MAX_LAST_WORD_LENGTH}})(?<ending>\\n\\n|$)`,
|
|
@@ -302,12 +288,12 @@ function nbspBeforeLastWordOverView(view: ProseView): void {
|
|
|
302
288
|
(match, v) => {
|
|
303
289
|
const start = match.index
|
|
304
290
|
const end = start + match[0].length
|
|
305
|
-
//
|
|
306
|
-
const precededByWord = start > 0 &&
|
|
291
|
+
// A word char before the space, or a boundary there.
|
|
292
|
+
const precededByWord = start > 0 && WORD_CHAR_RE.test(clean[start - 1])
|
|
307
293
|
if (!precededByWord && !view.hasBoundary(start)) return null
|
|
308
294
|
const glueNbsp = precedingGlueNbsp(v, start)
|
|
309
295
|
if (glueNbsp >= 0) {
|
|
310
|
-
if (!
|
|
296
|
+
if (!insertedShortWordGlue.has(glueNbsp)) return null
|
|
311
297
|
// Revert the short word's glue so the last word binds in its place.
|
|
312
298
|
v.replace(glueNbsp, glueNbsp + 1, " ")
|
|
313
299
|
}
|
|
@@ -373,7 +359,7 @@ function nbspBeforeContext(
|
|
|
373
359
|
!view.hasBoundary(start) &&
|
|
374
360
|
WORD_CHAR_RE.test(clean[start - 1])
|
|
375
361
|
) {
|
|
376
|
-
pattern.lastIndex = start
|
|
362
|
+
pattern.lastIndex = advanceScanIndex(clean, start, pattern)
|
|
377
363
|
continue
|
|
378
364
|
}
|
|
379
365
|
// Slot between the `thing` literal and the space (offset = end - 1);
|
|
@@ -384,7 +370,7 @@ function nbspBeforeContext(
|
|
|
384
370
|
if (!allowed) {
|
|
385
371
|
// A boundary broke the (possibly longer) literal; let the scan re-anchor
|
|
386
372
|
// one position later so a shorter sub-abbreviation can still match.
|
|
387
|
-
pattern.lastIndex = start
|
|
373
|
+
pattern.lastIndex = advanceScanIndex(clean, start, pattern)
|
|
388
374
|
continue
|
|
389
375
|
}
|
|
390
376
|
// Edit only the trailing space so the slot/lookahead boundaries keep their
|
|
@@ -425,7 +411,8 @@ export const nbspAfterCopyrightSymbols = makeProsePass(nbspAfterCopyrightSymbols
|
|
|
425
411
|
|
|
426
412
|
export const nbspBetweenInitials = makeProsePass(nbspBetweenInitialsOverView)
|
|
427
413
|
|
|
428
|
-
// Specific patterns first so they claim matches before generic
|
|
414
|
+
// Specific patterns first so they claim matches before the generic short-word
|
|
415
|
+
// rule, which nbspTransform runs after them.
|
|
429
416
|
const NBSP_TRANSFORMS: ((view: ProseView) => void)[] = [
|
|
430
417
|
nbspAfterHonorificsOverView,
|
|
431
418
|
nbspAfterReferenceAbbreviationsOverView,
|
|
@@ -433,8 +420,6 @@ const NBSP_TRANSFORMS: ((view: ProseView) => void)[] = [
|
|
|
433
420
|
nbspAfterCopyrightSymbolsOverView,
|
|
434
421
|
nbspBetweenInitialsOverView,
|
|
435
422
|
nbspBetweenNumberAndUnitOverView,
|
|
436
|
-
nbspAfterShortWordsOverView,
|
|
437
|
-
nbspBeforeLastWordOverView,
|
|
438
423
|
]
|
|
439
424
|
|
|
440
425
|
export function nbspTransform(input: string): string
|
|
@@ -448,5 +433,6 @@ export function nbspTransform(input: string | ProseView): string | void {
|
|
|
448
433
|
for (const fn of NBSP_TRANSFORMS) {
|
|
449
434
|
fn(view)
|
|
450
435
|
}
|
|
436
|
+
nbspBeforeLastWordOverView(view, nbspAfterShortWordsOverView(view))
|
|
451
437
|
})
|
|
452
438
|
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { HtmlOptions } from "./html.js"
|
|
2
|
+
import type { MarkdownOptions } from "./markdown.js"
|
|
3
|
+
|
|
4
|
+
export type SinkType = "md" | "html"
|
|
5
|
+
|
|
6
|
+
/** Imports a bare package specifier; injectable so tests can simulate an uninstalled peer. */
|
|
7
|
+
export type PeerImporter = (packageName: string) => Promise<unknown>
|
|
8
|
+
|
|
9
|
+
export interface SinkTransformers {
|
|
10
|
+
md: (input: string, options: MarkdownOptions) => Promise<string>
|
|
11
|
+
html: (input: string, options: HtmlOptions) => Promise<string>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** The optional peer dependencies each sink needs at runtime. */
|
|
15
|
+
export const SINK_PEER_DEPENDENCIES: Readonly<Record<SinkType, readonly string[]>> = {
|
|
16
|
+
md: ["unified", "remark-parse", "remark-gfm", "remark-stringify"],
|
|
17
|
+
html: ["unified", "rehype-parse", "rehype-stringify"],
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Thrown when an optional peer dependency needed for the requested file type is not installed. */
|
|
21
|
+
export class MissingPeerDependencyError extends Error {}
|
|
22
|
+
|
|
23
|
+
const MODULE_NOT_FOUND_CODES = new Set(["ERR_MODULE_NOT_FOUND", "MODULE_NOT_FOUND"])
|
|
24
|
+
|
|
25
|
+
function isMissingPackage(err: unknown, packageName: string): boolean {
|
|
26
|
+
const { code, message } = err as NodeJS.ErrnoException
|
|
27
|
+
return MODULE_NOT_FOUND_CODES.has(code ?? "") && message.includes(`'${packageName}'`)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const importPackage: PeerImporter = (packageName) => import(packageName)
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Lazily loads the transform for `type`, so only the peers for the file type
|
|
34
|
+
* being processed need to be installed.
|
|
35
|
+
* @throws MissingPeerDependencyError naming every absent peer and the install command.
|
|
36
|
+
*/
|
|
37
|
+
export async function loadSinkTransform<T extends SinkType>(
|
|
38
|
+
type: T,
|
|
39
|
+
importPeer: PeerImporter = importPackage,
|
|
40
|
+
): Promise<SinkTransformers[T]> {
|
|
41
|
+
const missing: string[] = []
|
|
42
|
+
for (const packageName of SINK_PEER_DEPENDENCIES[type]) {
|
|
43
|
+
try {
|
|
44
|
+
await importPeer(packageName)
|
|
45
|
+
} catch (err) {
|
|
46
|
+
if (!isMissingPackage(err, packageName)) throw err
|
|
47
|
+
missing.push(packageName)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (missing.length > 0) {
|
|
51
|
+
throw new MissingPeerDependencyError(
|
|
52
|
+
`Processing ${type === "md" ? "Markdown" : "HTML"} files requires ${missing.join(", ")}, which ` +
|
|
53
|
+
`${missing.length === 1 ? "is" : "are"} not installed. Install with: npm install ${missing.join(" ")}`,
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
if (type === "md") return (await import("./markdown.js")).transformMarkdown as SinkTransformers[T]
|
|
57
|
+
return (await import("./html.js")).transformHtml as SinkTransformers[T]
|
|
58
|
+
}
|
package/src/prettier-plugin.ts
CHANGED
|
@@ -5,8 +5,7 @@ import type { Parser, Plugin } from "prettier"
|
|
|
5
5
|
import type { Root } from "mdast"
|
|
6
6
|
import { cosmiconfig } from "cosmiconfig"
|
|
7
7
|
|
|
8
|
-
import { HTML_ONLY_OPTION_KEYS } from "./
|
|
9
|
-
import { MARKDOWN_ONLY_OPTION_KEYS } from "./markdown.js"
|
|
8
|
+
import { HTML_ONLY_OPTION_KEYS, MARKDOWN_ONLY_OPTION_KEYS } from "./sink-option-keys.js"
|
|
10
9
|
import { remarkPunctilio, type RemarkPunctilioOptions } from "./remark.js"
|
|
11
10
|
import { omitKeys } from "./utils.js"
|
|
12
11
|
|
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;
|
|
@@ -382,7 +393,7 @@ export function replaceAllInView(
|
|
|
382
393
|
let match: RegExpExecArray | null;
|
|
383
394
|
while ((match = regex.exec(text)) !== null) {
|
|
384
395
|
if (match[0].length === 0) {
|
|
385
|
-
regex.lastIndex
|
|
396
|
+
regex.lastIndex = advanceScanIndex(text, regex.lastIndex, regex);
|
|
386
397
|
}
|
|
387
398
|
|
|
388
399
|
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/symbols.ts
CHANGED
|
@@ -147,10 +147,14 @@ const DIGIT_SUFFIX = `(?:${PRIME_ALT}|${UNIT_ALT})?`
|
|
|
147
147
|
// (1e5x3 — the `e` is a letter) and model SKUs (Surface5x3, RTX3060x2);
|
|
148
148
|
// signed exponents (1e-5x3, 3.5E+10x2) are unambiguously scientific, their
|
|
149
149
|
// operand is preceded by a sign, and they convert.
|
|
150
|
-
|
|
151
|
-
|
|
150
|
+
//
|
|
151
|
+
// Operator spacing is horizontal whitespace only: an operator across a line
|
|
152
|
+
// break is a list marker (`Step 1\n* 2 cups`), not multiplication.
|
|
153
|
+
const OPERATOR_SPACE = "[^\\S\\n\\v\\f\\r\\u2028\\u2029]*"
|
|
154
|
+
const MULTIPLICATION_CHAIN = `(?<firstNum>\\d+${DIGIT_SUFFIX})(?<rest>(?:${OPERATOR_SPACE}[xX*]${OPERATOR_SPACE}\\d+${DIGIT_SUFFIX})+)`
|
|
155
|
+
const MULTIPLICATION_SEGMENT = `(?<spaceBefore>${OPERATOR_SPACE})[xX*](?<spaceAfter>${OPERATOR_SPACE})(?<num>\\d+${DIGIT_SUFFIX})`
|
|
152
156
|
|
|
153
|
-
/**
|
|
157
|
+
/** An `OPERATOR_SPACE [xX*] OPERATOR_SPACE \d+suffix` segment located by absolute clean-text offsets. */
|
|
154
158
|
interface ChainSegment {
|
|
155
159
|
/** Absolute offset of the operator character. */
|
|
156
160
|
operatorOffset: number
|
|
@@ -183,7 +187,7 @@ function chainSegments(chainStart: number, firstNum: string, rest: string): Chai
|
|
|
183
187
|
}
|
|
184
188
|
|
|
185
189
|
/**
|
|
186
|
-
* Each operator's spacing slot (
|
|
190
|
+
* Each operator's spacing slot (`OPERATOR_SPACE [xX*] OPERATOR_SPACE`) tolerates one node boundary at
|
|
187
191
|
* each outer edge and none inside the spacing; a second adjacent boundary at an
|
|
188
192
|
* edge breaks the segment. The operator's slot is the span [slotStart, slotEnd)
|
|
189
193
|
* covering its surrounding spaces and the operator itself.
|
package/src/transform-options.ts
CHANGED
|
@@ -35,8 +35,9 @@ export interface TransformOptions {
|
|
|
35
35
|
* (U+201E/U+201C double, U+201A/U+2018 single). Punctuation outside quotes.
|
|
36
36
|
* - `"french"`: French style. Uses guillemets with NNBSP padding: «\u202F...\u202F»
|
|
37
37
|
* Single quotes remain as curly quotes. Punctuation outside quotes.
|
|
38
|
-
* - `"none"`: Skip
|
|
39
|
-
*
|
|
38
|
+
* - `"none"`: Skip quote classification and prime conversion. Straight
|
|
39
|
+
* quotes, apostrophes, and prime marks are left unmodified; a U+02BC
|
|
40
|
+
* modifier-letter apostrophe is still normalized to U+2019.
|
|
40
41
|
*
|
|
41
42
|
* Default: "american"
|
|
42
43
|
*/
|
|
@@ -121,6 +122,14 @@ export type ResolvedTransformOptions = Required<TransformOptions>
|
|
|
121
122
|
|
|
122
123
|
/** Validates `options` and fills in the defaults. */
|
|
123
124
|
export function resolveTransformOptions(options: TransformOptions): ResolvedTransformOptions {
|
|
125
|
+
// JS callers outside the type system can pass null, a string, or an array;
|
|
126
|
+
// `Object.keys` would otherwise throw an opaque TypeError or enumerate a
|
|
127
|
+
// string's indices as unknown option names.
|
|
128
|
+
if (options === null || typeof options !== "object" || Array.isArray(options)) {
|
|
129
|
+
throw new TypeError(
|
|
130
|
+
`transform options must be an object, got ${options === null ? "null" : Array.isArray(options) ? "an array" : typeof options}.`
|
|
131
|
+
)
|
|
132
|
+
}
|
|
124
133
|
assertKnownOptionKeys(options, TRANSFORM_OPTION_KEYS, "transform")
|
|
125
134
|
|
|
126
135
|
const punctuationStyle = options.punctuationStyle ?? "american"
|
package/src/utils.ts
CHANGED
|
@@ -41,6 +41,20 @@ export function assertKnownOptionKeys(
|
|
|
41
41
|
}
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
const BYTE_ORDER_MARK = "\uFEFF"
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Runs `transform` on `input` without its leading BOM, which would otherwise
|
|
48
|
+
* be parsed as content, and restores the BOM on the result. @internal
|
|
49
|
+
*/
|
|
50
|
+
export async function transformPreservingBom(
|
|
51
|
+
input: string,
|
|
52
|
+
transform: (inputWithoutBom: string) => Promise<string>,
|
|
53
|
+
): Promise<string> {
|
|
54
|
+
if (!input.startsWith(BYTE_ORDER_MARK)) return transform(input)
|
|
55
|
+
return BYTE_ORDER_MARK + (await transform(input.slice(BYTE_ORDER_MARK.length)))
|
|
56
|
+
}
|
|
57
|
+
|
|
44
58
|
/** Returns a copy of `obj` without the listed keys. @internal */
|
|
45
59
|
export function omitKeys<T extends object>(obj: T, keys: readonly string[]): Partial<T> {
|
|
46
60
|
const result: Record<string, unknown> = {}
|