punctilio 5.5.2 → 5.5.4

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/src/dashes.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { asCharClassContent, cachedRegExp, FOLDED_WORD_CHARS, isWordLike, LATIN_LETTER_RE, LATIN_LETTERS, LATIN_OR_DIGIT, LATIN_OR_DIGIT_RE, LOCALE_QUOTE_MARKS, MAX_BOUNDARY_SEPARATORS, NBSP_CHARS, OPENING_BRACKETS, QUOTE_GLYPHS, SPACE_CHAR_RE, UNICODE_SYMBOLS } from "./constants.js"
1
+ import { asCharClassContent, cachedRegExp, FOLDED_TERMINALS, FOLDED_WORD_CHARS, FRACTION_GLYPHS, isWordLike, LATIN_LETTER_RE, LATIN_LETTERS, LATIN_OR_DIGIT, LATIN_OR_DIGIT_RE, MAX_BOUNDARY_SEPARATORS, NBSP_CHARS, OPENING_BRACKETS, OPERATOR_SPACE, QUOTE_GLYPHS, SPACE_CHAR_RE, UNICODE_SYMBOLS } from "./constants.js"
2
2
  import { boundaryCountAt, exceedsSingleBoundary, firstInteriorBoundary, lastBoundaryBefore, makeProsePass, overInput, type ProseView, replaceAllInView, type ReplaceAllOptions } from "./prose-view.js"
3
3
 
4
4
  export const DASH_STYLES = ["american", "british", "none"] as const
@@ -9,10 +9,7 @@ export interface DashOptions {
9
9
  dashStyle?: DashStyle
10
10
  }
11
11
 
12
- const {
13
- EN_DASH, EM_DASH, MINUS, MULTIPLICATION, PLUS_MINUS,
14
- LEFT_DOUBLE_QUOTE, RIGHT_DOUBLE_QUOTE, LEFT_SINGLE_QUOTE, RIGHT_SINGLE_QUOTE,
15
- } = UNICODE_SYMBOLS
12
+ const { EN_DASH, EM_DASH, MINUS, MULTIPLICATION, PLUS_MINUS } = UNICODE_SYMBOLS
16
13
 
17
14
  // Prevents false-positive ranges in model names like "Llama-2-7B".
18
15
  export const numberRangeDisallowedPrefixes = ["-", EN_DASH, EM_DASH, MINUS] as const
