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/cli.ts ADDED
@@ -0,0 +1,417 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Command-line entry point: format Markdown and HTML files in place,
4
+ * or check whether they would be reformatted (for use as a pre-commit hook).
5
+ *
6
+ * @packageDocumentation
7
+ */
8
+
9
+ import { createHash } from "node:crypto"
10
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
11
+ import { readFile, writeFile } from "node:fs/promises"
12
+ import { dirname, extname, isAbsolute, join, relative, resolve } from "node:path"
13
+ import { fileURLToPath } from "node:url"
14
+
15
+ import { Command, CommanderError, Option } from "commander"
16
+ import { cosmiconfig } from "cosmiconfig"
17
+ import ignore, { type Ignore } from "ignore"
18
+ import { glob } from "tinyglobby"
19
+
20
+ import { transformMarkdown, type MarkdownOptions } from "./markdown.js"
21
+ import { transformHtml, type HtmlOptions } from "./html.js"
22
+ import { PUNCTUATION_STYLES, type PunctuationStyle } from "./quotes.js"
23
+ import { DASH_STYLES, type DashStyle } from "./dashes.js"
24
+ import { stableStringify } from "./utils.js"
25
+
26
+ type CliOptions = MarkdownOptions & HtmlOptions
27
+ type FileType = "md" | "html"
28
+
29
+ interface CliIO {
30
+ stdin: AsyncIterable<string | Buffer>
31
+ stdout: NodeJS.WritableStream
32
+ stderr: NodeJS.WritableStream
33
+ }
34
+
35
+ class UsageError extends Error {}
36
+
37
+ const FILE_TYPES = ["md", "html"] as const satisfies readonly FileType[]
38
+ const EMPHASIS_MARKERS = ["*", "_"] as const
39
+ const BULLET_MARKERS = ["-", "*", "+"] as const
40
+ const RULE_MARKERS = ["-", "*", "_"] as const
41
+
42
+ const MARKDOWN_EXTENSIONS = new Set([".md", ".markdown"])
43
+ const HTML_EXTENSIONS = new Set([".html", ".htm"])
44
+
45
+ /**
46
+ * Shape of `program.opts()` after parsing. Fields named after their CLI
47
+ * flags (camelCased by commander). `commander.choices()` guarantees the
48
+ * literal types at runtime; we assert them at the boundary.
49
+ */
50
+ interface ParsedFlags {
51
+ check?: boolean
52
+ cache?: boolean
53
+ cacheLocation?: string
54
+ config?: string | false // string if --config <path>, false if --no-config
55
+ ignorePath?: string
56
+ stdinFilepath?: string
57
+ type?: FileType
58
+ punctuationStyle?: PunctuationStyle
59
+ dashStyle?: DashStyle
60
+ symbols?: boolean
61
+ nbsp?: boolean
62
+ collapseSpaces?: boolean
63
+ arrows?: boolean
64
+ fractions?: boolean
65
+ degrees?: boolean
66
+ superscript?: boolean
67
+ ligatures?: boolean
68
+ emphasisMarker?: "*" | "_"
69
+ strongMarker?: "*" | "_"
70
+ bulletMarker?: "-" | "*" | "+"
71
+ ruleMarker?: "-" | "*" | "_"
72
+ skipTag?: string[]
73
+ skipClass?: string[]
74
+ fragment?: boolean
75
+ }
76
+
77
+ function buildProgram(version: string, io: CliIO): Command {
78
+ return new Command()
79
+ .name("punctilio")
80
+ .description("Apply typographic improvements to Markdown and HTML files.")
81
+ .version(version, "-V, --version", "show version")
82
+ .argument("[files...]", "files to format (pass '-' to read from stdin)")
83
+ .option("--check", "exit 1 if any file would change; write nothing")
84
+ .option("--config <path>", "path to a punctilio config file (otherwise auto-discovered)")
85
+ .option("--no-config", "ignore any auto-discovered config file")
86
+ .option("--ignore-path <path>", "path to an ignore file (otherwise .punctilioignore in cwd)")
87
+ .option("--no-cache", "disable the incremental cache (default location node_modules/.cache/punctilio/cache.json)")
88
+ .option("--cache-location <path>", "override the cache file location")
89
+ .option("--stdin-filepath <path>", "treat stdin as if it were this filename (infers type from extension)")
90
+ .addOption(new Option("--type <type>", "force file type (otherwise inferred from extension)").choices(FILE_TYPES))
91
+ .addOption(new Option("--punctuation-style <style>", "quote and punctuation style").choices(PUNCTUATION_STYLES))
92
+ .addOption(new Option("--dash-style <style>", "dash style").choices(DASH_STYLES))
93
+ .option("--no-symbols", "disable symbol transforms (ellipsis, ×, ©, etc.)")
94
+ .option("--no-nbsp", "disable non-breaking space insertion")
95
+ .option("--no-collapse-spaces", "preserve runs of consecutive spaces")
96
+ .option("--no-arrows", "disable -> → arrow transforms")
97
+ .option("--fractions", "enable 1/2 → ½")
98
+ .option("--degrees", "enable 20 C → 20 °C")
99
+ .option("--superscript", "enable 1st → 1ˢᵗ")
100
+ .option("--ligatures", "enable !? → ⁉")
101
+ .addOption(new Option("--emphasis-marker <char>", "markdown emphasis character").choices(EMPHASIS_MARKERS))
102
+ .addOption(new Option("--strong-marker <char>", "markdown strong emphasis character").choices(EMPHASIS_MARKERS))
103
+ .addOption(new Option("--bullet-marker <char>", "markdown list bullet character").choices(BULLET_MARKERS))
104
+ .addOption(new Option("--rule-marker <char>", "markdown thematic break character").choices(RULE_MARKERS))
105
+ .option("--skip-tag <tag...>", "HTML tags whose contents are left alone (repeatable)")
106
+ .option("--skip-class <class...>", "HTML class names whose elements are skipped (repeatable)")
107
+ .option("--no-fragment", "parse input as a full HTML document")
108
+ .exitOverride()
109
+ .configureOutput({
110
+ writeOut: (str) => { io.stdout.write(str) },
111
+ writeErr: (str) => { io.stderr.write(str) },
112
+ })
113
+ }
114
+
115
+ function inferFileType(path: string, override?: FileType): FileType {
116
+ if (override !== undefined) return override
117
+ const ext = extname(path).toLowerCase()
118
+ if (MARKDOWN_EXTENSIONS.has(ext)) return "md"
119
+ if (HTML_EXTENSIONS.has(ext)) return "html"
120
+ throw new UsageError(
121
+ `Cannot infer file type from extension "${ext}" for ${path}. Pass --type md|html.`,
122
+ )
123
+ }
124
+
125
+ // Config keys must match library option names (punctuationStyle, skipTags, …),
126
+ // not the kebab-cased CLI flag names.
127
+ async function loadConfig(
128
+ cwd: string,
129
+ configPath: string | undefined,
130
+ disabled: boolean,
131
+ ): Promise<CliOptions> {
132
+ if (disabled) return {}
133
+ const explorer = cosmiconfig("punctilio")
134
+ const result = configPath ? await explorer.load(configPath) : await explorer.search(cwd)
135
+ if (!result || result.isEmpty) return {}
136
+ return result.config as CliOptions
137
+ }
138
+
139
+ function loadIgnore(cwd: string, ignorePath: string | undefined): Ignore {
140
+ const ig = ignore()
141
+ const effective = ignorePath ?? join(cwd, ".punctilioignore")
142
+ if (existsSync(effective)) ig.add(readFileSync(effective, "utf8"))
143
+ return ig
144
+ }
145
+
146
+ function isGlobPattern(pattern: string): boolean {
147
+ return /[*?[\]{}]/.test(pattern)
148
+ }
149
+
150
+ // Non-glob positionals pass through as literal paths so they error loudly
151
+ // if missing, matching the prior behavior of `punctilio file.md`.
152
+ async function discoverFiles(
153
+ patterns: string[],
154
+ cwd: string,
155
+ ig: Ignore,
156
+ ): Promise<string[]> {
157
+ const literals = patterns.filter((p) => !isGlobPattern(p))
158
+ const globs = patterns.filter(isGlobPattern)
159
+ const expanded = globs.length > 0
160
+ ? await glob(globs, { cwd, absolute: true, onlyFiles: true })
161
+ : []
162
+ const all = [...literals.map((p) => resolve(cwd, p)), ...expanded]
163
+ return all.filter((file) => {
164
+ const rel = relative(cwd, file)
165
+ return rel.startsWith("..") || !ig.ignores(rel)
166
+ })
167
+ }
168
+
169
+ interface CacheEntry {
170
+ contentHash: string
171
+ optionsHash: string
172
+ version: string
173
+ }
174
+
175
+ interface Cache {
176
+ files: Record<string, CacheEntry>
177
+ }
178
+
179
+ const DEFAULT_CACHE_LOCATION = join("node_modules", ".cache", "punctilio", "cache.json")
180
+
181
+ function hashString(s: string): string {
182
+ return createHash("sha256").update(s).digest("hex").slice(0, 16)
183
+ }
184
+
185
+ function hashOptions(opts: CliOptions): string {
186
+ return hashString(stableStringify(opts))
187
+ }
188
+
189
+ function loadCache(location: string, stderr: NodeJS.WritableStream): Cache {
190
+ if (!existsSync(location)) return { files: {} }
191
+ try {
192
+ const parsed = JSON.parse(readFileSync(location, "utf8")) as Cache
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 }
205
+ } catch {
206
+ stderr.write(`Warning: cache at ${location} could not be parsed; discarding.\n`)
207
+ return { files: {} }
208
+ }
209
+ }
210
+
211
+ function saveCache(location: string, cache: Cache): void {
212
+ mkdirSync(dirname(location), { recursive: true })
213
+ writeFileSync(location, JSON.stringify(cache))
214
+ }
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
+
226
+ function buildOptions(flags: ParsedFlags): CliOptions {
227
+ const opts: CliOptions = {}
228
+ if (flags.punctuationStyle) opts.punctuationStyle = flags.punctuationStyle
229
+ if (flags.dashStyle) opts.dashStyle = flags.dashStyle
230
+ if (flags.symbols === false) opts.symbols = false
231
+ if (flags.nbsp === false) opts.nbsp = false
232
+ if (flags.collapseSpaces === false) opts.collapseSpaces = false
233
+ if (flags.arrows === false) opts.includeArrows = false
234
+ if (flags.fractions) opts.fractions = true
235
+ if (flags.degrees) opts.degrees = true
236
+ if (flags.superscript) opts.superscript = true
237
+ if (flags.ligatures) opts.ligatures = true
238
+ if (flags.emphasisMarker) opts.emphasisMarker = flags.emphasisMarker
239
+ if (flags.strongMarker) opts.strongMarker = flags.strongMarker
240
+ if (flags.bulletMarker) opts.bulletMarker = flags.bulletMarker
241
+ if (flags.ruleMarker) opts.ruleMarker = flags.ruleMarker
242
+ if (flags.skipTag) opts.skipTags = flags.skipTag
243
+ if (flags.skipClass) opts.skipClasses = flags.skipClass
244
+ if (flags.fragment === false) opts.fragment = false
245
+ return opts
246
+ }
247
+
248
+ async function transformContent(input: string, type: FileType, opts: CliOptions): Promise<string> {
249
+ return type === "md" ? transformMarkdown(input, opts) : transformHtml(input, opts)
250
+ }
251
+
252
+ /**
253
+ * Preserves whether the original file ends with a trailing newline.
254
+ * The unified pipelines for Markdown and HTML both unconditionally append
255
+ * one, so without this adjustment every file lacking a trailing newline
256
+ * would always appear as needing a rewrite.
257
+ */
258
+ function matchTrailingNewline(original: string, formatted: string): string {
259
+ const originalHas = original.endsWith("\n")
260
+ const formattedHas = formatted.endsWith("\n")
261
+ if (originalHas === formattedHas) return formatted
262
+ if (originalHas) return formatted + "\n"
263
+ return formatted.replace(/\n$/, "")
264
+ }
265
+
266
+ async function readStdin(stdin: AsyncIterable<string | Buffer>): Promise<string> {
267
+ const chunks: Buffer[] = []
268
+ for await (const chunk of stdin) {
269
+ chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk)
270
+ }
271
+ return Buffer.concat(chunks).toString("utf8")
272
+ }
273
+
274
+ async function readPackageVersion(): Promise<string> {
275
+ const pkgPath = new URL("../package.json", import.meta.url)
276
+ /* istanbul ignore next -- the catch only fires when package.json is missing or unreadable */
277
+ try {
278
+ const raw = await readFile(pkgPath, "utf8")
279
+ return JSON.parse(raw).version ?? "unknown"
280
+ } catch {
281
+ return "unknown"
282
+ }
283
+ }
284
+
285
+ /**
286
+ * Runs the CLI with the given arguments and I/O streams.
287
+ *
288
+ * @returns Exit code: 0 if no changes (or all changes written),
289
+ * 1 if `--check` saw a file that would change, 2 for usage errors.
290
+ */
291
+ export async function runCli(args: string[], io: CliIO): Promise<number> {
292
+ const version = await readPackageVersion()
293
+ const program = buildProgram(version, io)
294
+
295
+ try {
296
+ program.parse(args, { from: "user" })
297
+ } catch (err) {
298
+ /* istanbul ignore if -- commander only throws CommanderError from parse */
299
+ if (!(err instanceof CommanderError)) throw err
300
+ if (err.code === "commander.helpDisplayed" || err.code === "commander.version") return 0
301
+ return 2
302
+ }
303
+
304
+ const flags = program.opts() as ParsedFlags
305
+ const positionals = program.args
306
+
307
+ try {
308
+ return await runValidated(flags, positionals, io, program, version)
309
+ } catch (err) {
310
+ if (err instanceof UsageError) {
311
+ io.stderr.write(`Error: ${err.message}\n`)
312
+ return 2
313
+ }
314
+ throw err
315
+ }
316
+ }
317
+
318
+ function readsStdin(positionals: string[], flags: ParsedFlags): boolean {
319
+ if (positionals.includes("-")) return true
320
+ return flags.stdinFilepath !== undefined && positionals.length === 0
321
+ }
322
+
323
+ async function runValidated(
324
+ flags: ParsedFlags,
325
+ positionals: string[],
326
+ io: CliIO,
327
+ program: Command,
328
+ version: string,
329
+ ): Promise<number> {
330
+ const cwd = process.cwd()
331
+ const config = await loadConfig(
332
+ cwd,
333
+ typeof flags.config === "string" ? flags.config : undefined,
334
+ flags.config === false,
335
+ )
336
+ const opts: CliOptions = { ...config, ...buildOptions(flags) }
337
+ const check = flags.check ?? false
338
+
339
+ if (readsStdin(positionals, flags)) {
340
+ const fileArgs = positionals.filter((p) => p !== "-")
341
+ if (fileArgs.length > 0) {
342
+ throw new UsageError("'-' cannot be combined with file arguments.")
343
+ }
344
+ const type: FileType | undefined =
345
+ flags.type ?? (flags.stdinFilepath !== undefined ? inferFileType(flags.stdinFilepath) : undefined)
346
+ if (type === undefined) {
347
+ throw new UsageError("Reading from stdin requires --type md|html or --stdin-filepath <path>.")
348
+ }
349
+ const input = await readStdin(io.stdin)
350
+ const output = matchTrailingNewline(input, await transformContent(input, type, opts))
351
+ if (check) return input === output ? 0 : 1
352
+ io.stdout.write(output)
353
+ return 0
354
+ }
355
+
356
+ if (positionals.length === 0) {
357
+ io.stderr.write(program.helpInformation())
358
+ return 2
359
+ }
360
+
361
+ const ig = loadIgnore(cwd, flags.ignorePath)
362
+ const files = await discoverFiles(positionals, cwd, ig)
363
+
364
+ const cacheLocation = flags.cache === false
365
+ ? undefined
366
+ : resolve(cwd, flags.cacheLocation ?? DEFAULT_CACHE_LOCATION)
367
+ const cache = cacheLocation ? loadCache(cacheLocation, io.stderr) : undefined
368
+ const optionsHash = cache ? hashOptions(opts) : ""
369
+
370
+ let anyChanged = false
371
+ for (const path of files) {
372
+ const type = inferFileType(path, flags.type)
373
+ const original = await readFile(path, "utf8")
374
+ const key = cache ? cacheKey(path, cwd) : ""
375
+ if (cache) {
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
383
+ }
384
+
385
+ const formatted = matchTrailingNewline(original, await transformContent(original, type, opts))
386
+ const unchanged = original === formatted
387
+ if (!unchanged) {
388
+ anyChanged = true
389
+ if (check) {
390
+ io.stderr.write(`Would reformat: ${path}\n`)
391
+ } else {
392
+ await writeFile(path, formatted)
393
+ io.stdout.write(`Reformatted: ${path}\n`)
394
+ }
395
+ }
396
+ if (cache && (unchanged || !check)) {
397
+ cache.files[key] = { contentHash: hashString(formatted), optionsHash, version }
398
+ }
399
+ }
400
+
401
+ if (cacheLocation && cache) saveCache(cacheLocation, cache)
402
+ return check && anyChanged ? 1 : 0
403
+ }
404
+
405
+ /* istanbul ignore next -- entry-point bootstrap; logic is covered via runCli */
406
+ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
407
+ runCli(process.argv.slice(2), {
408
+ stdin: process.stdin,
409
+ stdout: process.stdout,
410
+ stderr: process.stderr,
411
+ })
412
+ .then((code) => process.exit(code))
413
+ .catch((err: Error) => {
414
+ process.stderr.write(`Error: ${err.message}\n`)
415
+ process.exit(2)
416
+ })
417
+ }
package/src/dashes.ts CHANGED
@@ -3,8 +3,10 @@
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
- export type DashStyle = "american" | "british" | "none"
8
+ export const DASH_STYLES = ["american", "british", "none"] as const
9
+ export type DashStyle = (typeof DASH_STYLES)[number]
8
10
 
