punctilio 5.4.9 → 5.5.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 (46) hide show
  1. package/README.md +6 -6
  2. package/dist/constants.d.ts +2 -0
  3. package/dist/constants.d.ts.map +1 -1
  4. package/dist/constants.js +3 -1
  5. package/dist/constants.js.map +1 -1
  6. package/dist/dashes.d.ts.map +1 -1
  7. package/dist/dashes.js +3 -3
  8. package/dist/dashes.js.map +1 -1
  9. package/dist/nbsp.d.ts.map +1 -1
  10. package/dist/nbsp.js +26 -39
  11. package/dist/nbsp.js.map +1 -1
  12. package/dist/prettier-plugin.d.ts.map +1 -1
  13. package/dist/prettier-plugin.js +3 -2
  14. package/dist/prettier-plugin.js.map +1 -1
  15. package/dist/prose-view.d.ts +26 -0
  16. package/dist/prose-view.d.ts.map +1 -1
  17. package/dist/prose-view.js +87 -1
  18. package/dist/prose-view.js.map +1 -1
  19. package/dist/quote-classifier.d.ts.map +1 -1
  20. package/dist/quote-classifier.js +67 -20
  21. package/dist/quote-classifier.js.map +1 -1
  22. package/dist/rehype.d.ts +14 -8
  23. package/dist/rehype.d.ts.map +1 -1
  24. package/dist/rehype.js +36 -23
  25. package/dist/rehype.js.map +1 -1
  26. package/dist/remark.d.ts.map +1 -1
  27. package/dist/remark.js +71 -11
  28. package/dist/remark.js.map +1 -1
  29. package/dist/symbols.d.ts.map +1 -1
  30. package/dist/symbols.js +7 -3
  31. package/dist/symbols.js.map +1 -1
  32. package/dist/transform-options.d.ts +3 -2
  33. package/dist/transform-options.d.ts.map +1 -1
  34. package/dist/transform-options.js +6 -0
  35. package/dist/transform-options.js.map +1 -1
  36. package/package.json +3 -2
  37. package/src/constants.ts +4 -1
  38. package/src/dashes.ts +3 -2
  39. package/src/nbsp.ts +26 -40
  40. package/src/prettier-plugin.ts +3 -2
  41. package/src/prose-view.ts +99 -1
  42. package/src/quote-classifier.ts +68 -19
  43. package/src/rehype.ts +64 -36
  44. package/src/remark.ts +85 -12
  45. package/src/symbols.ts +8 -4
  46. package/src/transform-options.ts +11 -2
package/src/remark.ts CHANGED
@@ -1,12 +1,14 @@
1
- import type { PhrasingContent, Root, Text } from "mdast"
1
+ import type { Link, PhrasingContent, Root, Text } from "mdast"
2
2
  import type { Transformer } from "unified"
3
3
 
4
+ import { type Token, Tokenizer } from "parse5"
4
5
  import { visitParents } from "unist-util-visit-parents"
5
6
 
6
7
  import { transformView } from "./index.js"
7
8
  import { TRANSFORM_OPTION_KEYS, type TransformOptions } from "./transform-options.js"
8
9
  import { MAX_RECURSION_DEPTH } from "./constants.js"
9
- import { buildProseView, splitAtIndices } from "./prose-view.js"
10
+ import { runAcrossOpaqueGaps } from "./prose-view.js"
11
+ import { DEFAULT_SKIP_TAGS, RAW_TEXT_FORM_TAG } from "./rehype.js"
10
12
  import { assertKnownOptionKeys } from "./utils.js"
11
13
 
