punctilio 3.9.3 → 3.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/.pre-commit-hooks.yaml +27 -0
  2. package/README.md +55 -2
  3. package/dist/cli.d.ts +21 -0
  4. package/dist/cli.d.ts.map +1 -0
  5. package/dist/cli.js +346 -0
  6. package/dist/cli.js.map +1 -0
  7. package/dist/dashes.d.ts +2 -1
  8. package/dist/dashes.d.ts.map +1 -1
  9. package/dist/dashes.js +5 -3
  10. package/dist/dashes.js.map +1 -1
  11. package/dist/html.d.ts +44 -0
  12. package/dist/html.d.ts.map +1 -0
  13. package/dist/html.js +67 -0
  14. package/dist/html.js.map +1 -0
  15. package/dist/index.d.ts +0 -4
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +18 -26
  18. package/dist/index.js.map +1 -1
  19. package/dist/markdown.d.ts +0 -4
  20. package/dist/markdown.d.ts.map +1 -1
  21. package/dist/markdown.js +2 -5
  22. package/dist/markdown.js.map +1 -1
  23. package/dist/prettier-plugin.d.ts +18 -0
  24. package/dist/prettier-plugin.d.ts.map +1 -0
  25. package/dist/prettier-plugin.js +52 -0
  26. package/dist/prettier-plugin.js.map +1 -0
  27. package/dist/quotes.d.ts +2 -1
  28. package/dist/quotes.d.ts.map +1 -1
  29. package/dist/quotes.js +4 -1
  30. package/dist/quotes.js.map +1 -1
  31. package/dist/rehype.d.ts +14 -36
  32. package/dist/rehype.d.ts.map +1 -1
  33. package/dist/rehype.js +14 -36
  34. package/dist/rehype.js.map +1 -1
  35. package/dist/remark.d.ts +0 -3
  36. package/dist/remark.d.ts.map +1 -1
  37. package/dist/remark.js +0 -7
  38. package/dist/remark.js.map +1 -1
  39. package/dist/symbols.d.ts.map +1 -1
  40. package/dist/symbols.js +6 -5
  41. package/dist/symbols.js.map +1 -1
  42. package/dist/utils.d.ts +26 -9
  43. package/dist/utils.d.ts.map +1 -1
  44. package/dist/utils.js +40 -9
  45. package/dist/utils.js.map +1 -1
  46. package/package.json +25 -2
  47. package/src/cli.ts +417 -0
  48. package/src/dashes.ts +25 -4
  49. package/src/html.ts +91 -0
  50. package/src/index.ts +20 -30
  51. package/src/markdown.ts +2 -7
  52. package/src/prettier-plugin.ts +61 -0
  53. package/src/quotes.ts +5 -2
  54. package/src/rehype.ts +14 -36
  55. package/src/remark.ts +0 -7
  56. package/src/symbols.ts +18 -5
  57. package/src/utils.ts +42 -9
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) {
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Prettier plugin applying punctilio's typographic transforms to Markdown.
3
+ *
4
+ * @example
5
+ * ```jsonc
6
+ * // .prettierrc
7
+ * { "plugins": ["punctilio/prettier-plugin"] }
8
+ *
9
+ * // .punctiliorc.json (optional)
10
+ * { "punctuationStyle": "british", "skipTags": ["pre", "code"] }
11
+ * ```
12
+ *
13
+ * @packageDocumentation
14
+ */
15
+
16
+ import { dirname } from "node:path"
17
+
18
+ import * as prettierMarkdown from "prettier/plugins/markdown"
19
+ import type { Parser, Plugin } from "prettier"
20
+ import type { Root } from "mdast"
21
+ import { cosmiconfig } from "cosmiconfig"
22
+
23
+ import { remarkPunctilio, type RemarkPunctilioOptions } from "./remark.js"
24
+
25
+ const upstreamMarkdown = prettierMarkdown.parsers.markdown as Parser<Root>
26
+ const explorer = cosmiconfig("punctilio")
27
+
28
+ async function loadPunctilioConfig(searchFrom: string): Promise<RemarkPunctilioOptions> {
29
+ const result = await explorer.search(searchFrom)
30
+ return !result || result.isEmpty ? {} : (result.config as RemarkPunctilioOptions)
31
+ }
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
+
46
+ const plugin: Plugin = {
47
+ parsers: {
48
+ markdown: {
49
+ ...upstreamMarkdown,
50
+ async parse(text, options) {
51
+ const ast = await upstreamMarkdown.parse(text, options)
52
+ const searchFrom = typeof options.filepath === "string" ? dirname(options.filepath) : process.cwd()
53
+ const opts = await loadPunctilioConfig(searchFrom)
54
+ await runRemarkPunctilio(opts, ast)
55
+ return ast
56
+ },
57
+ },
58
+ },
59
+ }
60
+
61
+ export default plugin
package/src/quotes.ts CHANGED
@@ -25,7 +25,8 @@ const TERMINAL_PUNCTUATION_CLASS = TERMINAL_PUNCTUATION.join("")
25
25
  */
26
26
  const MAX_NESTED_QUOTES = 4
27
27
 
28
- export type PunctuationStyle = "american" | "british" | "german" | "french" | "none"
28
+ export const PUNCTUATION_STYLES = ["american", "british", "german", "french", "none"] as const
29
+ export type PunctuationStyle = (typeof PUNCTUATION_STYLES)[number]
29
30
 
30
31
  export interface QuoteOptions {
31
32
  /** Boundary marker for HTML element boundaries. Default: "\uE000\uE001" */
@@ -214,7 +215,9 @@ function applyPunctuationStyle(text: string, sep: string, style: PunctuationStyl
214
215
  text = moveOutsideToInside(text, escapedSep, ".", `(?!\\.\\.\\.)\\.`)
215
216
  // Comma outside → inside: Hello", → Hello,"
216
217
  text = moveOutsideToInside(text, escapedSep, ",", `,`)
217
- } 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.
218
221
  // Period inside → outside: "Hello." → "Hello".
219
222
  // No terminal punctuation guard — "Stop!." inside is always wrong; move the period out.
220
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
+ }