punctilio 3.13.1 → 4.0.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.
package/src/cli.ts CHANGED
@@ -5,17 +5,17 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
5
5
  import { readFile, writeFile } from "node:fs/promises"
6
6
  import { dirname, extname, isAbsolute, join, relative, resolve } from "node:path"
7
7
  import { fileURLToPath } from "node:url"
8
+ import { parseArgs } from "node:util"
8
9
 
9
- import { Command, CommanderError, Option } from "commander"
10
10
  import { cosmiconfig } from "cosmiconfig"
11
11
  import ignore, { type Ignore } from "ignore"
12
12
  import { glob } from "tinyglobby"
13
13
 
14
- import { type MarkdownOptions, transformMarkdown } from "./markdown.js"
15
- import { type HtmlOptions, transformHtml } from "./html.js"
14
+ import { MARKDOWN_ONLY_OPTION_KEYS, MARKDOWN_OPTION_KEYS, type MarkdownOptions, transformMarkdown } from "./markdown.js"
15
+ import { HTML_ONLY_OPTION_KEYS, HTML_OPTION_KEYS, type HtmlOptions, transformHtml } from "./html.js"
16
16
  import { PUNCTUATION_STYLES, type PunctuationStyle } from "./quotes.js"
17
17
  import { DASH_STYLES, type DashStyle } from "./dashes.js"
18
- import { stableStringify } from "./utils.js"
18
+ import { assertKnownOptionKeys, omitKeys, stableStringify } from "./utils.js"
19
19
 
20
20
  type CliOptions = MarkdownOptions & HtmlOptions
21
21
  type FileType = "md" | "html"
@@ -36,8 +36,9 @@ const RULE_MARKERS = ["-", "*", "_"] as const
36
36
  const MARKDOWN_EXTENSIONS = new Set([".md", ".markdown"])
37
37
  const HTML_EXTENSIONS = new Set([".html", ".htm"])
38
38
 