12
14
  /**
@@ -20,6 +22,8 @@ const PHRASING_CONTAINERS = new Set(["paragraph", "heading", "tableCell"])
20
22
 
21
23
  const SKIP_TYPES = new Set(["inlineCode", "html"])
22
24
 
25
+ const SKIPPED_HTML_TAGS: ReadonlySet<string> = new Set([...DEFAULT_SKIP_TAGS, RAW_TEXT_FORM_TAG])
26
+
23
27
  // Leaf phrasing nodes that render atomic content and carry no transformable
24
28
  // text. Between two text nodes, one is a real visual separator, so the
25
29
  // flattener records an opaque gap there (mirroring skipped `inlineCode`/`html`).
@@ -33,6 +37,67 @@ interface ProseCollector {
33
37
  // An opaque node was dropped since the last emitted text node; the next
34
38
  // emitted node records an opaque gap before it.
35
39
  pendingOpaque: boolean
40
+ // Raw-HTML skip-list tags opened and not yet closed; everything between an
41
+ // open tag and its close tag is skipped content.
42
+ openSkippedTags: string[]
43
+ // Markdown source the tree was parsed from; link positions index into it.
44
+ source: string
45
+ }
46
+
47
+ const URL_SCHEMES = ["mailto:", "https://", "http://"]
48
+
49
+ /** `url` without a leading `mailto:`, `https://` or `http://`, in any letter case. */
50
+ function stripUrlScheme(url: string): string {
51
+ const lowerCaseUrl = url.toLowerCase()
52
+ const scheme = URL_SCHEMES.find((candidate) => lowerCaseUrl.startsWith(candidate))
53
+ return scheme === undefined ? url : url.slice(scheme.length)
54
+ }
55
+
56
+ /** The Markdown source that produced `node`, or "" when it carries no usable position. */
57
+ function sourceOfLink(node: Link, source: string): string {
58
+ const start = node.position?.start.offset
59
+ const end = node.position?.end.offset
60
+ return start === undefined || end === undefined ? "" : source.slice(start, end)
61
+ }
62
+
63
+ /**
64
+ * True for an autolink or bare URL, whose visible text is the URL itself. A
65
+ * link not written as `[text](url)` (an `<…>` autolink or a GFM literal) is one
66
+ * by construction; a bracketed link is one when its text is its URL, with or
67
+ * without the scheme.
68
+ */
69
+ function isUrlTextLink(node: Link, source: string): boolean {
70
+ const linkSource = sourceOfLink(node, source)
71
+ if (linkSource !== "" && !linkSource.startsWith("[")) return true
72
+ if (node.children.length !== 1 || node.children[0].type !== "text") return false
73
+ return stripUrlScheme(node.children[0].value) === stripUrlScheme(node.url)
74
+ }
75
+
76
+ const ignoreToken = (): void => {}
77
+
78
+ /** Tracks raw-HTML skip-list tags so the text between an open and close tag is skipped. */
79
+ function trackSkippedHtmlTags(html: string, c: ProseCollector): void {
80
+ const openTag = (token: Token.TagToken): void => {
81
+ if (!token.selfClosing && SKIPPED_HTML_TAGS.has(token.tagName)) c.openSkippedTags.push(token.tagName)
82
+ }
83
+ const closeTag = (token: Token.TagToken): void => {
84
+ const openIndex = c.openSkippedTags.lastIndexOf(token.tagName)
85
+ if (openIndex !== -1) c.openSkippedTags.length = openIndex
86
+ }
87
+ const tokenizer = new Tokenizer(
88
+ { sourceCodeLocationInfo: false },
89
+ {
90
+ onComment: ignoreToken,
91
+ onDoctype: ignoreToken,
92
+ onStartTag: openTag,
93
+ onEndTag: closeTag,
94
+ onEof: ignoreToken,
95
+ onCharacter: ignoreToken,
96
+ onNullCharacter: ignoreToken,
97
+ onWhitespaceCharacter: ignoreToken,
98
+ }
99
+ )
100
+ tokenizer.write(html, true)
36
101
  }
37
102
 
38
103
  function collectProse(node: PhrasingContent, depth: number, c: ProseCollector): void {
@@ -41,7 +106,14 @@ function collectProse(node: PhrasingContent, depth: number, c: ProseCollector):
41
106
  return
42
107
  }
43
108
 
44
- if (SKIP_TYPES.has(node.type) || OPAQUE_LEAF_TYPES.has(node.type)) {
109
+ if (node.type === "html") trackSkippedHtmlTags(node.value, c)
110
+
111
+ if (
112
+ SKIP_TYPES.has(node.type) ||
113
+ OPAQUE_LEAF_TYPES.has(node.type) ||
114
+ c.openSkippedTags.length > 0 ||
115
+ (node.type === "link" && isUrlTextLink(node, c.source))
116
+ ) {
45
117
  c.pendingOpaque = true
46
118
  return
47
119
  }
@@ -58,8 +130,8 @@ function collectProse(node: PhrasingContent, depth: number, c: ProseCollector):
58
130
  }
59
131
  }
60
132
 
