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.
- package/.pre-commit-hooks.yaml +27 -0
- package/README.md +55 -2
- package/dist/cli.d.ts +21 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +346 -0
- package/dist/cli.js.map +1 -0
- package/dist/dashes.d.ts +2 -1
- package/dist/dashes.d.ts.map +1 -1
- package/dist/dashes.js +5 -3
- package/dist/dashes.js.map +1 -1
- package/dist/html.d.ts +44 -0
- package/dist/html.d.ts.map +1 -0
- package/dist/html.js +67 -0
- package/dist/html.js.map +1 -0
- package/dist/index.d.ts +0 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +18 -26
- package/dist/index.js.map +1 -1
- package/dist/markdown.d.ts +0 -4
- package/dist/markdown.d.ts.map +1 -1
- package/dist/markdown.js +2 -5
- package/dist/markdown.js.map +1 -1
- package/dist/prettier-plugin.d.ts +18 -0
- package/dist/prettier-plugin.d.ts.map +1 -0
- package/dist/prettier-plugin.js +52 -0
- package/dist/prettier-plugin.js.map +1 -0
- package/dist/quotes.d.ts +2 -1
- package/dist/quotes.d.ts.map +1 -1
- package/dist/quotes.js +4 -1
- package/dist/quotes.js.map +1 -1
- package/dist/rehype.d.ts +14 -36
- package/dist/rehype.d.ts.map +1 -1
- package/dist/rehype.js +14 -36
- package/dist/rehype.js.map +1 -1
- package/dist/remark.d.ts +0 -3
- package/dist/remark.d.ts.map +1 -1
- package/dist/remark.js +0 -7
- package/dist/remark.js.map +1 -1
- package/dist/symbols.d.ts.map +1 -1
- package/dist/symbols.js +6 -5
- package/dist/symbols.js.map +1 -1
- package/dist/utils.d.ts +26 -9
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +40 -9
- package/dist/utils.js.map +1 -1
- package/package.json +25 -2
- package/src/cli.ts +417 -0
- package/src/dashes.ts +25 -4
- package/src/html.ts +91 -0
- package/src/index.ts +20 -30
- package/src/markdown.ts +2 -7
- package/src/prettier-plugin.ts +61 -0
- package/src/quotes.ts +5 -2
- package/src/rehype.ts +14 -36
- package/src/remark.ts +0 -7
- package/src/symbols.ts +18 -5
- 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
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
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,
|
|
257
|
-
if (
|
|
258
|
-
text = primeMarks(text,
|
|
246
|
+
text = hyphenReplace(text, pipelineOpts)
|
|
247
|
+
if (pipelineOpts.punctuationStyle !== "none") {
|
|
248
|
+
text = primeMarks(text, pipelineOpts)
|
|
259
249
|
}
|
|
260
|
-
text = niceQuotes(text,
|
|
250
|
+
text = niceQuotes(text, pipelineOpts)
|
|
261
251
|
|
|
262
252
|
if (symbols) {
|
|
263
|
-
text = symbolTransform(text,
|
|
253
|
+
text = symbolTransform(text, pipelineOpts)
|
|
264
254
|
}
|
|
265
255
|
|
|
266
256
|
if (fractions) {
|
|
267
|
-
text = fractionsTransform(text,
|
|
257
|
+
text = fractionsTransform(text, pipelineOpts)
|
|
268
258
|
}
|
|
269
259
|
|
|
270
260
|
if (degrees) {
|
|
271
|
-
text = degreesTransform(text,
|
|
261
|
+
text = degreesTransform(text, pipelineOpts)
|
|
272
262
|
}
|
|
273
263
|
|
|
274
264
|
if (superscript) {
|
|
275
|
-
text = superscriptTransform(text,
|
|
265
|
+
text = superscriptTransform(text, pipelineOpts)
|
|
276
266
|
}
|
|
277
267
|
|
|
278
268
|
if (ligatures) {
|
|
279
|
-
text = ligaturesTransform(text,
|
|
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,
|
|
277
|
+
text = nbspTransformFn(text, pipelineOpts)
|
|
288
278
|
}
|
|
289
279
|
|
|
290
280
|
assertSeparatorCountPreserved(original, text, separator, "transform")
|