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.
Files changed (62) hide show
  1. package/README.md +12 -2
  2. package/dist/cli.d.ts +5 -0
  3. package/dist/cli.d.ts.map +1 -1
  4. package/dist/cli.js +156 -48
  5. package/dist/cli.js.map +1 -1
  6. package/dist/constants.d.ts +2 -0
  7. package/dist/constants.d.ts.map +1 -1
  8. package/dist/constants.js +3 -1
  9. package/dist/constants.js.map +1 -1
  10. package/dist/dashes.d.ts.map +1 -1
  11. package/dist/dashes.js +3 -3
  12. package/dist/dashes.js.map +1 -1
  13. package/dist/html.d.ts +4 -3
  14. package/dist/html.d.ts.map +1 -1
  15. package/dist/html.js +55 -17
  16. package/dist/html.js.map +1 -1
  17. package/dist/markdown.d.ts +1 -2
  18. package/dist/markdown.d.ts.map +1 -1
  19. package/dist/markdown.js +52 -5
  20. package/dist/markdown.js.map +1 -1
  21. package/dist/nbsp.d.ts.map +1 -1
  22. package/dist/nbsp.js +26 -39
  23. package/dist/nbsp.js.map +1 -1
  24. package/dist/peer-dependencies.d.ts +21 -0
  25. package/dist/peer-dependencies.d.ts.map +1 -0
  26. package/dist/peer-dependencies.js +40 -0
  27. package/dist/peer-dependencies.js.map +1 -0
  28. package/dist/prettier-plugin.d.ts.map +1 -1
  29. package/dist/prettier-plugin.js +1 -2
  30. package/dist/prettier-plugin.js.map +1 -1
  31. package/dist/prose-view.d.ts +7 -0
  32. package/dist/prose-view.d.ts.map +1 -1
  33. package/dist/prose-view.js +11 -1
  34. package/dist/prose-view.js.map +1 -1
  35. package/dist/quote-classifier.d.ts.map +1 -1
  36. package/dist/quote-classifier.js +67 -20
  37. package/dist/quote-classifier.js.map +1 -1
  38. package/dist/symbols.d.ts.map +1 -1
  39. package/dist/symbols.js +7 -3
  40. package/dist/symbols.js.map +1 -1
  41. package/dist/transform-options.d.ts +3 -2
  42. package/dist/transform-options.d.ts.map +1 -1
  43. package/dist/transform-options.js +6 -0
  44. package/dist/transform-options.js.map +1 -1
  45. package/dist/utils.d.ts +5 -0
  46. package/dist/utils.d.ts.map +1 -1
  47. package/dist/utils.js +10 -0
  48. package/dist/utils.js.map +1 -1
  49. package/package.json +4 -2
  50. package/src/cli.ts +180 -48
  51. package/src/constants.ts +4 -1
  52. package/src/dashes.ts +3 -2
  53. package/src/html.ts +60 -19
  54. package/src/markdown.ts +58 -9
  55. package/src/nbsp.ts +26 -40
  56. package/src/peer-dependencies.ts +58 -0
  57. package/src/prettier-plugin.ts +1 -2
  58. package/src/prose-view.ts +12 -1
  59. package/src/quote-classifier.ts +68 -19
  60. package/src/symbols.ts +8 -4
  61. package/src/transform-options.ts +11 -2
  62. 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
- function nbspAfterShortWordsOverView(view: ProseView): void {
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
- v.replace(offset + match[0].length - 1, offset + match[0].length, NBSP)
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
- * True when the word immediately before the NBSP at `nbspIndex` is a 1–2 letter
265
- * Latin word whose own left edge is a word boundary — an aesthetic short-word
266
- * glue that yields to last-word protection. Semantic glues (honorific `Dr.`,
267
- * abbreviation, unit `12`) carry a "."/digit/symbol right before the NBSP, so
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 `(?<=\w|boundary)` lookbehind and the preceding-glue resolution are
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
- // `(?<=\w|sep)`: a word char before the space, or a boundary there.
306
- const precededByWord = start > 0 && /\w/.test(clean[start - 1])
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 (!isShortWordGlue(v, glueNbsp)) return null
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 + 1
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 + 1
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 nbspAfterShortWords.
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
+ }
@@ -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 "./html.js"
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 += 1;
396
+ regex.lastIndex = advanceScanIndex(text, regex.lastIndex, regex);
386
397
  }