61
- function flattenProse(children: readonly PhrasingContent[]): { nodes: Text[]; opaqueBefore: Set<number> } {
62
- const c: ProseCollector = { nodes: [], opaqueBefore: new Set(), pendingOpaque: false }
133
+ function flattenProse(children: readonly PhrasingContent[], source: string): { nodes: Text[]; opaqueBefore: Set<number> } {
134
+ const c: ProseCollector = { nodes: [], opaqueBefore: new Set(), pendingOpaque: false, openSkippedTags: [], source }
63
135
  for (const child of children) collectProse(child, 0, c)
64
136
  return { nodes: c.nodes, opaqueBefore: c.opaqueBefore }
65
137
  }
@@ -77,7 +149,10 @@ export function remarkPunctilio(
77
149
  nbsp: options.nbsp ?? false,
78
150
  }
79
151
 
80
- return (tree: Root) => {
152
+ // `file` is absent when the transformer is called with a bare tree (the
153
+ // Prettier plugin); links are then judged by their text alone.
154
+ return (tree: Root, file?: { toString(): string }) => {
155
+ const source = file?.toString() ?? ""
81
156
  visitParents(tree, (node, ancestors) => {
82
157
  if (!PHRASING_CONTAINERS.has(node.type)) {
83
158
  return
@@ -93,13 +168,11 @@ export function remarkPunctilio(
93
168
  return
94
169
  }
95
170
 
96
- const { nodes, opaqueBefore } = flattenProse(node.children as PhrasingContent[])
171
+ const { nodes, opaqueBefore } = flattenProse(node.children as PhrasingContent[], source)
97
172
 
98
- // Split at opaque gaps (inline code, images, breaks) so no pass rewrites
99
- // text as adjacent across content that visually separates it.
100
- for (const group of splitAtIndices(nodes, opaqueBefore)) {
101
- transformView(buildProseView(group), pluginOptions)
102
- }
173
+ // Opaque gaps (inline code, images, breaks) become placeholders, so
174
+ // quotes pair across them while no pass rewrites text through them.
175
+ runAcrossOpaqueGaps(nodes, opaqueBefore, (view) => transformView(view, pluginOptions))
103
176
  })
104
177
  }
105
178
  }
package/src/symbols.ts CHANGED
@@ -147,10 +147,14 @@ const DIGIT_SUFFIX = `(?:${PRIME_ALT}|${UNIT_ALT})?`
147
147
  // (1e5x3 — the `e` is a letter) and model SKUs (Surface5x3, RTX3060x2);
148
148
  // signed exponents (1e-5x3, 3.5E+10x2) are unambiguously scientific, their
149
149
  // operand is preceded by a sign, and they convert.
150
- const MULTIPLICATION_CHAIN = `(?<firstNum>\\d+${DIGIT_SUFFIX})(?<rest>(?:\\s*[xX*]\\s*\\d+${DIGIT_SUFFIX})+)`
151
- const MULTIPLICATION_SEGMENT = `(?<spaceBefore>\\s*)[xX*](?<spaceAfter>\\s*)(?<num>\\d+${DIGIT_SUFFIX})`
150
+ //
151
+ // Operator spacing is horizontal whitespace only: an operator across a line
152
+ // break is a list marker (`Step 1\n* 2 cups`), not multiplication.
153
+ const OPERATOR_SPACE = "[^\\S\\n\\v\\f\\r\\u2028\\u2029]*"
154
+ const MULTIPLICATION_CHAIN = `(?<firstNum>\\d+${DIGIT_SUFFIX})(?<rest>(?:${OPERATOR_SPACE}[xX*]${OPERATOR_SPACE}\\d+${DIGIT_SUFFIX})+)`
155
+ const MULTIPLICATION_SEGMENT = `(?<spaceBefore>${OPERATOR_SPACE})[xX*](?<spaceAfter>${OPERATOR_SPACE})(?<num>\\d+${DIGIT_SUFFIX})`
152
156
 
153
- /** A `\s*[xX*]\s*\d+suffix` segment located by absolute clean-text offsets. */
157
+ /** An `OPERATOR_SPACE [xX*] OPERATOR_SPACE \d+suffix` segment located by absolute clean-text offsets. */
154
158
  interface ChainSegment {
155
159
  /** Absolute offset of the operator character. */
156
160
  operatorOffset: number
@@ -183,7 +187,7 @@ function chainSegments(chainStart: number, firstNum: string, rest: string): Chai
183
187
  }
184
188
 
185
189
  /**
186
- * Each operator's spacing slot (`\s*[xX*]\s*`) tolerates one node boundary at
190
+ * Each operator's spacing slot (`OPERATOR_SPACE [xX*] OPERATOR_SPACE`) tolerates one node boundary at
187
191
  * each outer edge and none inside the spacing; a second adjacent boundary at an
188
192
  * edge breaks the segment. The operator's slot is the span [slotStart, slotEnd)
189
193
  * covering its surrounding spaces and the operator itself.
@@ -35,8 +35,9 @@ export interface TransformOptions {
35
35
  * (U+201E/U+201C double, U+201A/U+2018 single). Punctuation outside quotes.
36
36
  * - `"french"`: French style. Uses guillemets with NNBSP padding: «\u202F...\u202F»
37
37
  * Single quotes remain as curly quotes. Punctuation outside quotes.
38
- * - `"none"`: Skip all quote and punctuation transforms entirely.
39
- * Straight quotes, apostrophes, and prime marks are left unmodified.
38
+ * - `"none"`: Skip quote classification and prime conversion. Straight
39
+ * quotes, apostrophes, and prime marks are left unmodified; a U+02BC
40
+ * modifier-letter apostrophe is still normalized to U+2019.
40
41
  *
41
42
  * Default: "american"
42
43
  */
@@ -121,6 +122,14 @@ export type ResolvedTransformOptions = Required<TransformOptions>
121
122
 
122
123
  /** Validates `options` and fills in the defaults. */
123
124
  export function resolveTransformOptions(options: TransformOptions): ResolvedTransformOptions {
125
+ // JS callers outside the type system can pass null, a string, or an array;
126
+ // `Object.keys` would otherwise throw an opaque TypeError or enumerate a
127
+ // string's indices as unknown option names.
128
+ if (options === null || typeof options !== "object" || Array.isArray(options)) {
129
+ throw new TypeError(
130
+ `transform options must be an object, got ${options === null ? "null" : Array.isArray(options) ? "an array" : typeof options}.`
131
+ )
132
+ }
124
133
  assertKnownOptionKeys(options, TRANSFORM_OPTION_KEYS, "transform")
125
134
 
126
135
  const punctuationStyle = options.punctuationStyle ?? "american"