punctilio 3.10.0 → 3.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +46 -3
  2. package/dist/cli.d.ts.map +1 -1
  3. package/dist/cli.js +35 -13
  4. package/dist/cli.js.map +1 -1
  5. package/dist/dashes.d.ts.map +1 -1
  6. package/dist/dashes.js +4 -3
  7. package/dist/dashes.js.map +1 -1
  8. package/dist/html.d.ts.map +1 -1
  9. package/dist/html.js +2 -1
  10. package/dist/html.js.map +1 -1
  11. package/dist/index.d.ts +0 -4
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +18 -26
  14. package/dist/index.js.map +1 -1
  15. package/dist/markdown.d.ts +0 -4
  16. package/dist/markdown.d.ts.map +1 -1
  17. package/dist/markdown.js +2 -5
  18. package/dist/markdown.js.map +1 -1
  19. package/dist/prettier-plugin.d.ts.map +1 -1
  20. package/dist/prettier-plugin.js +13 -2
  21. package/dist/prettier-plugin.js.map +1 -1
  22. package/dist/quotes.d.ts.map +1 -1
  23. package/dist/quotes.js +3 -1
  24. package/dist/quotes.js.map +1 -1
  25. package/dist/rehype.d.ts +14 -36
  26. package/dist/rehype.d.ts.map +1 -1
  27. package/dist/rehype.js +14 -36
  28. package/dist/rehype.js.map +1 -1
  29. package/dist/remark.d.ts +0 -3
  30. package/dist/remark.d.ts.map +1 -1
  31. package/dist/remark.js +0 -7
  32. package/dist/remark.js.map +1 -1
  33. package/dist/symbols.d.ts.map +1 -1
  34. package/dist/symbols.js +6 -5
  35. package/dist/symbols.js.map +1 -1
  36. package/dist/utils.d.ts +26 -9
  37. package/dist/utils.d.ts.map +1 -1
  38. package/dist/utils.js +40 -9
  39. package/dist/utils.js.map +1 -1
  40. package/package.json +1 -1
  41. package/src/cli.ts +37 -13
  42. package/src/dashes.ts +23 -3
  43. package/src/html.ts +2 -3
  44. package/src/index.ts +20 -30
  45. package/src/markdown.ts +2 -7
  46. package/src/prettier-plugin.ts +14 -2
  47. package/src/quotes.ts +3 -1
  48. package/src/rehype.ts +14 -36
  49. package/src/remark.ts +0 -7
  50. package/src/symbols.ts +18 -5
  51. package/src/utils.ts +42 -9