387
398
 
388
399
  if (matchContainsBoundary(match, view) && !allowBoundaries?.(match, view)) {
@@ -1,4 +1,4 @@
1
- import { FOLDED_WORD_CHARS, LATIN_LETTER_RE, SPACE_CHAR_RE, TERMINAL_PUNCTUATION, UNICODE_SYMBOLS, WORD_RE } from "./constants.js"
1
+ import { CLOSING_BRACKETS, FOLDED_WORD_CHARS, LATIN_LETTER_RE, OPENING_BRACKETS, SPACE_CHAR_RE, TERMINAL_PUNCTUATION, UNICODE_SYMBOLS, WORD_RE } from "./constants.js"
2
2
  import type { ProseView } from "./prose-view.js"
3
3
 
4
4
  const {
@@ -14,6 +14,8 @@ const {
14
14
  SINGLE_LOW_9_QUOTE,
15
15
  LEFT_GUILLEMET,
16
16
  RIGHT_GUILLEMET,
17
+ INVERTED_EXCLAMATION,
18
+ INVERTED_QUESTION,
17
19
  NBSP,
18
20
  NNBSP,
19
21
  PRIME,
@@ -103,6 +105,12 @@ interface Item {
103
105
  * changes that re-derived shape and breaks the fixed point ("'06,'").
104
106
  */
105
107
  frozenCloser?: boolean
108
+ /**
109
+ * Set on a German item whose curly source glyph was re-derived to a straight
110
+ * candidate by position. If classification assigns it no role, it renders as
111
+ * this glyph: the style never downgrades curly text to straight.
112
+ */
113
+ curlySource?: string
106
114
  /** Insertion anchor (clean-text offset and bind side) once moved. */
107
115
  anchorOffset: number
108
116
  anchorBind: "left" | "right"
@@ -143,10 +151,11 @@ const SINGLE_ENDING_SET = new Set<string>([".", "!", "?", ";", ":", ",", ")", EM
143
151
  * straight double quote belongs with the curly pair: whichever way it curls
144
152
  * lands in this set, so deciding as if it already had keeps re-runs (which
145
153
  * see the curled glyph) consistent. */
146
- const SINGLE_OPEN_BEFORE_SET = new Set<string>([LEFT_DOUBLE_QUOTE, RIGHT_DOUBLE_QUOTE, '"', EM_DASH, "-", "("])
154
+ const SINGLE_OPEN_BEFORE_SET = new Set<string>([LEFT_DOUBLE_QUOTE, RIGHT_DOUBLE_QUOTE, '"', EM_DASH, "-", "(", "/", "[", "{", INVERTED_QUESTION, INVERTED_EXCLAMATION])
147
155
 
148
- /** Boundary chars before an opening double quote (plus any \s char). */
149
- const DOUBLE_OPEN_BEFORE_SET = new Set<string>(["(", "/", "[", "{", "-", EM_DASH])
156
+ /** Boundary chars before an opening double quote (plus any \s char). A
157
+ * single opener qualifies: a double quoted directly inside a single. */
158
+ const DOUBLE_OPEN_BEFORE_SET = new Set<string>(["(", "/", "[", "{", "-", EM_DASH, LEFT_SINGLE_QUOTE, INVERTED_QUESTION, INVERTED_EXCLAMATION])
150
159
 
151
160
  /** Ending-context chars that block the opener rule's arm 2 (plus any \s char).
152
161
  * ELLIPSIS is deliberately absent: the rule's `...` arm opens, so `…` must too. */
@@ -160,10 +169,10 @@ const DOUBLE_EMPTY_BEFORE_SET = new Set<string>(["(", "[", "{"])
160
169
  * rule rejects falls through to opener/closer rules whose after-contexts are
161
170
  * the closer set, and the two routes must accept the same shapes or a re-run
162
171
  * (which sees the rendered opener, not the whitespace) classifies anew. */
163
- const DOUBLE_EMPTY_AFTER_SET = new Set<string>([")", "]", "}", ".", "!", "?", ",", ";", ":", "/", "-", "s", EM_DASH, ELLIPSIS, ...FOLDED_TERMINALS])
172
+ const DOUBLE_EMPTY_AFTER_SET = new Set<string>([")", "]", "}", ".", "!", "?", ",", ";", ":", "/", "-", "s", EM_DASH, ELLIPSIS, RIGHT_SINGLE_QUOTE, ...FOLDED_TERMINALS])
164
173
 
165
174
  /** Ending-context chars after a closing double quote (plus any \s char). */
166
- const DOUBLE_CLOSE_AFTER_SET = new Set<string>(["/", ")", "]", ".", ",", ";", EM_DASH, ":", "-", "}", "!", "?", "s", ELLIPSIS, ...FOLDED_TERMINALS])
175
+ const DOUBLE_CLOSE_AFTER_SET = new Set<string>(["/", ")", "]", ".", ",", ";", EM_DASH, ":", "-", "}", "!", "?", "s", ELLIPSIS, RIGHT_SINGLE_QUOTE, ...FOLDED_TERMINALS])
167
176
 
168
177
  /** Builds an `(items, index)` predicate: in range, non-boundary, and `test(ch)`. */
169
178
  function makeItemTester(test: (ch: string) => boolean): (items: Item[], index: number) => boolean {
@@ -298,7 +307,9 @@ function buildItems(view: ProseView, style: ActiveQuoteStyle): Item[] {
298
307
  mapped = DIGIT_RE.test(text[i - 1] ?? "") ? MODIFIER_LETTER_APOSTROPHE : "'"
299
308
  }
300
309
  if (mapped !== null) {
301
- items.push(makeItem(false, mapped, i, i + 1))
310
+ const item = makeItem(false, mapped, i, i + 1)
311
+ if (mapped === '"' || mapped === "'") item.curlySource = ch
312
+ items.push(item)
302
313
  i++
303
314
  continue
304
315
  }
@@ -346,16 +357,19 @@ function possessiveAfter(items: Item[], index: number): boolean {
346
357
  /**
347
358
  * `(?<=[^\s“'"])` — the closing/possessive lookbehind. A boundary directly
348
359
  * before the quote satisfies it (a boundary is an ordinary non-space char).
349
- * A straight double quote is excluded along with `“`: it may curl into an
350
- * opener later in this same pass, which puts the candidate in exactly the
351
- * nested-opener position the `“` exclusion covers, and deciding against it
352
- * now keeps re-runs (which see the curled glyph) consistent.
360
+ * A straight double quote in opener position is excluded along with `“`: it
361
+ * curls into an opener later in this same pass, which puts the candidate in
362
+ * exactly the nested-opener position the `“` exclusion covers, and deciding
363
+ * against it now keeps re-runs (which see the curled glyph) consistent. A
364
+ * straight double quote outside opener position curls into a closer (`”`,
365
+ * which passes), so it passes too (`'"Hello"'`).
353
366
  */
354
367
  function singleCloserBefore(items: Item[], index: number): boolean {
355
368
  if (index === 0) return false
356
369
  const prev = items[index - 1]
357
370
  if (prev.boundary) return true
358
- return !SPACE_RE.test(prev.ch) && prev.ch !== LEFT_DOUBLE_QUOTE && prev.ch !== "'" && prev.ch !== '"'
371
+ if (prev.ch === '"') return !doubleOpenerPrefixOk(items, index - 1)
372
+ return !SPACE_RE.test(prev.ch) && prev.ch !== LEFT_DOUBLE_QUOTE && prev.ch !== "'"
359
373
  }
360
374
 
361
375
  /** Letter directly before (no boundary) and letter after (one boundary transparent). */
@@ -588,7 +602,7 @@ function classifySingles(items: Item[], style: ActiveQuoteStyle): void {
588
602
  // ---------------------------------------------------------------------------
589
603
 
590
604
  /** Empty (`""`) and whitespace-only (`" "`) double-quote pairs (no boundaries allowed anywhere in or around the match). */
591
- function classifyEmptyDoublePairs(items: Item[]): void {
605
+ function classifyEmptyDoublePairs(items: Item[], style: ActiveQuoteStyle): void {
592
606
  const beforeOk = (index: number): boolean => {
593
607
  if (index < 0) return true
594
608
  const item = items[index]
@@ -597,7 +611,7 @@ function classifyEmptyDoublePairs(items: Item[]): void {
597
611
  const afterOk = (index: number): boolean => {
598
612
  if (index >= items.length) return true
599
613
  const item = items[index]
600
- return !item.boundary && (SPACE_RE.test(item.ch) || DOUBLE_EMPTY_AFTER_SET.has(item.ch))
614
+ return !item.boundary && (SPACE_RE.test(item.ch) || DOUBLE_EMPTY_AFTER_SET.has(item.ch) || apostropheEndsDouble(item.ch, style))
601
615
  }
602
616
  for (let i = 0; i < items.length - 1; i++) {
603
617
  if (!isCharItem(items, i, '"') || !isCharItem(items, i + 1, '"')) continue
@@ -839,6 +853,16 @@ function isDoubleCloseAfterChar(ch: string): boolean {
839
853
  return SPACE_RE.test(ch) || DOUBLE_CLOSE_AFTER_SET.has(ch)
840
854
  }
841
855
 
856
+ /**
857
+ * An apostrophe directly after a double quote ends it, as the closing single
858
+ * quote it renders as does. German is the exception: it re-derives the
859
+ * rendered apostrophe (U+2019) by position, so a re-run can read it as a
860
+ * closing single quote that the first pass, which saw an apostrophe, did not.
861
+ */
862
+ function apostropheEndsDouble(ch: string, style: ActiveQuoteStyle): boolean {
863
+ return ch === MODIFIER_LETTER_APOSTROPHE && style !== "german"
864
+ }
865
+
842
866
  /**
843
867
  * Closing double quote by ending context: a non-space/non-paren character (or
844
868
  * a boundary) before it, and an ending character, a boundary, or end of input
@@ -861,7 +885,7 @@ function classifyClosingDoubles(items: Item[], style: ActiveQuoteStyle): void {
861
885
  }
862
886
  const next = items[i + 1]
863
887
  if (foldsToNotEqual(items, i + 1)) continue
864
- if (next === undefined || next.boundary || isDoubleCloseAfterChar(next.ch)) {
888
+ if (next === undefined || next.boundary || isDoubleCloseAfterChar(next.ch) || apostropheEndsDouble(next.ch, style)) {
865
889
  item.ch = RIGHT_DOUBLE_QUOTE
866
890
  }
867
891
  }
@@ -889,8 +913,31 @@ function classifySinglesBeforeClosingDoubles(items: Item[]): void {
889
913
  }
890
914
  }
891
915
 
916
+ /**
917
+ * German reads its rendered glyphs by depth: a U+201C below an open „ closes
918
+ * it. A curly orphan that kept its source glyph (see {@link Item.curlySource})
919
+ * below a quote opened in this same pass is therefore that quote's closer on
920
+ * the next run; label it one now so both runs agree. Singles need no
921
+ * counterpart: a single opener is only recognized when a closer follows it
922
+ * with no straight single in between, and an orphan U+2018 is one.
923
+ */
924
+ function closeCurlyOrphansBelowOpeners(items: Item[]): void {
925
+ let depth = 0
926
+ for (const item of items) {
927
+ if (item.boundary) continue
928
+ if (item.ch === LEFT_DOUBLE_QUOTE) {
929
+ depth++
930
+ } else if (item.ch === RIGHT_DOUBLE_QUOTE) {
931
+ if (depth > 0) depth--
932
+ } else if (item.ch === '"' && item.curlySource === LEFT_DOUBLE_QUOTE && depth > 0) {
933
+ item.ch = RIGHT_DOUBLE_QUOTE
934
+ depth--
935
+ }
936
+ }
937
+ }
938
+
892
939
  function classifyDoubles(items: Item[], style: ActiveQuoteStyle): void {
893
- classifyEmptyDoublePairs(items)
940
+ classifyEmptyDoublePairs(items, style)
894
941
  classifyOpeningDoubles(items, style)
895
942
  classifyQuotedPunctuationOpeners(items)
896
943
  classifyBraceOpeners(items)
@@ -987,11 +1034,11 @@ function applyRelocations(order: Item[], moves: Relocation[]): Item[] {
987
1034
  return result
988
1035
  }
989
1036
 
990
- const OPEN_BRACKETS = new Set<string>(["(", "[", "{"])
991
- const CLOSE_BRACKETS = new Set<string>([")", "]", "}"])
1037
+ const OPEN_BRACKETS = new Set<string>(OPENING_BRACKETS)
1038
+ const CLOSE_BRACKETS = new Set<string>(CLOSING_BRACKETS)
992
1039
 
993
1040
  /**
994
- * Unmatched open brackets (`(`, `[`, `{`) strictly before each item — a
1041
+ * Unmatched open brackets (see {@link OPENING_BRACKETS}) strictly before each item — a
995
1042
  * position is inside a bracket pair when its entry is > 0. Brackets never move
996
1043
  * during placement, so the depths hold across the fixpoint's rounds. Boundary
997
1044
  * items are transparent; a closer with no opener above it floors the depth at 0.
@@ -1463,6 +1510,7 @@ function queueRenderEdits(view: ProseView, items: Item[], table: Record<QuoteRol
1463
1510
  }
1464
1511
  continue
1465
1512
  }
1513
+ if (role === undefined && item.curlySource !== undefined) continue
1466
1514
  // Fast path for ordinary characters; pre-labeled items may have changed
1467
1515
  // `ch` without gaining a role (German orphan closers) and must still diff.
1468
1516
  if (role === undefined && item.ch === text[item.start] && item.end - item.start === 1) continue
@@ -1492,6 +1540,7 @@ export function classifyAndRenderQuotes(
1492
1540
  const items = buildItems(view, style)
1493
1541
  classifySingles(items, style)
1494
1542
  classifyDoubles(items, style)
1543
+ if (style === "german") closeCurlyOrphansBelowOpeners(items)
1495
1544
  const placed = applyPunctuationPlacement(items, style, apostrophe)
1496
1545
  // Placement can slide a closer past punctuation until it sits directly after
1497
1546
  // an s/S (british/french move the closer left, out of "dogs.'" → "dogs'."),
package/src/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
- const MULTIPLICATION_CHAIN = `(?<firstNum>\\d+${DIGIT_SUFFIX})(?<rest>(?:\\s*[xX*]\\s*\\d+${DIGIT_SUFFIX})+)`
151
- const MULTIPLICATION_SEGMENT = `(?<spaceBefore>\\s*)[xX*](?<spaceAfter>\\s*)(?<num>\\d+${DIGIT_SUFFIX})`
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
- /** A `\s*[xX*]\s*\d+suffix` segment located by absolute clean-text offsets. */
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 (`\s*[xX*]\s*`) tolerates one node boundary at
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.
@@ -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 all quote and punctuation transforms entirely.
39
- * Straight quotes, apostrophes, and prime marks are left unmodified.
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> = {}