punctilio 3.13.2 → 4.1.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 (48) hide show
  1. package/.pre-commit-hooks.yaml +1 -1
  2. package/README.md +30 -11
  3. package/dist/cli.d.ts.map +1 -1
  4. package/dist/cli.js +190 -60
  5. package/dist/cli.js.map +1 -1
  6. package/dist/dashes.js +4 -4
  7. package/dist/dashes.js.map +1 -1
  8. package/dist/html.d.ts +5 -0
  9. package/dist/html.d.ts.map +1 -1
  10. package/dist/html.js +8 -2
  11. package/dist/html.js.map +1 -1
  12. package/dist/index.d.ts +7 -2
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +9 -2
  15. package/dist/index.js.map +1 -1
  16. package/dist/markdown.d.ts +9 -0
  17. package/dist/markdown.d.ts.map +1 -1
  18. package/dist/markdown.js +7 -1
  19. package/dist/markdown.js.map +1 -1
  20. package/dist/prettier-plugin.d.ts.map +1 -1
  21. package/dist/prettier-plugin.js +13 -1
  22. package/dist/prettier-plugin.js.map +1 -1
  23. package/dist/rehype.d.ts +15 -1
  24. package/dist/rehype.d.ts.map +1 -1
  25. package/dist/rehype.js +41 -12
  26. package/dist/rehype.js.map +1 -1
  27. package/dist/remark.d.ts +5 -0
  28. package/dist/remark.d.ts.map +1 -1
  29. package/dist/remark.js +7 -6
  30. package/dist/remark.js.map +1 -1
  31. package/dist/symbols.d.ts.map +1 -1
  32. package/dist/symbols.js +2 -3
  33. package/dist/symbols.js.map +1 -1
  34. package/dist/utils.d.ts +18 -1
  35. package/dist/utils.d.ts.map +1 -1
  36. package/dist/utils.js +40 -3
  37. package/dist/utils.js.map +1 -1
  38. package/package.json +2 -3
  39. package/src/cli.ts +200 -59
  40. package/src/dashes.ts +4 -4
  41. package/src/html.ts +11 -2
  42. package/src/index.ts +15 -4
  43. package/src/markdown.ts +15 -1
  44. package/src/prettier-plugin.ts +13 -1
  45. package/src/rehype.ts +60 -11
  46. package/src/remark.ts +13 -6
  47. package/src/symbols.ts +2 -3
  48. package/src/utils.ts +48 -3
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
 
@@ -46,10 +46,40 @@ export interface RehypePunctilioOptions
46
46
  * Default: []
47
47
  */
48
48
  skipClasses?: string[]
49
+
50
+ /**
51
+ * Invert the element model: transform text inside every element except the
52
+ * skip-list (`skipTags`/`skipClasses`) plus `textarea`/`input`/`select`,
53
+ * whose text is a literal form-control value rather than prose. The default
54
+ * (`false`) keeps the `TRANSFORMABLE_ELEMENTS` allowlist, transforming only
55
+ * known prose-bearing tags (and custom elements).
56
+ *
57
+ * Default: false
58
+ */
59
+ transformAllElements?: boolean
49
60
  }
50
61
 
51
62
  const DEFAULT_SKIP_TAGS = ["code", "pre", "script", "style", "kbd", "var", "samp", "template", "math", "svg"]
52
63
 