package/dist/utils.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"utils.js","sourceRoot":"","sources":["../src/utils.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"utils.js","sourceRoot":"","sources":["../src/utils.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "punctilio",
3
- "version": "3.10.0",
3
+ "version": "3.11.1",
4
4
  "description": "Smart typography transformations: curly quotes, em-dashes, en-dashes, and more",
5
5
  "type": "module",
6
6
  "sideEffects": false,
package/src/cli.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  import { createHash } from "node:crypto"
10
10
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
11
11
  import { readFile, writeFile } from "node:fs/promises"
12
- import { dirname, extname, join, relative, resolve } from "node:path"
12
+ import { dirname, extname, isAbsolute, join, relative, resolve } from "node:path"
13
13
  import { fileURLToPath } from "node:url"
14
14
 
15
15
  import { Command, CommanderError, Option } from "commander"
@@ -21,6 +21,7 @@ import { transformMarkdown, type MarkdownOptions } from "./markdown.js"
21
21
  import { transformHtml, type HtmlOptions } from "./html.js"
22
22
  import { PUNCTUATION_STYLES, type PunctuationStyle } from "./quotes.js"
23
23
  import { DASH_STYLES, type DashStyle } from "./dashes.js"
24
+ import { stableStringify } from "./utils.js"
24
25
 
25
26
  type CliOptions = MarkdownOptions & HtmlOptions
26
27
  type FileType = "md" | "html"
@@ -182,18 +183,27 @@ function hashString(s: string): string {
182
183
  }
183
184
 
184
185
  function hashOptions(opts: CliOptions): string {
185
- const stable = Object.entries(opts)
186
- .filter(([, v]) => v !== undefined)
187
- .sort(([a], [b]) => a.localeCompare(b))
188
- return hashString(JSON.stringify(stable))
186
+ return hashString(stableStringify(opts))
189
187
  }
190
188
 
191
- function loadCache(location: string): Cache {
189
+ function loadCache(location: string, stderr: NodeJS.WritableStream): Cache {
192
190
  if (!existsSync(location)) return { files: {} }
193
191
  try {
194
192
  const parsed = JSON.parse(readFileSync(location, "utf8")) as Cache
195
- return parsed.files ? parsed : { files: {} }
193
+ if (!parsed.files) {
194
+ stderr.write(`Warning: cache at ${location} is missing the "files" key; discarding.\n`)
195
+ return { files: {} }
196
+ }
197
+ // Drop absolute-path keys carried over from a pre-cwd-relative version
198
+ // of the cache. They never match modern lookups, so keeping them
199
+ // forever would just bloat the cache file on every save.
200
+ const files: Record<string, CacheEntry> = {}
201
+ for (const [k, v] of Object.entries(parsed.files)) {
202
+ if (!isAbsolute(k)) files[k] = v
203
+ }
204
+ return { files }
196
205
  } catch {
206
+ stderr.write(`Warning: cache at ${location} could not be parsed; discarding.\n`)
197
207
  return { files: {} }
198
208
  }
199
209
  }
@@ -203,6 +213,16 @@ function saveCache(location: string, cache: Cache): void {
203
213
  writeFileSync(location, JSON.stringify(cache))
204
214
  }
205
215
 
216
+ /**
217
+ * Cache key for a file. Always cwd-relative so a project that moves
218
+ * directories (or syncs the cache file across machines via the same
219
+ * repo layout) keeps hitting the cache. Files outside cwd appear as
220
+ * `../foo.md` and still hash consistently.
221
+ */
222
+ function cacheKey(filePath: string, cwd: string): string {
223
+ return relative(cwd, filePath)
224
+ }
225
+
206
226
  function buildOptions(flags: ParsedFlags): CliOptions {
207
227
  const opts: CliOptions = {}
208
228
  if (flags.punctuationStyle) opts.punctuationStyle = flags.punctuationStyle
@@ -344,18 +364,22 @@ async function runValidated(
344
364
  const cacheLocation = flags.cache === false
345
365
  ? undefined
346
366
  : resolve(cwd, flags.cacheLocation ?? DEFAULT_CACHE_LOCATION)
347
- const cache = cacheLocation ? loadCache(cacheLocation) : undefined
367
+ const cache = cacheLocation ? loadCache(cacheLocation, io.stderr) : undefined
348
368
  const optionsHash = cache ? hashOptions(opts) : ""
349
369
 
350
370
  let anyChanged = false
351
371
  for (const path of files) {
352
372
  const type = inferFileType(path, flags.type)
353
373
  const original = await readFile(path, "utf8")
374
+ const key = cache ? cacheKey(path, cwd) : ""
354
375
  if (cache) {
355
- const entry = cache.files[path]
356
- if (entry?.contentHash === hashString(original)
357
- && entry.optionsHash === optionsHash
358
- && entry.version === version) continue
376
+ const entry = cache.files[key]
377
+ if (
378
+ entry !== undefined &&
379
+ entry.contentHash === hashString(original) &&
380
+ entry.optionsHash === optionsHash &&
381
+ entry.version === version
382
+ ) continue
359
383
  }
360
384
 
361
385
  const formatted = matchTrailingNewline(original, await transformContent(original, type, opts))
@@ -370,7 +394,7 @@ async function runValidated(
370
394
  }
371
395
  }
372
396
  if (cache && (unchanged || !check)) {
373
- cache.files[path] = { contentHash: hashString(formatted), optionsHash, version }
397
+ cache.files[key] = { contentHash: hashString(formatted), optionsHash, version }
374
398
  }
375
399
  }
376
400
 
package/src/dashes.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  */
4
4
 
5
5
  import { UNICODE_SYMBOLS, DEFAULT_SEPARATOR, LATIN_LETTERS, wordBoundaryStart, wordBoundaryEnd, getEscapedSeparator, cachedRegExp } from "./constants.js"
6
+ import { namedGroups } from "./utils.js"
6
7
 
7
8
  export const DASH_STYLES = ["american", "british", "none"] as const
8
9
  export type DashStyle = (typeof DASH_STYLES)[number]
@@ -69,7 +70,13 @@ export function enDashNumberRange(text: string, options: DashOptions = {}): stri
69
70
  text = text.replace(
70
71
  cachedRegExp(positiveRangePattern, "g"),
71
72
  (...args) => {
72
- const groups = args.at(-1) as Record<string, string>
73
+ const groups = namedGroups<{
74
+ precedingAreaCode?: string
75
+ start: string
76
+ end: string
77
+ following?: string
78
+ suffix?: string
79
+ }>(args)
73
80
  if (groups.following) return args[0] as string
74
81
  const startNum = groups.start.replace(cachedRegExp(chr, "g"), "")
75
82
  const endNum = groups.end.replace(cachedRegExp(chr, "g"), "")
@@ -96,7 +103,13 @@ export function enDashNumberRange(text: string, options: DashOptions = {}): stri
96
103
  "g"
97
104
  ),
98
105
  (...args) => {
99
- const groups = args.at(-1) as Record<string, string>
106
+ const groups = namedGroups<{
107
+ start: string
108
+ neg?: string
109
+ end: string
110
+ following?: string
111
+ suffix?: string
112
+ }>(args)
100
113
  if (groups.following) return args[0] as string
101
114
  return `${groups.start}${EN_DASH}${groups.neg ? MINUS : ""}${groups.end}${groups.suffix ?? ""}`
102
115
  }
@@ -121,7 +134,14 @@ export function enDashDateRange(text: string, options: DashOptions = {}): string
121
134
  return text.replace(
122
135
  cachedRegExp(`${wb}(?<startMonth>${monthPattern})${startYear}(?<preSep>${chr}?)(?<preSpace> ?)-(?<postSpace> ?)(?<postSep>${chr}?)(?<endMonth>${monthPattern})${endYear}${wbe}`, "g"),
123
136
  (...args) => {
124
- const groups = args.at(-1) as Record<string, string>
137
+ const groups = namedGroups<{
138
+ startMonth: string
139
+ startYear?: string
140
+ preSep: string
141
+ endMonth: string
142
+ endYear?: string
143
+ postSep: string
144
+ }>(args)
125
145
  const [pre, post] = dashStyle === "british" ? [" ", " "] : ["", ""]
126
146
  return `${groups.startMonth}${groups.startYear || ""}${groups.preSep}${pre}${EN_DASH}${post}${groups.postSep}${groups.endMonth}${groups.endYear || ""}`
127
147
  }
package/src/html.ts CHANGED
@@ -15,6 +15,7 @@ import rehypeStringify from "rehype-stringify"
15
15
  import QuickLRU from "quick-lru"
16
16
 
17
17
  import { rehypePunctilio, type RehypePunctilioOptions } from "./rehype.js"
18
+ import { stableStringify } from "./utils.js"
18
19
 
19
20
  /**
20
21
  * Options for the HTML transform pipeline.
@@ -77,9 +78,7 @@ export async function transformHtml(
77
78
  return String(result)
78
79
  }
79
80
 
80
- const optionsKey = JSON.stringify(
81
- Object.entries(options).filter(([, v]) => v !== undefined).sort(([a], [b]) => a.localeCompare(b))
82
- )
81
+ const optionsKey = stableStringify(options)
83
82
 
84
83
  let processor = processorCache.get(optionsKey)
85
84
  if (!processor) {
package/src/index.ts CHANGED
@@ -173,7 +173,7 @@ import { niceQuotes } from "./quotes.js"
173
173
  import { hyphenReplace } from "./dashes.js"
174
174
  import { symbolTransform, fractions as fractionsTransform, degrees as degreesTransform, superscriptOrdinal as superscriptTransform, primeMarks, collapseSpaces as collapseSpacesTransform, punctuationLigatures as ligaturesTransform } from "./symbols.js"
175
175
  import { nbspTransform as nbspTransformFn } from "./nbsp.js"
176
- import { assertSeparatorCountPreserved, formatErrorString } from "./utils.js"
176
+ import { assertSeparatorCountPreserved, filterUndefined, formatErrorString } from "./utils.js"
177
177
  import { DEFAULT_SEPARATOR, ISSUES_URL, UNICODE_SYMBOLS } from "./constants.js"
178
178
 
179
179
  export { assertSeparatorAbsent, assertSeparatorCountPreserved, countSeparators, transformTextNodes } from "./utils.js"
@@ -210,10 +210,6 @@ const defaultOpts: Required<Omit<TransformOptions, "separator">> = {
210
210
  * 9. collapseSpaces (collapses multiple spaces into one)
211
211
  * 10. nbsp (non-breaking spaces, enabled by default)
212
212
  *
213
- * @param text - The text to transform
214
- * @param options - Configuration options
215
- * @returns The text with all typography improvements applied
216
- *
217
213
  * @example
218
214
  * ```ts
219
215
  * import { transform } from 'punctilio'
@@ -231,52 +227,46 @@ const defaultOpts: Required<Omit<TransformOptions, "separator">> = {
231
227
  export function transform(text: string, options: TransformOptions = {}): string {
232
228
  const separator = options.separator ?? DEFAULT_SEPARATOR
233
229
 
234
- // Validate separator: must be non-empty and contain only BMP characters.
235
- // for...of iterates by code point; surrogate pairs (non-BMP) yield a
236
- // two-UTF16-unit string, so ch.length > 1 detects them.
237
230
  if (separator.length === 0) {
238
231
  throw new Error("Invalid separator: must not be empty.")
239
232
  }
240
- for (const ch of separator) {
241
- if (ch.length > 1) {
242
- throw new Error(
243
- `Invalid separator: must contain only BMP characters (no characters outside the Basic Multilingual Plane). ` +
244
- `Received "${separator}" which contains a non-BMP character.`
245
- )
246
- }
233
+ // Non-BMP characters are encoded in UTF-16 as surrogate pairs in the range
234
+ // U+D800–U+DFFF. A surrogate code unit (paired or lone) means the separator
235
+ // would survive some regexes as a single char and split others as two units.
236
+ if (/[\uD800-\uDFFF]/.test(separator)) {
237
+ throw new Error(
238
+ `Invalid separator: must contain only BMP characters (no characters outside the Basic Multilingual Plane). ` +
239
+ `Received "${separator}" which contains a non-BMP character.`
240
+ )
247
241
  }
248
242
 
249
243
  const original = text
250
- // Filter undefined so { nbsp: undefined } uses the default, not falsy override
251
- const definedOptions = Object.fromEntries(
252
- Object.entries(options).filter(([, v]) => v !== undefined)
253
- ) as TransformOptions
254
- const { symbols, fractions, degrees, superscript, ligatures, nbsp, collapseSpaces, checkIdempotency, ...separatorOpts } = { ...defaultOpts, ...definedOptions }
244
+ const { symbols, fractions, degrees, superscript, ligatures, nbsp, collapseSpaces, checkIdempotency, ...pipelineOpts } = { ...defaultOpts, ...filterUndefined(options) }
255
245
 
256
- text = hyphenReplace(text, separatorOpts)
257
- if (separatorOpts.punctuationStyle !== "none") {
258
- text = primeMarks(text, separatorOpts)
246
+ text = hyphenReplace(text, pipelineOpts)
247
+ if (pipelineOpts.punctuationStyle !== "none") {
248
+ text = primeMarks(text, pipelineOpts)
259
249
  }
260
- text = niceQuotes(text, separatorOpts)
250
+ text = niceQuotes(text, pipelineOpts)
261
251
 
262
252
  if (symbols) {
263
- text = symbolTransform(text, separatorOpts)
253
+ text = symbolTransform(text, pipelineOpts)
264
254
  }
265
255
 
266
256
  if (fractions) {
267
- text = fractionsTransform(text, separatorOpts)
257
+ text = fractionsTransform(text, pipelineOpts)
268
258
  }
269
259
 
270
260
  if (degrees) {
271
- text = degreesTransform(text, separatorOpts)
261
+ text = degreesTransform(text, pipelineOpts)
272
262
  }
273
263
 
274
264
  if (superscript) {
275
- text = superscriptTransform(text, separatorOpts)
265
+ text = superscriptTransform(text, pipelineOpts)
276
266
  }
277
267
 
278
268
  if (ligatures) {
279
- text = ligaturesTransform(text, separatorOpts)
269
+ text = ligaturesTransform(text, pipelineOpts)
280
270
  }
281
271
 
282
272
  if (collapseSpaces) {
@@ -284,7 +274,7 @@ export function transform(text: string, options: TransformOptions = {}): string
284
274
  }
285
275
 
286
276
  if (nbsp) {
287
- text = nbspTransformFn(text, separatorOpts)
277
+ text = nbspTransformFn(text, pipelineOpts)
288
278
  }
289
279
 
290
280
  assertSeparatorCountPreserved(original, text, separator, "transform")
package/src/markdown.ts CHANGED
@@ -16,6 +16,7 @@ import remarkStringify from "remark-stringify"
16
16
  import QuickLRU from "quick-lru"
17
17
 
18
18
  import { remarkPunctilio, type RemarkPunctilioOptions } from "./remark.js"
19
+ import { stableStringify } from "./utils.js"
19
20
 
20
21
  /**
21
22
  * Options for the Markdown transform pipeline.
@@ -94,10 +95,6 @@ export function clearProcessorCache(): void {
94
95
  * The unified processor pipeline is cached and reused across calls with
95
96
  * identical options, avoiding redundant pipeline construction.
96
97
  *
97
- * @param input - The Markdown text to transform
98
- * @param options - Configuration options for both the transform and serializer
99
- * @returns The typographically improved Markdown text
100
- *
101
98
  * @example
102
99
  * ```ts
103
100
  * import { transformMarkdown } from 'punctilio/markdown'
@@ -110,9 +107,7 @@ export async function transformMarkdown(
110
107
  input: string,
111
108
  options: MarkdownOptions = {}
112
109
  ): Promise<string> {
113
- const optionsKey = JSON.stringify(
114
- Object.entries(options).filter(([, v]) => v !== undefined).sort(([a], [b]) => a.localeCompare(b))
115
- )
110
+ const optionsKey = stableStringify(options)
116
111
 
117
112
  let processor = processorCache.get(optionsKey)
118
113
  if (!processor) {
@@ -30,6 +30,19 @@ async function loadPunctilioConfig(searchFrom: string): Promise<RemarkPunctilioO
30
30
  return !result || result.isEmpty ? {} : (result.config as RemarkPunctilioOptions)
31
31
  }
32
32
 
33
+ /**
34
+ * Runs `remarkPunctilio`'s transformer against an mdast tree. The plugin
35
+ * mutates the tree in place; awaiting handles any future async variant
36
+ * without churning callers.
37
+ */
38
+ async function runRemarkPunctilio(opts: RemarkPunctilioOptions, ast: Root): Promise<void> {
39
+ // The unified `Transformer` signature requires a VFile and a callback,
40
+ // but `remarkPunctilio` only ever reads the tree. Narrow to the actual
41
+ // shape here so the cast lives in one well-commented place.
42
+ const transformer = remarkPunctilio(opts) as (tree: Root) => void | Promise<void>
43
+ await transformer(ast)
44
+ }
45
+
33
46
  const plugin: Plugin = {
34
47
  parsers: {
35
48
  markdown: {
@@ -38,8 +51,7 @@ const plugin: Plugin = {
38
51
  const ast = await upstreamMarkdown.parse(text, options)
39
52
  const searchFrom = typeof options.filepath === "string" ? dirname(options.filepath) : process.cwd()
40
53
  const opts = await loadPunctilioConfig(searchFrom)
41
- const transform = remarkPunctilio(opts) as (tree: Root) => void
42
- transform(ast)
54
+ await runRemarkPunctilio(opts, ast)
43
55
  return ast
44
56
  },
45
57
  },
package/src/quotes.ts CHANGED
@@ -215,7 +215,9 @@ function applyPunctuationStyle(text: string, sep: string, style: PunctuationStyl
215
215
  text = moveOutsideToInside(text, escapedSep, ".", `(?!\\.\\.\\.)\\.`)
216
216
  // Comma outside → inside: Hello", → Hello,"
217
217
  text = moveOutsideToInside(text, escapedSep, ",", `,`)
218
- } else if (style === "british" || style === "german" || style === "french") {
218
+ } else {
219
+ // Every non-"american" non-"none" style (british/german/french) places
220
+ // punctuation outside the quotes.
219
221
  // Period inside → outside: "Hello." → "Hello".
220
222
  // No terminal punctuation guard — "Stop!." inside is always wrong; move the period out.
221
223
  // Match ALL consecutive closing quotes so nested quotes like .'" become '". in one pass.
package/src/rehype.ts CHANGED
@@ -78,13 +78,9 @@ const DEFAULT_SKIP_TAGS = ["code", "pre", "script", "style", "kbd", "var", "samp
78
78
  /**
79
79
  * Flattens text nodes from an element tree into a single array.
80
80
  *
81
- * @param node - The element or element content to process
82
- * @param shouldSkip - Function to determine which elements to skip
83
- * @param options - Optional flattening options. Pass `shouldSkipText` to
84
- * exclude individual text nodes from the result. Applied after
85
- * element-level `shouldSkip`, so the hook is never invoked for text
86
- * inside an already-skipped element.
87
- * @returns Array of Text nodes
81
+ * Pass `options.shouldSkipText` to exclude individual text nodes from the
82
+ * result. Applied after element-level `shouldSkip`, so the hook is never
83
+ * invoked for text inside an already-skipped element.
88
84
  *
89
85
  * @example
90
86
  * ```ts
@@ -143,10 +139,6 @@ function flattenTextNodesImpl(
143
139
  /**
144
140
  * Extracts concatenated text content from an element.
145
141
  *
146
- * @param node - The element to extract text from
147
- * @param shouldSkip - Optional function to determine which elements to skip
148
- * @returns The combined text content
149
- *
150
142
  * @example
151
143
  * ```ts
152
144
  * const text = getTextContent(paragraphElement)
@@ -164,10 +156,7 @@ export function getTextContent(
164
156
 
165
157
  /**
166
158
  * Recursively finds the first text node in a tree of HTML elements.
167
- *
168
- * @param node - The root node to search from
169
- * @param depth - Current recursion depth (internal use)
170
- * @returns The first text node found, or null if no text nodes exist
159
+ * Returns null if no text nodes exist.
171
160
  *
172
161
  * @example
173
162
  * ```ts
@@ -205,7 +194,6 @@ const CLOSER_TO_OPENER: Record<string, string> = { "\u201D": "\u201C" }
205
194
  * excluded because \u2019 (right single quote) doubles as an apostrophe,
206
195
  * making balance-checking unreliable.
207
196
  *
208
- * @param input - The text to validate
209
197
  * @throws Error if double quotes are mismatched
210
198
  *
211
199
  * @example
@@ -245,20 +233,17 @@ export function assertSmartQuotesMatch(input: string): void {
245
233
  * 2. Concatenates and transforms the whole paragraph
246
234
  * 3. Splits the result back into the original text nodes
247
235
  *
248
- * @param node - The element to transform
249
- * @param transformFn - The transformation function to apply
250
- * @param shouldSkip - Function to determine which elements to skip
251
- * @param separator - The marker character to use (default: DEFAULT_SEPARATOR)
252
- * @param checkInvariance - Whether to verify that the transform produces the same
253
- * result with and without markers. When true, checks that
254
- * `stripMarkers(transform(textWithMarkers)) === transform(stripMarkers(text))`.
255
- * Useful for debugging transforms that accidentally interact with markers.
256
- * Default: false
257
- * @param options - Optional transform options. Pass `shouldSkipText` here to
258
- * opt individual text nodes out of transformation without skipping their
259
- * enclosing element. Skipped text nodes keep their original `.value`.
236
+ * When `checkInvariance` is true, also verifies that the transform produces
237
+ * the same result with and without markers — i.e.,
238
+ * `stripMarkers(transform(textWithMarkers)) === transform(stripMarkers(text))`.
239
+ * Useful for debugging transforms that accidentally interact with markers.
240
+ *
241
+ * Pass `options.shouldSkipText` to opt individual text nodes out of
242
+ * transformation without skipping their enclosing element; skipped text
243
+ * nodes keep their original `.value`.
244
+ *
260
245
  * @throws Error if transformation alters the number of text nodes
261
- * @throws Error if checkInvariance is true and the invariance check fails
246
+ * @throws Error if `checkInvariance` is true and the invariance check fails
262
247
  *
263
248
  * @example
264
249
  * ```ts
@@ -430,11 +415,7 @@ function hasTextDescendant(
430
415
  * When an element has block-level children, we recurse so each block
431
416
  * is processed independently (e.g., `<div><p>A</p><p>B</p></div>`).
432
417
  *
433
- * @param node - The root element to search from
434
- * @param shouldSkip - Function to determine which elements to skip
435
- * @param depth - Current recursion depth (internal use)
436
418
  * @param alreadyTransformed - Elements already processed; skipped during traversal to avoid redundant work
437
- * @returns Array of elements that contain transformable text
438
419
  */
439
420
  export function collectTransformableElements(
440
421
  node: Element,
@@ -503,9 +484,6 @@ function markDescendants(node: Element, set: Set<Element>, depth: number = 0): v
503
484
  /**
504
485
  * Rehype plugin that applies punctilio typography transformations to HTML.
505
486
  *
506
- * @param options - Plugin configuration options
507
- * @returns Unified transformer function
508
- *
509
487
  * @example
510
488
  * ```ts
511
489
  * import { unified } from 'unified'
package/src/remark.ts CHANGED
@@ -32,10 +32,6 @@ const SKIP_TYPES = new Set(["inlineCode", "html"])
32
32
  /**
33
33
  * Recursively collects text nodes from a phrasing content tree,
34
34
  * skipping code and HTML nodes.
35
- *
36
- * @param node - The phrasing content node to collect from
37
- * @param depth - Current recursion depth (internal use)
38
- * @returns Array of Text nodes
39
35
  */
40
36
  function flattenTextNodes(
41
37
  node: PhrasingContent,
@@ -66,9 +62,6 @@ function flattenTextNodes(
66
62
  /**
67
63
  * Remark plugin that applies punctilio typography transformations to Markdown.
68
64
  *
69
- * @param options - Plugin configuration options
70
- * @returns Unified transformer function
71
- *
72
65
  * @example
73
66
  * ```ts
74
67
  * import { unified } from 'unified'
package/src/symbols.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  */
4
4
 
5
5
  import { UNICODE_SYMBOLS, DEFAULT_SEPARATOR, LATIN_LETTERS, wordBoundaryEnd, SPACE_CHARS, cachedRegExp, getEscapedSeparator } from "./constants.js"
6
+ import { namedGroups } from "./utils.js"
6
7
 
7
8
  export interface SymbolOptions {
8
9
  /** Boundary marker for HTML element boundaries. Default: "\uE000\uE001" */
@@ -49,7 +50,7 @@ export function ellipsis(text: string, options: SymbolOptions = {}): string {
49
50
  // into one ellipsis.
50
51
  const pattern = cachedRegExp(`\\.(?:[${SPACE_CHARS}]|(?<sep1>${chr}))?\\.(?:[${SPACE_CHARS}]?|(?<sep2>${chr})?)\\.`, "g")
51
52
  text = text.replace(pattern, (...args) => {
52
- const { sep1, sep2 } = args.at(-1) as Record<string, string | undefined>
53
+ const { sep1, sep2 } = namedGroups<{ sep1?: string; sep2?: string }>(args)
53
54
  return ELLIPSIS + (sep1 ?? "") + (sep2 ?? "")
54
55
  })
55
56
 
@@ -94,7 +95,7 @@ export function multiplication(text: string, options: SymbolOptions = {}): strin
94
95
  )
95
96
 
96
97
  text = text.replace(chainPattern, (...args) => {
97
- const { firstNum, rest } = args.at(-1) as Record<string, string>
98
+ const { firstNum, rest } = namedGroups<{ firstNum: string; rest: string }>(args)
98
99
  // Skip hexadecimal: 0x... or 0X...
99
100
  if (firstNum === "0" && /^x/i.test(rest)) return args[0] as string
100
101
 
@@ -111,7 +112,13 @@ export function multiplication(text: string, options: SymbolOptions = {}): strin
111
112
  "gy"
112
113
  ),
113
114
  (...innerArgs) => {
114
- const groups = innerArgs.at(-1) as Record<string, string>
115
+ const groups = namedGroups<{
116
+ pre: string
117
+ spaceBefore: string
118
+ spaceAfter: string
119
+ post: string
120
+ num: string
121
+ }>(innerArgs)
115
122
  const space = groups.spaceBefore || groups.spaceAfter ? " " : ""
116
123
  return `${groups.pre}${space}${MULTIPLICATION}${space}${groups.post}${groups.num}`
117
124
  }
@@ -164,7 +171,7 @@ export function mathSymbols(text: string, options: SymbolOptions = {}): string {
164
171
  // and preserves the text-node-count invariant in transformTextNodes.
165
172
  const pattern = cachedRegExp(`${left}(?<sep>${chr})?${right}${lookahead(chr)}`, "g")
166
173
  text = text.replace(pattern, (...args) => {
167
- const { sep } = args.at(-1) as Record<string, string | undefined>
174
+ const { sep } = namedGroups<{ sep?: string }>(args)
168
175
  return `${replacement}${sep ?? ""}`
169
176
  })
170
177
  }
@@ -348,7 +355,13 @@ function balancedPrimeReplacer(primeChar: string, escapedSeparator: string) {
348
355
  // Replace callback: (match, ...captures, offset, fullString, namedGroups)
349
356
  return (...args: unknown[]): string => {
350
357
  const fullMatch = args[0] as string
351
- const groups = args.at(-1) as Record<string, string | undefined>
358
+ const groups = namedGroups<{
359
+ digit?: string
360
+ sep?: string
361
+ afterSep?: string
362
+ contraction?: string
363
+ trailing?: string
364
+ }>(args)
352
365
 
353
366
  // Contraction (letter + quote + letter): skip entirely
354
367
  if (groups.contraction !== undefined) {
package/src/utils.ts CHANGED
@@ -40,8 +40,6 @@ export function formatErrorString(content: string, label: string): string {
40
40
  * Throws if any text node contains the separator character, which would
41
41
  * corrupt the split/join mechanism used by the rehype and remark plugins.
42
42
  *
43
- * @param textValues - The raw text values to check
44
- * @param separator - The separator character
45
43
  * @throws Error if the separator is found in any text value
46
44
  */
47
45
  export function assertSeparatorAbsent(textValues: string[], separator: string): void {
@@ -88,9 +86,8 @@ interface TextNode {
88
86
  * 4. Splits the result back on the separator and writes each fragment
89
87
  * back into the corresponding text node.
90
88
  *
91
- * @param textNodes - The text nodes to transform (mutated in place)
92
- * @param transformFn - The transformation function to apply
93
- * @param separator - The marker string used to track node boundaries
89
+ * `textNodes` is mutated in place.
90
+ *
94
91
  * @throws Error if the transformation alters the number of text nodes
95
92
  */
96
93
  export function transformTextNodes(
@@ -122,10 +119,6 @@ export function transformTextNodes(
122
119
  * Validates that a transformation preserved the separator count.
123
120
  * Throws an error if separators were added or removed.
124
121
  *
125
- * @param original - The original text before transformation
126
- * @param transformed - The text after transformation
127
- * @param separator - The separator character to check (default: DEFAULT_SEPARATOR)
128
- * @param transformName - Name of the transform for error messages (default: "transform")
129
122
  * @throws Error if separator count changed
130
123
  */
131
124
  export function assertSeparatorCountPreserved(
@@ -144,3 +137,43 @@ export function assertSeparatorCountPreserved(
144
137
  )
145
138
  }
146
139
  }
140
+
141
+ /**
142
+ * Returns a copy of `obj` with `undefined`-valued keys removed.
143
+ * Used so caller-supplied `{ foo: undefined }` doesn't override a default.
144
+ *
145
+ * @internal
146
+ */
147
+ export function filterUndefined<T extends object>(obj: T): Partial<T> {
148
+ const result: Record<string, unknown> = {}
149
+ for (const [k, v] of Object.entries(obj)) {
150
+ if (v !== undefined) result[k] = v
151
+ }
152
+ return result as Partial<T>
153
+ }
154
+
155
+ /**
156
+ * Returns a deterministic JSON-style string for an options object: drops
157
+ * `undefined` values and sorts keys, so equivalent option sets always
158
+ * produce the same string (suitable as a cache or LRU key).
159
+ *
160
+ * @internal
161
+ */
162
+ export function stableStringify(obj: object): string {
163
+ const entries = Object.entries(obj)
164
+ .filter(([, v]) => v !== undefined)
165
+ .sort(([a], [b]) => a.localeCompare(b))
166
+ return JSON.stringify(entries)
167
+ }
168
+
169
+ /**
170
+ * Extracts the named-groups object from a `String.prototype.replace`
171
+ * callback's argument tuple. The last element is always the groups
172
+ * object when the regex has named captures. Callers supply the group
173
+ * shape via the type parameter to localise the unavoidable type cast.
174
+ *
175
+ * @internal
176
+ */
177
+ export function namedGroups<G>(args: unknown[]): G {
178
+ return args[args.length - 1] as G
179
+ }