9
11
  export interface DashOptions {
10
12
  /** Boundary marker for HTML element boundaries. Default: "\uE000\uE001" */
@@ -68,7 +70,13 @@ export function enDashNumberRange(text: string, options: DashOptions = {}): stri
68
70
  text = text.replace(
69
71
  cachedRegExp(positiveRangePattern, "g"),
70
72
  (...args) => {
71
- 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)
72
80
  if (groups.following) return args[0] as string
73
81
  const startNum = groups.start.replace(cachedRegExp(chr, "g"), "")
74
82
  const endNum = groups.end.replace(cachedRegExp(chr, "g"), "")
@@ -95,7 +103,13 @@ export function enDashNumberRange(text: string, options: DashOptions = {}): stri
95
103
  "g"
96
104
  ),
97
105
  (...args) => {
98
- 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)
99
113
  if (groups.following) return args[0] as string
100
114
  return `${groups.start}${EN_DASH}${groups.neg ? MINUS : ""}${groups.end}${groups.suffix ?? ""}`
101
115
  }
@@ -120,7 +134,14 @@ export function enDashDateRange(text: string, options: DashOptions = {}): string
120
134
  return text.replace(
121
135
  cachedRegExp(`${wb}(?<startMonth>${monthPattern})${startYear}(?<preSep>${chr}?)(?<preSpace> ?)-(?<postSpace> ?)(?<postSep>${chr}?)(?<endMonth>${monthPattern})${endYear}${wbe}`, "g"),
122
136
  (...args) => {
123
- 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)
124
145
  const [pre, post] = dashStyle === "british" ? [" ", " "] : ["", ""]
125
146
  return `${groups.startMonth}${groups.startYear || ""}${groups.preSep}${pre}${EN_DASH}${post}${groups.postSep}${groups.endMonth}${groups.endYear || ""}`
126
147
  }