39
- // Fields match commander's camelCased flag names; .choices() guarantees literals at runtime.
39
+ // camelCased flag values; buildFlags validates choice literals at runtime.
40
40
  interface ParsedFlags {
41
+ write?: boolean
41
42
  check?: boolean
42
43
  cache?: boolean
43
44
  cacheLocation?: string
@@ -64,42 +65,158 @@ interface ParsedFlags {
64
65
  fragment?: boolean
65
66
  }
66
67
 
67
- function buildProgram(version: string, io: CliIO): Command {
68
- return new Command()
69
- .name("punctilio")
70
- .description("Apply typographic improvements to Markdown and HTML files.")
71
- .version(version, "-V, --version", "show version")
72
- .argument("[files...]", "files to format (pass '-' to read from stdin)")
73
- .option("--check", "exit 1 if any file would change; write nothing")
74
- .option("--config <path>", "path to a punctilio config file (otherwise auto-discovered)")
75
- .option("--no-config", "ignore any auto-discovered config file")
76
- .option("--ignore-path <path>", "path to an ignore file (otherwise .punctilioignore in cwd)")
77
- .option("--no-cache", "disable the incremental cache (default location node_modules/.cache/punctilio/cache.json)")
78
- .option("--cache-location <path>", "override the cache file location")
79
- .option("--stdin-filepath <path>", "treat stdin as if it were this filename (infers type from extension)")
80
- .addOption(new Option("--type <type>", "force file type (otherwise inferred from extension)").choices(FILE_TYPES))
81
- .addOption(new Option("--punctuation-style <style>", "quote and punctuation style").choices(PUNCTUATION_STYLES))
82
- .addOption(new Option("--dash-style <style>", "dash style").choices(DASH_STYLES))
83
- .option("--no-symbols", "disable symbol transforms (ellipsis, ×, ©, etc.)")
84
- .option("--no-nbsp", "disable non-breaking space insertion")
85
- .option("--no-collapse-spaces", "preserve runs of consecutive spaces")
86
- .option("--no-arrows", "disable -> → arrow transforms")
87
- .option("--fractions", "enable 1/2 → ½")
88
- .option("--degrees", "enable 20 C → 20 °C")
89
- .option("--superscript", "enable 1st → 1ˢᵗ")
90
- .option("--ligatures", "enable !? → ⁉")
91
- .addOption(new Option("--emphasis-marker <char>", "markdown emphasis character").choices(EMPHASIS_MARKERS))
92
- .addOption(new Option("--strong-marker <char>", "markdown strong emphasis character").choices(EMPHASIS_MARKERS))
93
- .addOption(new Option("--bullet-marker <char>", "markdown list bullet character").choices(BULLET_MARKERS))
94
- .addOption(new Option("--rule-marker <char>", "markdown thematic break character").choices(RULE_MARKERS))
95
- .option("--skip-tag <tag...>", "HTML tags whose contents are left alone (repeatable)")
96
- .option("--skip-class <class...>", "HTML class names whose elements are skipped (repeatable)")
97
- .option("--no-fragment", "parse input as a full HTML document")
98
- .exitOverride()
99
- .configureOutput({
100
- writeOut: (str) => { io.stdout.write(str) },
101
- writeErr: (str) => { io.stderr.write(str) },
68
+ const PARSE_ARGS_OPTIONS = {
69
+ "bullet-marker": { type: "string" },
70
+ "cache-location": { type: "string" },
71
+ check: { type: "boolean" },
72
+ config: { type: "string" },
73
+ "dash-style": { type: "string" },
74
+ degrees: { type: "boolean" },
75
+ "emphasis-marker": { type: "string" },
76
+ fractions: { type: "boolean" },
77
+ help: { short: "h", type: "boolean" },
78
+ "ignore-path": { type: "string" },
79
+ ligatures: { type: "boolean" },
80
+ nbsp: { type: "boolean" },
81
+ "no-arrows": { type: "boolean" },
82
+ "no-cache": { type: "boolean" },
83
+ "no-collapse-spaces": { type: "boolean" },
84
+ "no-config": { type: "boolean" },
85
+ "no-fragment": { type: "boolean" },
86
+ "no-nbsp": { type: "boolean" },
87
+ "no-symbols": { type: "boolean" },
88
+ "punctuation-style": { type: "string" },
89
+ "rule-marker": { type: "string" },
90
+ "skip-class": { multiple: true, type: "string" },
91
+ "skip-tag": { multiple: true, type: "string" },
92
+ "stdin-filepath": { type: "string" },
93
+ "strong-marker": { type: "string" },
94
+ superscript: { type: "boolean" },
95
+ type: { type: "string" },
96
+ version: { short: "V", type: "boolean" },
97
+ write: { type: "boolean" },
98
+ } as const
99
+
100
+ const HELP_TEXT = `Usage: punctilio [options] [files...]
101
+
102
+ Apply typographic improvements to Markdown and HTML files.
103
+
104
+ Arguments:
105
+ files files to format (pass '-' to read from stdin)
106
+
107
+ Options:
108
+ -V, --version show version
109
+ --write rewrite files in place (default prints formatted output to stdout)
110
+ --check exit 1 if any file would change; write nothing
111
+ --config <path> path to a punctilio config file (otherwise auto-discovered)
112
+ --no-config ignore any auto-discovered config file
113
+ --ignore-path <path> path to an ignore file (otherwise .punctilioignore in cwd)
114
+ --no-cache disable the incremental cache (default location node_modules/.cache/punctilio/cache.json)
115
+ --cache-location <path> override the cache file location
116
+ --stdin-filepath <path> treat stdin as if it were this filename (infers type from extension)
117
+ --type <type> force file type (otherwise inferred from extension) (choices: ${FILE_TYPES.join(", ")})
118
+ --punctuation-style <style> quote and punctuation style (choices: ${PUNCTUATION_STYLES.join(", ")})
119
+ --dash-style <style> dash style (choices: ${DASH_STYLES.join(", ")})
120
+ --no-symbols disable symbol transforms (ellipsis, ×, ©, etc.)
121
+ --nbsp enable non-breaking space insertion (default: on for HTML, off for Markdown)
122
+ --no-nbsp disable non-breaking space insertion
123
+ --no-collapse-spaces preserve runs of consecutive spaces
124
+ --no-arrows disable -> → arrow transforms
125
+ --fractions enable 1/2 → ½
126
+ --degrees enable 20 C → 20 °C
127
+ --superscript enable 1st → 1ˢᵗ
128
+ --ligatures enable !? → ⁉
129
+ --emphasis-marker <char> markdown emphasis character (choices: ${EMPHASIS_MARKERS.join(", ")})
130
+ --strong-marker <char> markdown strong emphasis character (choices: ${EMPHASIS_MARKERS.join(", ")})
131
+ --bullet-marker <char> markdown list bullet character (choices: ${BULLET_MARKERS.join(", ")})
132
+ --rule-marker <char> markdown thematic break character (choices: ${RULE_MARKERS.join(", ")})
133
+ --skip-tag <tag> HTML tag whose contents are left alone (repeatable)
134
+ --skip-class <class> HTML class name whose elements are skipped (repeatable)
135
+ --no-fragment parse input as a full HTML document
136
+ -h, --help display help for command
137
+ `
138
+
139
+ function parseRawArgs(args: string[]) {
140
+ try {
141
+ return parseArgs({
142
+ args,
143
+ allowPositionals: true,
144
+ options: PARSE_ARGS_OPTIONS,
145
+ strict: true,
146
+ tokens: true,
102
147
  })
148
+ } catch (err) {
149
+ // parseArgs throws on unknown options and missing option values.
150
+ throw new UsageError((err as Error).message)
151
+ }
152
+ }
153
+
154
+ type ParsedValues = ReturnType<typeof parseRawArgs>["values"]
155
+ type ParsedTokens = ReturnType<typeof parseRawArgs>["tokens"]
156
+
157
+ function parseChoice<T extends string>(
158
+ flag: string,
159
+ value: string | undefined,
160
+ choices: readonly T[],
161
+ ): T | undefined {
162
+ if (value === undefined) return undefined
163
+ if (!(choices as readonly string[]).includes(value)) {
164
+ throw new UsageError(
165
+ `option '${flag}' argument '${value}' is invalid. Allowed choices are ${choices.join(", ")}.`,
166
+ )
167
+ }
168
+ return value as T
169
+ }
170
+
171
+ /** Maps a `--no-x` boolean to the library's `x: false`, or undefined when unset. */
172
+ function negated(noFlag: boolean | undefined): false | undefined {
173
+ return noFlag ? false : undefined
174
+ }
175
+
176
+ /** Returns the name of whichever of `names` appears last on the command line. */
177
+ function lastFlagToken(tokens: ParsedTokens, names: readonly string[]): string | undefined {
178
+ let lastName: string | undefined
179
+ for (const token of tokens) {
180
+ if (token.kind === "option" && names.includes(token.name)) lastName = token.name
181
+ }
182
+ return lastName
183
+ }
184
+
185
+ function buildFlags(values: ParsedValues, tokens: ParsedTokens): ParsedFlags {
186
+ const flags: ParsedFlags = {
187
+ write: values.write,
188
+ check: values.check,
189
+ cache: negated(values["no-cache"]),
190
+ cacheLocation: values["cache-location"],
191
+ ignorePath: values["ignore-path"],
192
+ stdinFilepath: values["stdin-filepath"],
193
+ type: parseChoice("--type", values.type, FILE_TYPES),
194
+ punctuationStyle: parseChoice("--punctuation-style", values["punctuation-style"], PUNCTUATION_STYLES),
195
+ dashStyle: parseChoice("--dash-style", values["dash-style"], DASH_STYLES),
196
+ symbols: negated(values["no-symbols"]),
197
+ collapseSpaces: negated(values["no-collapse-spaces"]),
198
+ arrows: negated(values["no-arrows"]),
199
+ fractions: values.fractions,
200
+ degrees: values.degrees,
201
+ superscript: values.superscript,
202
+ ligatures: values.ligatures,
203
+ emphasisMarker: parseChoice("--emphasis-marker", values["emphasis-marker"], EMPHASIS_MARKERS),
204
+ strongMarker: parseChoice("--strong-marker", values["strong-marker"], EMPHASIS_MARKERS),
205
+ bulletMarker: parseChoice("--bullet-marker", values["bullet-marker"], BULLET_MARKERS),
206
+ ruleMarker: parseChoice("--rule-marker", values["rule-marker"], RULE_MARKERS),
207
+ skipTag: values["skip-tag"],
208
+ skipClass: values["skip-class"],
209
+ fragment: negated(values["no-fragment"]),
210
+ }
211
+ // nbsp is tri-state: --nbsp / --no-nbsp / unset stays undefined so the
212
+ // per-sink default applies (on for HTML, off for Markdown). When both
213
+ // appear, the last one on the command line wins.
214
+ const nbspToken = lastFlagToken(tokens, ["nbsp", "no-nbsp"])
215
+ if (nbspToken !== undefined) flags.nbsp = nbspToken === "nbsp"
216
+ // config is likewise last-one-wins between --config <path> and --no-config.
217
+ const configToken = lastFlagToken(tokens, ["config", "no-config"])
218
+ if (configToken !== undefined) flags.config = configToken === "config" ? values.config : false
219
+ return flags
103
220
  }
104
221
 
105
222
  function inferFileType(path: string, override?: FileType): FileType {
@@ -112,6 +229,12 @@ function inferFileType(path: string, override?: FileType): FileType {
112
229
  )
113
230
  }
114
231
 
232
+ // A config file may legitimately mix markdown-only and HTML-only keys for use
233
+ // across file types; transformContent strips the keys the sink doesn't accept.
234
+ const CLI_CONFIG_KEYS: readonly string[] = [
235
+ ...new Set([...MARKDOWN_OPTION_KEYS, ...HTML_OPTION_KEYS]),
236
+ ]
237
+
115
238
  // Config keys must match library option names (punctuationStyle, skipTags, …),
116
239
  // not the kebab-cased CLI flag names.
117
240
  async function loadConfig(
@@ -123,6 +246,11 @@ async function loadConfig(
123
246
  const explorer = cosmiconfig("punctilio")
124
247
  const result = configPath ? await explorer.load(configPath) : await explorer.search(cwd)
125
248
  if (!result || result.isEmpty) return {}
249
+ try {
250
+ assertKnownOptionKeys(result.config as object, CLI_CONFIG_KEYS, `config file ${result.filepath}`)
251
+ } catch (err) {
252
+ throw new UsageError((err as Error).message)
253
+ }
126
254
  return result.config as CliOptions
127
255
  }
128
256
 
@@ -212,7 +340,7 @@ function buildOptions(flags: ParsedFlags): CliOptions {
212
340
  if (flags.punctuationStyle) opts.punctuationStyle = flags.punctuationStyle
213
341
  if (flags.dashStyle) opts.dashStyle = flags.dashStyle
214
342
  if (flags.symbols === false) opts.symbols = false
215
- if (flags.nbsp === false) opts.nbsp = false
343
+ if (flags.nbsp !== undefined) opts.nbsp = flags.nbsp
216
344
  if (flags.collapseSpaces === false) opts.collapseSpaces = false
217
345
  if (flags.arrows === false) opts.includeArrows = false
218
346
  if (flags.fractions) opts.fractions = true
@@ -229,8 +357,13 @@ function buildOptions(flags: ParsedFlags): CliOptions {
229
357
  return opts
230
358
  }
231
359
 
360
+ // The merged options may carry keys for the other sink (e.g. emphasisMarker
361
+ // alongside skipTags from one shared config); strip the inapplicable ones so
362
+ // each sink's strict key validation only sees keys it understands.
232
363
  async function transformContent(input: string, type: FileType, opts: CliOptions): Promise<string> {
233
- return type === "md" ? transformMarkdown(input, opts) : transformHtml(input, opts)
364
+ return type === "md"
365
+ ? transformMarkdown(input, omitKeys(opts, HTML_ONLY_OPTION_KEYS) as MarkdownOptions)
366
+ : transformHtml(input, omitKeys(opts, MARKDOWN_ONLY_OPTION_KEYS) as HtmlOptions)
234
367
  }
235
368
 
236
369
  // Unified pipelines unconditionally append a trailing newline; undo if original lacked one.
@@ -264,22 +397,18 @@ async function readPackageVersion(): Promise<string> {
264
397
  /** Returns exit code: 0 success, 1 `--check` saw changes, 2 usage error. */
265
398
  export async function runCli(args: string[], io: CliIO): Promise<number> {
266
399
  const version = await readPackageVersion()
267
- const program = buildProgram(version, io)
268
-
269
- try {
270
- program.parse(args, { from: "user" })
271
- } catch (err) {
272
- /* istanbul ignore if -- commander only throws CommanderError from parse */
273
- if (!(err instanceof CommanderError)) throw err
274
- if (err.code === "commander.helpDisplayed" || err.code === "commander.version") return 0
275
- return 2
276
- }
277
-
278
- const flags = program.opts() as ParsedFlags
279
- const positionals = program.args
280
400
 
281
401
  try {
282
- return await runValidated(flags, positionals, io, program, version)
402
+ const { positionals, tokens, values } = parseRawArgs(args)
403
+ if (values.help) {
404
+ io.stdout.write(HELP_TEXT)
405
+ return 0
406
+ }
407
+ if (values.version) {
408
+ io.stdout.write(`${version}\n`)
409
+ return 0
410
+ }
411
+ return await runValidated(buildFlags(values, tokens), positionals, io, version)
283
412
  } catch (err) {
284
413
  if (err instanceof UsageError) {
285
414
  io.stderr.write(`Error: ${err.message}\n`)
@@ -298,7 +427,6 @@ async function runValidated(
298
427
  flags: ParsedFlags,
299
428
  positionals: string[],
300
429
  io: CliIO,
301
- program: Command,
302
430
  version: string,
303
431
  ): Promise<number> {
304
432
  const cwd = process.cwd()
@@ -309,8 +437,15 @@ async function runValidated(
309
437
  )
310
438
  const opts: CliOptions = { ...config, ...buildOptions(flags) }
311
439
  const check = flags.check ?? false
440
+ const write = flags.write ?? false
441
+ if (check && write) {
442
+ throw new UsageError("--write and --check are mutually exclusive.")
443
+ }
312
444
 
313
445
  if (readsStdin(positionals, flags)) {
446
+ if (write) {
447
+ throw new UsageError("--write cannot be used when reading from stdin.")
448
+ }
314
449
  const fileArgs = positionals.filter((p) => p !== "-")
315
450
  if (fileArgs.length > 0) {
316
451
  throw new UsageError("'-' cannot be combined with file arguments.")
@@ -328,14 +463,16 @@ async function runValidated(
328
463
  }
329
464
 
330
465
  if (positionals.length === 0) {
331
- io.stderr.write(program.helpInformation())
466
+ io.stderr.write(HELP_TEXT)
332
467
  return 2
333
468
  }
334
469
 
335
470
  const ig = loadIgnore(cwd, flags.ignorePath)
336
471
  const files = await discoverFiles(positionals, cwd, ig)
337
472
 
338
- const cacheLocation = flags.cache === false
473
+ // In stdout mode every file's content must be produced, so a cache hit
474
+ // could never skip work; only --write and --check use the cache.
475
+ const cacheLocation = flags.cache === false || (!write && !check)
339
476
  ? undefined
340
477
  : resolve(cwd, flags.cacheLocation ?? DEFAULT_CACHE_LOCATION)
341
478
  const cache = cacheLocation ? loadCache(cacheLocation, io.stderr) : undefined
@@ -357,6 +494,10 @@ async function runValidated(
357
494
  }
358
495
 
359
496
  const formatted = matchTrailingNewline(original, await transformContent(original, type, opts))
497
+ if (!write && !check) {
498
+ io.stdout.write(formatted)
499
+ continue
500
+ }
360
501
  const unchanged = original === formatted
361
502
  if (!unchanged) {
362
503
  anyChanged = true
package/src/html.ts CHANGED
@@ -3,8 +3,8 @@ import rehypeParse from "rehype-parse"
3
3
  import rehypeStringify from "rehype-stringify"
4
4
  import QuickLRU from "quick-lru"
5
5
 
6
- import { rehypePunctilio, type RehypePunctilioOptions } from "./rehype.js"
7
- import { stableStringify } from "./utils.js"
6
+ import { REHYPE_ONLY_OPTION_KEYS, REHYPE_OPTION_KEYS, rehypePunctilio, type RehypePunctilioOptions } from "./rehype.js"
7
+ import { assertKnownOptionKeys, stableStringify } from "./utils.js"
8
8
 
9
9
  export interface HtmlOptions extends RehypePunctilioOptions {
10
10
  /**
@@ -15,6 +15,13 @@ export interface HtmlOptions extends RehypePunctilioOptions {
15
15
  fragment?: boolean
16
16
  }
17
17
 
18
+ /** Option keys handled by the HTML pipeline (parser plus rehype plugin)
19
+ * rather than `transform()`. */
20
+ export const HTML_ONLY_OPTION_KEYS: readonly string[] = ["fragment", ...REHYPE_ONLY_OPTION_KEYS]
21
+
22
+ /** Runtime list of valid `transformHtml` option keys. */
23
+ export const HTML_OPTION_KEYS: readonly string[] = [...REHYPE_OPTION_KEYS, "fragment"]
24
+
18
25
  function createProcessor(options: HtmlOptions) {
19
26
  const { fragment, ...punctilioOptions } = options
20
27
  return unified()
@@ -38,6 +45,8 @@ export async function transformHtml(
38
45
  input: string,
39
46
  options: HtmlOptions = {}
40
47
  ): Promise<string> {
48
+ assertKnownOptionKeys(options, HTML_OPTION_KEYS, "transformHtml")
49
+
41
50
  // Function-valued options aren't JSON-serializable, so the cache key
42
51
  // can't distinguish them. Bypass the cache to avoid returning a
43
52
  // processor wired to a different shouldSkipText.
package/src/index.ts CHANGED
@@ -139,9 +139,11 @@ export interface TransformOptions {
139
139
  /**
140
140
  * Whether to verify that the transformation is idempotent (running twice
141
141
  * produces the same result). When enabled, throws an error if the second
142
- * pass produces a different result than the first.
142
+ * pass produces a different result than the first. The check doubles the
143
+ * cost of every transform and only detects punctilio's own bugs, so it is
144
+ * off by default; enable it in test suites or when debugging.
143
145
  *
144
- * Default: true
146
+ * Default: false
145
147
  */
146
148
  checkIdempotency?: boolean
147
149
 
@@ -151,7 +153,7 @@ import { niceQuotes } from "./quotes.js"
151
153
  import { hyphenReplace } from "./dashes.js"
152
154
  import { collapseSpaces as collapseSpacesTransform, degrees as degreesTransform, fractions as fractionsTransform, punctuationLigatures as ligaturesTransform, primeMarks, superscriptOrdinal as superscriptTransform, symbolTransform } from "./symbols.js"
153
155
  import { nbspTransform as nbspTransformFn } from "./nbsp.js"
154
- import { assertSeparatorCountPreserved, filterUndefined, formatErrorString } from "./utils.js"
156
+ import { assertKnownOptionKeys, assertSeparatorCountPreserved, filterUndefined, formatErrorString } from "./utils.js"
155
157
  import { DEFAULT_SEPARATOR, ISSUES_URL, UNICODE_SYMBOLS } from "./constants.js"
156
158
 
157
159
  export {
@@ -181,12 +183,21 @@ const defaultOpts: Required<Omit<TransformOptions, "separator">> = {
181
183
  ligatures: false,
182
184
  nbsp: true,
183
185
  collapseSpaces: true,
184
- checkIdempotency: true,
186
+ checkIdempotency: false,
185
187
  punctuationStyle: "american",
186
188
  dashStyle: "american",
187
189
  }
188
190
 
191
+ /** Runtime list of valid `transform()` option keys, derived from the option
192
+ * defaults so it cannot drift from {@link TransformOptions}. */
193
+ export const TRANSFORM_OPTION_KEYS: readonly string[] = [
194
+ ...Object.keys(defaultOpts),
195
+ "separator",
196
+ ]
197
+
189
198
  export function transform(text: string, options: TransformOptions = {}): string {
199
+ assertKnownOptionKeys(options, TRANSFORM_OPTION_KEYS, "transform")
200
+
190
201
  const separator = options.separator ?? DEFAULT_SEPARATOR
191
202
 
192
203
  if (separator.length === 0) {
package/src/markdown.ts CHANGED
@@ -4,9 +4,15 @@ import remarkGfm from "remark-gfm"
4
4
  import remarkStringify from "remark-stringify"
5
5
  import QuickLRU from "quick-lru"
6
6
 
7
+ import { TRANSFORM_OPTION_KEYS } from "./index.js"
7
8
  import { remarkPunctilio, type RemarkPunctilioOptions } from "./remark.js"
8
- import { stableStringify } from "./utils.js"
9
+ import { assertKnownOptionKeys, stableStringify } from "./utils.js"
9
10
 
11
+ /**
12
+ * Options for `transformMarkdown`. Inherits the Markdown-sink default of
13
+ * `nbsp: false` from {@link RemarkPunctilioOptions}; pass `nbsp: true` to
14
+ * opt in to non-breaking space insertion.
15
+ */
10
16
  export interface MarkdownOptions extends RemarkPunctilioOptions {
11
17
  /**
12
18
  * Character for emphasis (`*text*` vs `_text_`) and strong (`**text**` vs `__text__`).
@@ -27,6 +33,12 @@ export interface MarkdownOptions extends RemarkPunctilioOptions {
27
33
  ruleMarker?: "-" | "*" | "_"
28
34
  }
29
35
 
36
+ /** Option keys handled by `transformMarkdown` itself rather than `transform()`. */
37
+ export const MARKDOWN_ONLY_OPTION_KEYS: readonly string[] = ["emphasisMarker", "strongMarker", "bulletMarker", "ruleMarker"]
38
+
39
+ /** Runtime list of valid `transformMarkdown` option keys. */
40
+ export const MARKDOWN_OPTION_KEYS: readonly string[] = [...TRANSFORM_OPTION_KEYS, ...MARKDOWN_ONLY_OPTION_KEYS]
41
+
30
42
  function createProcessor(options: MarkdownOptions) {
31
43
  const {
32
44
  emphasisMarker,
@@ -70,6 +82,8 @@ export async function transformMarkdown(
70
82
  input: string,
71
83
  options: MarkdownOptions = {}
72
84
  ): Promise<string> {
85
+ assertKnownOptionKeys(options, MARKDOWN_OPTION_KEYS, "transformMarkdown")
86
+
73
87
  const optionsKey = stableStringify(options)
74
88
 
75
89
  let processor = processorCache.get(optionsKey)
@@ -5,14 +5,26 @@ import type { Parser, Plugin } from "prettier"
5
5
  import type { Root } from "mdast"
6
6
  import { cosmiconfig } from "cosmiconfig"
7
7
 
8
+ import { HTML_ONLY_OPTION_KEYS } from "./html.js"
9
+ import { MARKDOWN_ONLY_OPTION_KEYS } from "./markdown.js"
8
10
  import { remarkPunctilio, type RemarkPunctilioOptions } from "./remark.js"
11
+ import { omitKeys } from "./utils.js"
9
12
 
10
13
  const upstreamMarkdown = prettierMarkdown.parsers.markdown as Parser<Root>
11
14
  const explorer = cosmiconfig("punctilio")
12
15
 
16
+ // A config file may be shared with the CLI, so it can carry markdown-only
17
+ // keys (prettier controls stringification itself) and HTML-only keys. Strip
18
+ // those; unknown keys still fail loudly inside remarkPunctilio.
19
+ const INAPPLICABLE_CONFIG_KEYS: readonly string[] = [
20
+ ...MARKDOWN_ONLY_OPTION_KEYS,
21
+ ...HTML_ONLY_OPTION_KEYS,
22
+ ]
23
+
13
24
  async function loadPunctilioConfig(searchFrom: string): Promise<RemarkPunctilioOptions> {
14
25
  const result = await explorer.search(searchFrom)
15
- return !result || result.isEmpty ? {} : (result.config as RemarkPunctilioOptions)
26
+ if (!result || result.isEmpty) return {}
27
+ return omitKeys(result.config as RemarkPunctilioOptions, INAPPLICABLE_CONFIG_KEYS)
16
28
  }
17
29
 
18
30
  async function runRemarkPunctilio(opts: RemarkPunctilioOptions, ast: Root): Promise<void> {
package/src/rehype.ts CHANGED
@@ -3,9 +3,9 @@ import type { Transformer } from "unified"
3
3
 
4
4
  import { SKIP, visitParents } from "unist-util-visit-parents"
5
5
 
6
- import { transform, type TransformOptions } from "./index.js"
6
+ import { transform, TRANSFORM_OPTION_KEYS, type TransformOptions } from "./index.js"
7
7
  import { DEFAULT_SEPARATOR, MAX_RECURSION_DEPTH } from "./constants.js"
8
- import { formatErrorString, transformTextNodes } from "./utils.js"
8
+ import { assertKnownOptionKeys, formatErrorString, transformTextNodes } from "./utils.js"
9
9
 
10
10
  type ElementPredicate = (node: Element) => boolean
11
11
 
@@ -50,6 +50,12 @@ export interface RehypePunctilioOptions
50
50
 
51
51
  const DEFAULT_SKIP_TAGS = ["code", "pre", "script", "style", "kbd", "var", "samp", "template", "math", "svg"]
52
52
 
53
+ /** Option keys handled by `rehypePunctilio` itself rather than `transform()`. */
54
+ export const REHYPE_ONLY_OPTION_KEYS: readonly string[] = ["skipTags", "skipClasses", "shouldSkipText"]
55
+
56
+ /** Runtime list of valid `rehypePunctilio` option keys. */
57
+ export const REHYPE_OPTION_KEYS: readonly string[] = [...TRANSFORM_OPTION_KEYS, ...REHYPE_ONLY_OPTION_KEYS]
58
+
53
59
  export function flattenTextNodes(
54
60
  node: Element | ElementContent,
55
61
  shouldSkip: ElementPredicate,
@@ -266,8 +272,18 @@ const TRANSFORMABLE_ELEMENTS = new Set([
266
272
  "ruby",
267
273
  "rt",
268
274
  "rp",
275
+ "title",
276
+ "button",
277
+ "option",
278
+ "output",
269
279
  ])
270
280
 
281
+ // A tag name containing "-" is a custom element per the HTML custom-element
282
+ // naming rule; assume those hold transformable prose like built-in elements.
283
+ function isTransformableElement(tagName: string): boolean {
284
+ return TRANSFORMABLE_ELEMENTS.has(tagName) || tagName.includes("-")
285
+ }
286
+
271
287
  function hasTextDescendant(
272
288
  node: Element | ElementContent,
273
289
  shouldSkip: ElementPredicate,
@@ -319,7 +335,7 @@ export function collectTransformableElements(
319
335
  hasTextDescendant(node, shouldSkip)
320
336
 
321
337
  if (
322
- TRANSFORMABLE_ELEMENTS.has(node.tagName) &&
338
+ isTransformableElement(node.tagName) &&
323
339
  (hasDirectText || hasTextDescendants)
324
340
  ) {
325
341
  results.push(node)
@@ -354,6 +370,8 @@ function markDescendants(node: Element, set: Set<Element>, depth: number = 0): v
354
370
  export function rehypePunctilio(
355
371
  options: RehypePunctilioOptions = {}
356
372
  ): Transformer<Root, Root> {
373
+ assertKnownOptionKeys(options, REHYPE_OPTION_KEYS, "rehypePunctilio")
374
+
357
375
  const {
358
376
  skipTags = DEFAULT_SKIP_TAGS,
359
377
  skipClasses = [],
@@ -377,11 +395,7 @@ export function rehypePunctilio(
377
395
  const shouldSkip = (node: Element): boolean =>
378
396
  skipTagSet.has(node.tagName) || hasSkipClass(node)
379
397
 
380
- // Default idempotency check to false in plugin context — the separator
381
- // count check already guards against corruption, and the double-pass
382
- // penalty compounds across every block-level element.
383
398
  const pluginOptions = {
384
- checkIdempotency: false,
385
399
  ...transformOptions,
386
400
  separator,
387
401
  }
package/src/remark.ts CHANGED
@@ -3,10 +3,15 @@ import type { Transformer } from "unified"
3
3
 
4
4
  import { visitParents } from "unist-util-visit-parents"
5
5
 
6
- import { transform, type TransformOptions } from "./index.js"
6
+ import { transform, TRANSFORM_OPTION_KEYS, type TransformOptions } from "./index.js"
7
7
  import { DEFAULT_SEPARATOR, MAX_RECURSION_DEPTH } from "./constants.js"
8
- import { transformTextNodes } from "./utils.js"
8
+ import { assertKnownOptionKeys, transformTextNodes } from "./utils.js"
9
9
 
10
+ /**
11
+ * Same options as `transform()`, except `nbsp` defaults to `false`:
12
+ * invisible U+00A0 characters written into Markdown source files break
13
+ * grep/Ctrl+F matching. Pass `nbsp: true` explicitly to opt in.
14
+ */
10
15
  export type RemarkPunctilioOptions = TransformOptions
11
16
 
12
17
  const PHRASING_CONTAINERS = new Set(["paragraph", "heading", "tableCell"])
@@ -42,14 +47,16 @@ function flattenTextNodes(
42
47
  export function remarkPunctilio(
43
48
  options: RemarkPunctilioOptions = {}
44
49
  ): Transformer<Root, Root> {
50
+ assertKnownOptionKeys(options, TRANSFORM_OPTION_KEYS, "remarkPunctilio")
51
+
45
52
  const separator = options.separator ?? DEFAULT_SEPARATOR
46
53
 
47
- // Default idempotency check to false in plugin context — the separator
48
- // count check already guards against corruption, and the double-pass
49
- // penalty compounds across every block-level element.
54
+ // Markdown is a source format, so default `nbsp` to false (`?? false`
55
+ // rather than spread defaults so an explicit `nbsp: undefined` also gets
56
+ // the Markdown default instead of falling through to transform's).
50
57
  const pluginOptions = {
51
- checkIdempotency: false,
52
58
  ...options,
59
+ nbsp: options.nbsp ?? false,
53
60
  separator,
54
61
  }
55
62
 
package/src/symbols.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { cachedRegExp, DEFAULT_SEPARATOR, getEscapedSeparator, LATIN_LETTERS, SPACE_CHARS, UNICODE_SYMBOLS, wordBoundaryEnd } from "./constants.js"
2
- import { namedGroups } from "./utils.js"
2
+ import { namedGroups, replaceCallbackContext } from "./utils.js"
3
3
 
4
4
  export interface SymbolOptions {
5
5
  /** Boundary marker for HTML element boundaries. Default: "\uE000\uE001" */
@@ -337,8 +337,7 @@ function balancedPrimeReplacer(primeChar: string, escapedSeparator: string) {
337
337
  // Inside balanced quotes: check if this is feet-inches notation
338
338
  // (e.g., 5′10" where ″ is inches, not a closing quote)
339
339
  if (feetInchesContext) {
340
- const matchOffset = args.at(-3) as number
341
- const fullString = args.at(-2) as string
340
+ const { offset: matchOffset, input: fullString } = replaceCallbackContext(args)
342
341
  if (feetInchesContext.test(fullString.substring(0, matchOffset))) {
343
342
  return `${digit}${sep}${primeChar}${afterSep}`
344
343
  }