64
+ // Form elements whose text content is a literal control value, not prose.
65
+ // Hard-skipped (whole subtree) under `transformAllElements`, on top of the
66
+ // user skip-list, so their values are protected even when nested inside a
67
+ // transformable element.
68
+ const FORM_VALUE_TAGS = ["textarea", "input"]
69
+
70
+ // `<select>` is not prose itself, but its `<option>` children are (and are
71
+ // transformed under the default allowlist). Under `transformAllElements` we
72
+ // exclude `select` as a transform *unit* while still recursing into — and
73
+ // transforming — its options, so the inverted mode never transforms less than
74
+ // the allowlist.
75
+ const NON_PROSE_CONTAINER_TAGS = new Set(["select"])
76
+
77
+ /** Option keys handled by `rehypePunctilio` itself rather than `transform()`. */
78
+ export const REHYPE_ONLY_OPTION_KEYS: readonly string[] = ["skipTags", "skipClasses", "shouldSkipText", "transformAllElements"]
79
+
80
+ /** Runtime list of valid `rehypePunctilio` option keys. */
81
+ export const REHYPE_OPTION_KEYS: readonly string[] = [...TRANSFORM_OPTION_KEYS, ...REHYPE_ONLY_OPTION_KEYS]
82
+
53
83
  export function flattenTextNodes(
54
84
  node: Element | ElementContent,
55
85
  shouldSkip: ElementPredicate,
@@ -266,8 +296,18 @@ const TRANSFORMABLE_ELEMENTS = new Set([
266
296
  "ruby",
267
297
  "rt",
268
298
  "rp",
299
+ "title",
300
+ "button",
301
+ "option",
302
+ "output",
269
303
  ])
270
304
 
305
+ // A tag name containing "-" is a custom element per the HTML custom-element
306
+ // naming rule; assume those hold transformable prose like built-in elements.
307
+ function isTransformableElement(tagName: string): boolean {
308
+ return TRANSFORMABLE_ELEMENTS.has(tagName) || tagName.includes("-")
309
+ }
310
+
271
311
  function hasTextDescendant(
272
312
  node: Element | ElementContent,
273
313
  shouldSkip: ElementPredicate,
@@ -296,7 +336,8 @@ export function collectTransformableElements(
296
336
  node: Element,
297
337
  shouldSkip: ElementPredicate,
298
338
  depth: number = 0,
299
- alreadyTransformed?: ReadonlySet<Element>
339
+ alreadyTransformed?: ReadonlySet<Element>,
340
+ isTransformable: (tagName: string) => boolean = isTransformableElement
300
341
  ): Element[] {
301
342
  /* istanbul ignore if -- defensive: prevents stack overflow from malicious HTML */
302
343
  if (depth > MAX_RECURSION_DEPTH) {
@@ -319,7 +360,7 @@ export function collectTransformableElements(
319
360
  hasTextDescendant(node, shouldSkip)
320
361
 
321
362
  if (
322
- TRANSFORMABLE_ELEMENTS.has(node.tagName) &&
363
+ isTransformable(node.tagName) &&
323
364
  (hasDirectText || hasTextDescendants)
324
365
  ) {
325
366
  results.push(node)
@@ -328,7 +369,7 @@ export function collectTransformableElements(
328
369
  // children that should be independent, or has no text to transform.
329
370
  for (const child of node.children) {
330
371
  if (child.type === "element") {
331
- const childResults = collectTransformableElements(child, shouldSkip, depth + 1, alreadyTransformed)
372
+ const childResults = collectTransformableElements(child, shouldSkip, depth + 1, alreadyTransformed, isTransformable)
332
373
  for (const r of childResults) results.push(r)
333
374
  }
334
375
  }
@@ -354,17 +395,29 @@ function markDescendants(node: Element, set: Set<Element>, depth: number = 0): v
354
395
  export function rehypePunctilio(
355
396
  options: RehypePunctilioOptions = {}
356
397
  ): Transformer<Root, Root> {
398
+ assertKnownOptionKeys(options, REHYPE_OPTION_KEYS, "rehypePunctilio")
399
+
357
400
  const {
358
401
  skipTags = DEFAULT_SKIP_TAGS,
359
402
  skipClasses = [],
360
403
  separator = DEFAULT_SEPARATOR,
361
404
  shouldSkipText,
405
+ transformAllElements = false,
362
406
  ...transformOptions
363
407
  } = options
364
408
 
365
- const skipTagSet = new Set(skipTags)
409
+ // In inverted mode, form-value elements join the skip-list so their literal
410
+ // values are neither transformed nor flattened into a transformable ancestor.
411
+ const skipTagSet = new Set(transformAllElements ? [...skipTags, ...FORM_VALUE_TAGS] : skipTags)
366
412
  const skipClassSet = new Set(skipClasses)
367
413
 
414
+ // Inverted mode transforms every non-skipped element except non-prose
415
+ // containers; the default keeps the `TRANSFORMABLE_ELEMENTS` allowlist (plus
416
+ // custom elements).
417
+ const isTransformable = transformAllElements
418
+ ? (tagName: string) => !NON_PROSE_CONTAINER_TAGS.has(tagName)
419
+ : isTransformableElement
420
+
368
421
  const hasSkipClass = (node: Element): boolean => {
369
422
  if (skipClassSet.size === 0) return false
370
423
  const classNames = node.properties?.className
@@ -377,11 +430,7 @@ export function rehypePunctilio(
377
430
  const shouldSkip = (node: Element): boolean =>
378
431
  skipTagSet.has(node.tagName) || hasSkipClass(node)
379
432
 
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
433
  const pluginOptions = {
384
- checkIdempotency: false,
385
434
  ...transformOptions,
386
435
  separator,
387
436
  }
@@ -412,7 +461,7 @@ export function rehypePunctilio(
412
461
  }
413
462
 
414
463
  // Collect and transform elements with text content
415
- const elementsToTransform = collectTransformableElements(node, shouldSkip, 0, transformed)
464
+ const elementsToTransform = collectTransformableElements(node, shouldSkip, 0, transformed, isTransformable)
416
465
  for (const elt of elementsToTransform) {
417
466
  if (!transformed.has(elt)) {
418
467
  transformElement(elt, transformFn, shouldSkip, separator, false, elementOptions)
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
  }
package/src/utils.ts CHANGED
@@ -4,16 +4,18 @@ const ERROR_STRING_THRESHOLD = 2000
4
4
 
5
5
  /**
6
6
  * Formats a string for error messages, truncating above the threshold.
7
- * Writes full content to stderr in Node.js so build logs capture it.
7
+ * When the PUNCTILIO_DEBUG environment variable is set to a non-empty
8
+ * value, also writes the full content to stderr in Node.js so build logs
9
+ * capture it.
8
10
  */
9
11
  export function formatErrorString(content: string, label: string): string {
10
12
  if (content.length <= ERROR_STRING_THRESHOLD) {
11
13
  return JSON.stringify(content)
12
14
  }
13
15
 
14
- // In Node.js, write full content to stderr for debugging
16
+ // In Node.js, write full content to stderr for debugging when opted in
15
17
  try {
16
- if (typeof globalThis.process?.stderr?.write === "function") {
18
+ if (globalThis.process?.env?.PUNCTILIO_DEBUG && typeof globalThis.process?.stderr?.write === "function") {
17
19
  globalThis.process.stderr.write(
18
20
  `\n[punctilio ${label} full content (${content.length} chars)]:\n${content}\n\n`
19
21
  )
@@ -106,6 +108,30 @@ export function assertSeparatorCountPreserved(
106
108
  }
107
109
  }
108
110
 
111
+ /** Throws if `options` contains a key not present in `validKeys`. @internal */
112
+ export function assertKnownOptionKeys(
113
+ options: object,
114
+ validKeys: readonly string[],
115
+ context: string,
116
+ ): void {
117
+ for (const key of Object.keys(options)) {
118
+ if (!validKeys.includes(key)) {
119
+ throw new Error(
120
+ `Unknown option "${key}" for ${context}. Valid options: ${[...validKeys].sort().join(", ")}.`
121
+ )
122
+ }
123
+ }
124
+ }
125
+
126
+ /** Returns a copy of `obj` without the listed keys. @internal */
127
+ export function omitKeys<T extends object>(obj: T, keys: readonly string[]): Partial<T> {
128
+ const result: Record<string, unknown> = {}
129
+ for (const [k, v] of Object.entries(obj)) {
130
+ if (!keys.includes(k)) result[k] = v
131
+ }
132
+ return result as Partial<T>
133
+ }
134
+
109
135
  /** Returns a copy of `obj` with `undefined`-valued keys removed. @internal */
110
136
  export function filterUndefined<T extends object>(obj: T): Partial<T> {
111
137
  const result: Record<string, unknown> = {}
@@ -128,3 +154,22 @@ export function stableStringify(obj: object): string {
128
154
  export function namedGroups<G>(args: unknown[]): G {
129
155
  return args[args.length - 1] as G
130
156
  }
157
+
158
+ /**
159
+ * Extracts the match offset and input string from a `.replace()` callback's
160
+ * arguments, independent of capture-group count. The trailing arguments are
161
+ * `..., offset, input[, namedGroups]`: the input is the last string (the
162
+ * named-groups object, when present, follows it) and the offset is the
163
+ * number immediately before the input. @internal
164
+ */
165
+ export function replaceCallbackContext(args: unknown[]): { offset: number; input: string } {
166
+ let inputIndex = args.length - 1
167
+ while (inputIndex >= 0 && typeof args[inputIndex] !== "string") {
168
+ inputIndex--
169
+ }
170
+ const offset = args[inputIndex - 1]
171
+ if (inputIndex < 1 || typeof offset !== "number") {
172
+ throw new Error("Could not locate offset and input in replace-callback arguments.")
173
+ }
174
+ return { offset, input: args[inputIndex] as string }
175
+ }