package/src/html.ts ADDED
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Convenience module for HTML-to-HTML typography transformations.
3
+ *
4
+ * Provides a single function that takes HTML input and returns
5
+ * typographically improved HTML output.
6
+ *
7
+ * Requires `unified`, `rehype-parse`, and `rehype-stringify` to be installed.
8
+ *
9
+ * @packageDocumentation
10
+ */
11
+
12
+ import { unified } from "unified"
13
+ import rehypeParse from "rehype-parse"
14
+ import rehypeStringify from "rehype-stringify"
15
+ import QuickLRU from "quick-lru"
16
+
17
+ import { rehypePunctilio, type RehypePunctilioOptions } from "./rehype.js"
18
+ import { stableStringify } from "./utils.js"
19
+
20
+ /**
21
+ * Options for the HTML transform pipeline.
22
+ */
23
+ export interface HtmlOptions extends RehypePunctilioOptions {
24
+ /**
25
+ * Whether to parse the input as an HTML fragment (no implicit `<html>`,
26
+ * `<head>`, or `<body>` wrapping). Set to `false` to parse a complete
27
+ * document. Default: `true`.
28
+ */
29
+ fragment?: boolean
30
+ }
31
+
32
+ function createProcessor(options: HtmlOptions) {
33
+ const { fragment, ...punctilioOptions } = options
34
+ return unified()
35
+ .use(rehypeParse, { fragment: fragment ?? true })
36
+ .use(rehypePunctilio, punctilioOptions)
37
+ .use(rehypeStringify)
38
+ }
39
+
40
+ const MAX_PROCESSOR_CACHE_SIZE = 8
41
+
42
+ const processorCache = new QuickLRU<string, ReturnType<typeof createProcessor>>({
43
+ maxSize: MAX_PROCESSOR_CACHE_SIZE,
44
+ })
45
+
46
+ /**
47
+ * Clears the processor cache. Exported for test isolation only.
48
+ * @internal
49
+ */
50
+ export function clearProcessorCache(): void {
51
+ processorCache.clear()
52
+ }
53
+
54
+ /**
55
+ * Transforms HTML text with typographic improvements.
56
+ *
57
+ * Parses the input as HTML, applies punctilio typography transformations,
58
+ * and serializes back to HTML. The unified processor pipeline is cached
59
+ * and reused across calls with identical options.
60
+ *
61
+ * @example
62
+ * ```ts
63
+ * import { transformHtml } from 'punctilio/html'
64
+ *
65
+ * await transformHtml('<p>"Hello" -- world</p>')
66
+ * // → '<p>“Hello”—world</p>'
67
+ * ```
68
+ */
69
+ export async function transformHtml(
70
+ input: string,
71
+ options: HtmlOptions = {}
72
+ ): Promise<string> {
73
+ // Function-valued options aren't JSON-serializable, so the cache key
74
+ // can't distinguish them. Bypass the cache to avoid returning a
75
+ // processor wired to a different shouldSkipText.
76
+ if (typeof options.shouldSkipText === "function") {
77
+ const result = await createProcessor(options).process(input)
78
+ return String(result)
79
+ }
80
+
81
+ const optionsKey = stableStringify(options)
82
+
83
+ let processor = processorCache.get(optionsKey)
84
+ if (!processor) {
85
+ processor = createProcessor(options)
86
+ processorCache.set(optionsKey, processor)
87
+ }
88
+
89
+ const result = await processor.process(input)
90
+ return String(result)
91
+ }
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")