@@ -391,7 +388,7 @@ interface ResolvedTail {
391
388
  function resolvePositiveTail(view: ProseView, endPos: number): ResolvedTail | null {
392
389
  const candidates = followingCandidates(view, endPos, true)
393
390
  for (let i = candidates.length - 1; i >= 0; i--) {
394
- const suffixEnd = suffixWbeEnd(view, candidates[i], true)
391
+ const suffixEnd = suffixWbeEnd(view, candidates[i], true, i === 0)
395
392
  if (suffixEnd !== null) {
396
393
  return { followingEmpty: i === 0, consumedEnd: suffixEnd }
397
394
  }
@@ -466,7 +463,7 @@ function matchNegativeRangeAt(view: ProseView, i: number): number | null {
466
463
  const endPos = end.firstDigitStart + len
467
464
  const candidates = followingCandidates(view, endPos, false)
468
465
  for (let c = candidates.length - 1; c >= 0; c--) {
469
- const suffixEnd = suffixWbeEnd(view, candidates[c], false)
466
+ const suffixEnd = suffixWbeEnd(view, candidates[c], false, c === 0)
470
467
  if (suffixEnd === null) continue
471
468
  if (c === 0) {
472
469
  view.replace(startEnd, negEnd, `${EN_DASH}${hasNeg ? MINUS : ""}`)
@@ -529,8 +526,15 @@ const RANGE_SUFFIX_RE = /[xKBTMCF]/
529
526
  * The `(?:[AaPp][Mm]|[xKBTMCF])?${wbe}` suffix at `pos`, with one tolerated
530
527
  * boundary before the suffix. Returns the end offset after the optional suffix
531
528
  * when the tail completes (wbe passes), or null when it does not.
529
+ *
530
+ * `allowMultiplier` admits the lowercase `x`. It folds to `×`, which is no
531
+ * suffix, so after a non-empty `following` a re-run reads the tail as failing
532
+ * and converts the leading range: `1-2-3x` must convert like `1-2-3×`. An
533
+ * operator the multiplication pass folds in place (`1-2-3*4`, `1-2-3 x 4`)
534
+ * fails the tail the same way.
532
535
  */
533
- function suffixWbeEnd(view: ProseView, pos: number, allowAmPm: boolean): number | null {
536
+ function suffixWbeEnd(view: ProseView, pos: number, allowAmPm: boolean, allowMultiplier: boolean): number | null {
537
+ if (!allowMultiplier && foldsToMultiplication(view.text, pos)) return null
534
538
  if (wordBoundaryEndOk(view, pos)) return pos
535
539
  // An optional suffix: one tolerated boundary then am/pm or one of
536
540
  // {@link RANGE_SUFFIX_RE}'s letters, followed by wbe. The suffix letters sit
@@ -542,12 +546,19 @@ function suffixWbeEnd(view: ProseView, pos: number, allowAmPm: boolean): number
542
546
  if (allowAmPm && /[ap]/i.test(text[pos] ?? "") && /m/i.test(text[pos + 1] ?? "") && !view.hasBoundary(pos + 1)) {
543
547
  return wordBoundaryEndOk(view, pos + 2) ? pos + 2 : null
544
548
  }
545
- if (RANGE_SUFFIX_RE.test(text[pos] ?? "")) {
549
+ if (RANGE_SUFFIX_RE.test(text[pos] ?? "") && (allowMultiplier || text[pos] !== "x")) {
546
550
  return wordBoundaryEndOk(view, pos + 1) ? pos + 1 : null
547
551
  }
548
552
  return null
549
553
  }
550
554
 
555
+ /** True iff an `x`, `X` or `*` operator with an operand digit after it starts at `pos`. */
556
+ function foldsToMultiplication(text: string, pos: number): boolean {
557
+ const operator = cachedRegExp(`${OPERATOR_SPACE}[xX*]${OPERATOR_SPACE}\\d`, "y")
558
+ operator.lastIndex = pos
559
+ return operator.test(text)
560
+ }
561
+
551
562
  const NOT_AFTER_DASH_RE = new RegExp(`[${DISALLOWED_PREFIX_CLASS_FRAGMENT}${LATIN_LETTERS}${MULTIPLICATION}${PLUS_MINUS}.+]`)
552
563
 
553
564
  /**
@@ -591,14 +602,14 @@ function endsFoldableEllipsisDots(view: ProseView, dotIndex: number): boolean {
591
602
  */
592
603
  function notAfterDashOk(view: ProseView, startOffset: number): boolean {
593
604
  if (startOffset === 0) return true
594
- if (boundaryCountAt(view, startOffset) > 0) return true
595
605
  const prev = view.text[startOffset - 1]
596
606
  // A multiplication sign one space slot further left blocks like the `x` it
597
- // folds from: the multiplication pass renders `5x8` as `5 × 8`, so a range
598
- // blocked at `x8-6` must stay blocked at `× 8-6`.
607
+ // folds from, even across a node boundary: the multiplication pass renders
608
+ // `5x8` as `5 × 8`, so a range blocked at `x8-6` must stay blocked at `× 8-6`.
599
609
  if (prev !== undefined && SPACE_CHAR_RE.test(prev) && view.text[startOffset - 2] === MULTIPLICATION) {
600
610
  return false
601
611
  }
612
+ if (boundaryCountAt(view, startOffset) > 0) return true
602
613
  if (prev === undefined || !NOT_AFTER_DASH_RE.test(prev)) return true
603
614
  if (prev === "." && endsFoldableEllipsisDots(view, startOffset - 1)) return true
604
615
  const dashIndex = startOffset - 1
@@ -781,7 +792,7 @@ function minusSubtractionPrefixOk(match: RegExpExecArray, view: ProseView, prefi
781
792
  return NUM_BODY_RE.test(view.text.slice(numStart, truncatedEnd))
782
793
  }
783
794
 
784
- const MINUS_BEFORE_CLASS_RE = new RegExp(`[\\s${asCharClassContent(OPENING_BRACKETS)}"'${LEFT_DOUBLE_QUOTE}${RIGHT_DOUBLE_QUOTE}${LEFT_SINGLE_QUOTE}${RIGHT_SINGLE_QUOTE}${LOCALE_QUOTE_MARKS}]`)
795
+ const MINUS_BEFORE_CLASS_RE = new RegExp(`[\\s${asCharClassContent(OPENING_BRACKETS)}${QUOTE_GLYPHS}]`)
785
796
  // The folded word glyphs block like the word chars they fold from ("1x|-0"
786
797
  // and "1×|-0" must agree), the folded math operators block like the operator
787
798
  // chars they fold from (the `!=` → `≠` fold consumes the `=` that blocked
@@ -790,10 +801,7 @@ const MINUS_BEFORE_CLASS_RE = new RegExp(`[\\s${asCharClassContent(OPENING_BRACK
790
801
  // ("?|!-0" folds to "⁈|-0" with the hyphen stranded boundary-adjacent).
791
802
  const FOLDED_WORD_CLASS_FRAGMENT = [...FOLDED_WORD_CHARS].join("")
792
803
  const FOLDED_MATH_CLASS_FRAGMENT = [...FOLDED_MATH_OPERATORS].join("")
793
- const FOLDED_TERMINAL_CLASS_FRAGMENT = [
794
- UNICODE_SYMBOLS.ELLIPSIS, UNICODE_SYMBOLS.DOUBLE_QUESTION, UNICODE_SYMBOLS.QUESTION_EXCLAMATION,
795
- UNICODE_SYMBOLS.EXCLAMATION_QUESTION, UNICODE_SYMBOLS.DOUBLE_EXCLAMATION, UNICODE_SYMBOLS.INTERROBANG, "!",
796
- ].join("")
804
+ const FOLDED_TERMINAL_CLASS_FRAGMENT = [UNICODE_SYMBOLS.ELLIPSIS, ...FOLDED_TERMINALS, "!"].join("")
797
805
  const NUM_BEFORE_2B_BLOCK_RE = new RegExp(`[\\d.,${LATIN_LETTERS}${FOLDED_WORD_CLASS_FRAGMENT}${FOLDED_MATH_CLASS_FRAGMENT}${FOLDED_TERMINAL_CLASS_FRAGMENT}]`)
798
806
 
799
807
  /**
@@ -862,7 +870,7 @@ function convertParentheticalDashes(view: ProseView, style: DashStyle): void {
862
870
 
863
871
  const DASH_RUN_CHARS = new Set(["-", EN_DASH, EM_DASH])
864
872
 
865
- function isDashChar(ch: string | undefined): boolean {
873
+ export function isDashChar(ch: string | undefined): boolean {
866
874
  return ch !== undefined && DASH_RUN_CHARS.has(ch)
867
875
  }
868
876
 
@@ -1072,14 +1080,13 @@ function convertBoundaryLedDashes(view: ProseView, rendered: string): void {
1072
1080
  if (b < lastProcessedEnd) continue
1073
1081
  if (!isDashChar(text[b])) continue
1074
1082
  // Non-space before the boundary: ≥2 stacked boundaries, or a non-space
1075
- // clean char immediately left. A folded math operator there marks the
1076
- // fold-stranded configuration: the math pass consumes one character
1077
- // across this junction (`=` + `~—` folds to `≈` + `—`), so the character
1078
- // that blocked this rule on the source text is gone, and converting now
1079
- // would diverge from that blocked first pass.
1083
+ // clean char immediately left. Blocked there: a folded math operator (the
1084
+ // math pass consumes the `=` that blocked this rule: `=|~—` folds to
1085
+ // `≈|—`), and an en/em dash, the head of an already-converted run whose
1086
+ // tail this is.
1080
1087
  if (stackedHere < 2) {
1081
1088
  const before = text[b - 1]
1082
- if (before === undefined || /\s/.test(before) || FOLDED_MATH_OPERATORS.has(before)) continue
1089
+ if (before === undefined || /\s/.test(before) || FOLDED_MATH_OPERATORS.has(before) || before === EN_DASH || before === EM_DASH) continue
1083
1090
  }
1084
1091
  // Dash run from the boundary, not crossing a further boundary.
1085
1092
  let runEnd = b + 1
@@ -1237,11 +1244,15 @@ function removeSpacesAroundEmDash(view: ProseView): void {
1237
1244
  )
1238
1245
  }
1239
1246
 
1240
- /** Preserve a space after a line-leading em-dash before an uppercase/digit. */
1247
+ /**
1248
+ * Preserve a space after a line-leading em-dash before an uppercase/digit. A
1249
+ * vulgar fraction glyph counts as the digit it folds from (`—2/5` and `—⅖`
1250
+ * must agree).
1251
+ */
1241
1252
  function preserveLineLeadingEmDashSpace(view: ProseView): void {
1242
1253
  pass(
1243
1254
  view,
1244
- cachedRegExp(`^${EM_DASH}(?<after>[A-Z0-9])`, "gm"),
1255
+ cachedRegExp(`^${EM_DASH}(?<after>[A-Z0-9${FRACTION_GLYPHS}])`, "gm"),
1245
1256
  (match, v) => {
1246
1257
  // One boundary is tolerated between the line start and the em-dash; two or
1247
1258
  // more exceed that slot and block the match.
package/src/markdown.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { unified } from "unified"
2
2
  import remarkParse from "remark-parse"
3
3
  import remarkGfm from "remark-gfm"
4
- import remarkStringify from "remark-stringify"
4
+ import remarkStringify, { type Options as StringifyOptions } from "remark-stringify"
5
5
  import QuickLRU from "quick-lru"
6
6
 
7
7
  import { MARKDOWN_OPTION_KEYS } from "./sink-option-keys.js"
@@ -35,6 +35,23 @@ export interface MarkdownOptions extends RemarkPunctilioOptions {
35
35
  ruleMarker?: "-" | "*" | "_"
36
36
  }
37
37
 
38
+ /** Stringify settings shared by {@link createProcessor} and {@link remarkRoundTrip}. */
39
+ function stringifyOptions({ emphasisMarker, strongMarker, bulletMarker, ruleMarker }: MarkdownOptions): StringifyOptions {
40
+ return {
41
+ emphasis: emphasisMarker ?? "*",
42
+ strong: strongMarker ?? "*",
43
+ bullet: bulletMarker ?? "-",
44
+ rule: ruleMarker ?? "-",
45
+ // Use 4 dashes (----) for thematic breaks to avoid ambiguity with
46
+ // YAML front matter delimiters (---) during re-parsing
47
+ ...(ruleMarker === "-" || ruleMarker === undefined ? { ruleRepetition: 4 } : {}),
48
+ // remark-stringify escapes opening brackets but not closing brackets,
49
+ // producing `\[1]` instead of `\[1\]`. This can cause issues when the
50
+ // output is re-parsed by markdown renderers.
51
+ unsafe: [{ character: "]", inConstruct: "phrasing" }],
52
+ }
53
+ }
54
+
38
55
  function createProcessor(options: MarkdownOptions) {
39
56
  const {
40
57
  emphasisMarker,
@@ -48,19 +65,7 @@ function createProcessor(options: MarkdownOptions) {
48
65
  .use(remarkParse)
49
66
  .use(remarkGfm)
50
67
  .use(remarkPunctilio, punctilioOptions)
51
- .use(remarkStringify, {
52
- emphasis: emphasisMarker ?? "*",
53
- strong: strongMarker ?? "*",
54
- bullet: bulletMarker ?? "-",
55
- rule: ruleMarker ?? "-",
56
- // Use 4 dashes (----) for thematic breaks to avoid ambiguity with
57
- // YAML front matter delimiters (---) during re-parsing
58
- ...(ruleMarker === "-" || ruleMarker === undefined ? { ruleRepetition: 4 } : {}),
59
- // remark-stringify escapes opening brackets but not closing brackets,
60
- // producing `\[1]` instead of `\[1\]`. This can cause issues when the
61
- // output is re-parsed by markdown renderers.
62
- unsafe: [{ character: "]", inConstruct: "phrasing" }],
63
- })
68
+ .use(remarkStringify, stringifyOptions({ emphasisMarker, strongMarker, bulletMarker, ruleMarker }))
64
69
  }
65
70
 
66
71
  const MAX_PROCESSOR_CACHE_SIZE = 8
@@ -126,17 +131,30 @@ export async function transformMarkdown(
126
131
  options: MarkdownOptions = {}
127
132
  ): Promise<string> {
128
133
  assertKnownOptionKeys(options, MARKDOWN_OPTION_KEYS, "transformMarkdown")
129
- return transformPreservingBom(input, (text) => transformWithFrontmatter(text, options))
134
+ return transformPreservingBom(input, (text) => transformWithFrontmatter(text, (body) => processMarkdown(body, options)))
135
+ }
136
+
137
+ /**
138
+ * @internal Parse, then stringify, with no punctilio pass: the output
139
+ * `transformMarkdown` is held to on a second run (remark's own round trip is
140
+ * not always a fixed point).
141
+ */
142
+ export async function remarkRoundTrip(
143
+ input: string,
144
+ options: MarkdownOptions = {}
145
+ ): Promise<string> {
146
+ const processor = unified().use(remarkParse).use(remarkGfm).use(remarkStringify, stringifyOptions(options))
147
+ return transformPreservingBom(input, (text) => transformWithFrontmatter(text, async (body) => String(await processor.process(body))))
130
148
  }
131
149
 
132
- async function transformWithFrontmatter(input: string, options: MarkdownOptions): Promise<string> {
150
+ async function transformWithFrontmatter(input: string, processBody: (body: string) => Promise<string>): Promise<string> {
133
151
  const frontmatterEnd = frontmatterLength(input)
134
- if (frontmatterEnd === 0) return processMarkdown(input, options)
152
+ if (frontmatterEnd === 0) return processBody(input)
135
153
 
136
154
  // Front matter is data (YAML/TOML), not prose; remark would parse it as a
137
155
  // thematic break plus a heading and rewrite it.
138
156
  const body = input.slice(frontmatterEnd)
139
157
  const blankLines = LEADING_BLANK_LINES_RE.exec(body)![0]
140
- const transformedBody = await processMarkdown(body.slice(blankLines.length), options)
158
+ const transformedBody = await processBody(body.slice(blankLines.length))
141
159
  return input.slice(0, frontmatterEnd) + blankLines + transformedBody
142
160
  }
package/src/nbsp.ts CHANGED
@@ -14,6 +14,16 @@ const {
14
14
 
15
15
  const SPACE = `[${SPACE_CHARS}]`
16
16
 
17
+ /**
18
+ * Glues the space at `offset` with an NBSP. A space that is already
19
+ * non-breaking (NBSP, or the NNBSP French quote padding) stays as written: an
20
+ * NBSP in its place would be re-padded to NNBSP by the next quote pass.
21
+ */
22
+ function glueSpace(view: ProseView, offset: number): void {
23
+ if (NBSP_CHARS.includes(view.text[offset])) return
24
+ view.replace(offset, offset + 1, NBSP)
25
+ }
26
+
17
27
  const UNICODE_UPPERCASE = "\\p{Lu}"
18
28
 
19
29
  // A word-forming character (any Unicode letter or number). Used for the
@@ -197,7 +207,7 @@ function nbspAfterShortWordsOverView(view: ProseView): ReadonlySet<number> {
197
207
  // Edit only the space so any boundary keeps its side of the NBSP.
198
208
  const spaceOffset = offset + match[0].length - 1
199
209
  if (!NBSP_CHARS.includes(clean[spaceOffset])) insertedGlue.add(spaceOffset)
200
- v.replace(spaceOffset, spaceOffset + 1, NBSP)
210
+ glueSpace(v, spaceOffset)
201
211
  return null
202
212
  },
203
213
  {
@@ -226,7 +236,7 @@ function nbspBetweenNumberAndUnitOverView(view: ProseView): void {
226
236
  (match, v) => {
227
237
  // Edit only the space (after the single digit) so any boundaries keep
228
238
  // their sides.
229
- v.replace(match.index + 1, match.index + 2, NBSP)
239
+ glueSpace(v, match.index + 1)
230
240
  return null
231
241
  },
232
242
  {
@@ -304,7 +314,7 @@ function nbspBeforeLastWordOverView(view: ProseView, insertedShortWordGlue: Read
304
314
  if (exceedsSingleBoundary(v, end - match.groups!.ending.length)) return null
305
315
  // Edit only the leading space so the lastWord/ending (and any boundary
306
316
  // before \n\n) keep their positions.
307
- v.replace(start, start + 1, NBSP)
317
+ glueSpace(v, start)
308
318
  return null
309
319
  },
310
320
  {
@@ -375,7 +385,7 @@ function nbspBeforeContext(
375
385
  }
376
386
  // Edit only the trailing space so the slot/lookahead boundaries keep their
377
387
  // sides.
378
- view.replace(end - 1, end, NBSP)
388
+ glueSpace(view, end - 1)
379
389
  }
380
390
  }
381
391
  view.commit()