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.
- package/README.md +6 -6
- package/dist/constants.d.ts +2 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +3 -1
- package/dist/constants.js.map +1 -1
- package/dist/dashes.d.ts.map +1 -1
- package/dist/dashes.js +3 -3
- package/dist/dashes.js.map +1 -1
- package/dist/nbsp.d.ts.map +1 -1
- package/dist/nbsp.js +26 -39
- package/dist/nbsp.js.map +1 -1
- package/dist/prettier-plugin.d.ts.map +1 -1
- package/dist/prettier-plugin.js +3 -2
- package/dist/prettier-plugin.js.map +1 -1
- package/dist/prose-view.d.ts +26 -0
- package/dist/prose-view.d.ts.map +1 -1
- package/dist/prose-view.js +87 -1
- package/dist/prose-view.js.map +1 -1
- package/dist/quote-classifier.d.ts.map +1 -1
- package/dist/quote-classifier.js +67 -20
- package/dist/quote-classifier.js.map +1 -1
- package/dist/rehype.d.ts +14 -8
- package/dist/rehype.d.ts.map +1 -1
- package/dist/rehype.js +36 -23
- package/dist/rehype.js.map +1 -1
- package/dist/remark.d.ts.map +1 -1
- package/dist/remark.js +71 -11
- package/dist/remark.js.map +1 -1
- package/dist/symbols.d.ts.map +1 -1
- package/dist/symbols.js +7 -3
- package/dist/symbols.js.map +1 -1
- package/dist/transform-options.d.ts +3 -2
- package/dist/transform-options.d.ts.map +1 -1
- package/dist/transform-options.js +6 -0
- package/dist/transform-options.js.map +1 -1
- package/package.json +3 -2
- package/src/constants.ts +4 -1
- package/src/dashes.ts +3 -2
- package/src/nbsp.ts +26 -40
- package/src/prettier-plugin.ts +3 -2
- package/src/prose-view.ts +99 -1
- package/src/quote-classifier.ts +68 -19
- package/src/rehype.ts +64 -36
- package/src/remark.ts +85 -12
- package/src/symbols.ts +8 -4
- 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 {
|
|
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 (
|
|
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
|
-
|
|
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
|
-
//
|
|
99
|
-
//
|
|
100
|
-
|
|
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
|
-
|
|
151
|
-
|
|
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
|
-
/**
|
|
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 (
|
|
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.
|
package/src/transform-options.ts
CHANGED
|
@@ -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
|
|
39
|
-
*
|
|
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"
|