punctilio 5.4.8 → 5.4.10
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 +12 -2
- package/dist/cli.d.ts +5 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +156 -48
- package/dist/cli.js.map +1 -1
- 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/html.d.ts +4 -3
- package/dist/html.d.ts.map +1 -1
- package/dist/html.js +55 -17
- package/dist/html.js.map +1 -1
- package/dist/markdown.d.ts +1 -2
- package/dist/markdown.d.ts.map +1 -1
- package/dist/markdown.js +52 -5
- package/dist/markdown.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/peer-dependencies.d.ts +21 -0
- package/dist/peer-dependencies.d.ts.map +1 -0
- package/dist/peer-dependencies.js +40 -0
- package/dist/peer-dependencies.js.map +1 -0
- package/dist/prettier-plugin.d.ts.map +1 -1
- package/dist/prettier-plugin.js +1 -2
- package/dist/prettier-plugin.js.map +1 -1
- package/dist/prose-view.d.ts +7 -0
- package/dist/prose-view.d.ts.map +1 -1
- package/dist/prose-view.js +11 -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/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/dist/utils.d.ts +5 -0
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +10 -0
- package/dist/utils.js.map +1 -1
- package/package.json +4 -2
- package/src/cli.ts +180 -48
- package/src/constants.ts +4 -1
- package/src/dashes.ts +3 -2
- package/src/html.ts +60 -19
- package/src/markdown.ts +58 -9
- package/src/nbsp.ts +26 -40
- package/src/peer-dependencies.ts +58 -0
- package/src/prettier-plugin.ts +1 -2
- package/src/prose-view.ts +12 -1
- package/src/quote-classifier.ts +68 -19
- package/src/symbols.ts +8 -4
- package/src/transform-options.ts +11 -2
- package/src/utils.ts +14 -0
package/src/cli.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { createHash } from "node:crypto"
|
|
4
4
|
import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs"
|
|
5
|
-
import { readFile, rename, writeFile } from "node:fs/promises"
|
|
5
|
+
import { chmod, readFile, realpath, rename, rm, stat, 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
8
|
import { parseArgs } from "node:util"
|
|
@@ -11,9 +11,10 @@ import { cosmiconfig } from "cosmiconfig"
|
|
|
11
11
|
import ignore, { type Ignore } from "ignore"
|
|
12
12
|
import { glob } from "tinyglobby"
|
|
13
13
|
|
|
14
|
+
import { HTML_ONLY_OPTION_KEYS, MARKDOWN_ONLY_OPTION_KEYS } from "./sink-option-keys.js"
|
|
14
15
|
import type { MarkdownOptions } from "./markdown.js"
|
|
15
16
|
import type { HtmlOptions } from "./html.js"
|
|
16
|
-
import {
|
|
17
|
+
import { loadSinkTransform, MissingPeerDependencyError, type PeerImporter } from "./peer-dependencies.js"
|
|
17
18
|
import { PUNCTUATION_STYLES, type PunctuationStyle } from "./quotes.js"
|
|
18
19
|
import { DASH_STYLES, type DashStyle } from "./dashes.js"
|
|
19
20
|
import { assertKnownOptionKeys, omitKeys, stableStringify } from "./utils.js"
|
|
@@ -25,6 +26,8 @@ interface CliIO {
|
|
|
25
26
|
stdin: AsyncIterable<string | Buffer>
|
|
26
27
|
stdout: NodeJS.WritableStream
|
|
27
28
|
stderr: NodeJS.WritableStream
|
|
29
|
+
/** Overrides how optional peer dependencies are imported; tests use it to simulate one being absent. */
|
|
30
|
+
importPeer?: PeerImporter
|
|
28
31
|
}
|
|
29
32
|
|
|
30
33
|
class UsageError extends Error {}
|
|
@@ -133,7 +136,7 @@ Options:
|
|
|
133
136
|
--rule-marker <char> markdown thematic break character (choices: ${RULE_MARKERS.join(", ")})
|
|
134
137
|
--skip-tag <tag> HTML tag whose contents are left alone (repeatable)
|
|
135
138
|
--skip-class <class> HTML class name whose elements are skipped (repeatable)
|
|
136
|
-
--no-fragment parse
|
|
139
|
+
--no-fragment always parse HTML as a full document (default: a full document is detected from a leading <!doctype> or <html>)
|
|
137
140
|
-h, --help display help for command
|
|
138
141
|
`
|
|
139
142
|
|
|
@@ -230,11 +233,74 @@ function inferFileType(path: string, override?: FileType): FileType {
|
|
|
230
233
|
)
|
|
231
234
|
}
|
|
232
235
|
|
|
236
|
+
interface ConfigValueRule {
|
|
237
|
+
expected: string
|
|
238
|
+
isValid: (value: unknown) => boolean
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const BOOLEAN_RULE: ConfigValueRule = {
|
|
242
|
+
expected: "a boolean",
|
|
243
|
+
isValid: (value) => typeof value === "boolean",
|
|
244
|
+
}
|
|
245
|
+
const STRING_ARRAY_RULE: ConfigValueRule = {
|
|
246
|
+
expected: "an array of strings",
|
|
247
|
+
isValid: (value) => Array.isArray(value) && value.every((item) => typeof item === "string"),
|
|
248
|
+
}
|
|
249
|
+
const FUNCTION_RULE: ConfigValueRule = {
|
|
250
|
+
expected: "a function",
|
|
251
|
+
isValid: (value) => typeof value === "function",
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function oneOfRule(choices: readonly string[]): ConfigValueRule {
|
|
255
|
+
return {
|
|
256
|
+
expected: `one of ${choices.join(", ")}`,
|
|
257
|
+
isValid: (value) => typeof value === "string" && choices.includes(value),
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// `satisfies` makes the compiler flag any option key that lacks a rule.
|
|
262
|
+
const CONFIG_VALUE_RULES = {
|
|
263
|
+
symbols: BOOLEAN_RULE,
|
|
264
|
+
collapseSpaces: BOOLEAN_RULE,
|
|
265
|
+
includeArrows: BOOLEAN_RULE,
|
|
266
|
+
fractions: BOOLEAN_RULE,
|
|
267
|
+
degrees: BOOLEAN_RULE,
|
|
268
|
+
superscript: BOOLEAN_RULE,
|
|
269
|
+
ligatures: BOOLEAN_RULE,
|
|
270
|
+
nbsp: BOOLEAN_RULE,
|
|
271
|
+
fragment: BOOLEAN_RULE,
|
|
272
|
+
transformAllElements: BOOLEAN_RULE,
|
|
273
|
+
punctuationStyle: oneOfRule(PUNCTUATION_STYLES),
|
|
274
|
+
dashStyle: oneOfRule(DASH_STYLES),
|
|
275
|
+
emphasisMarker: oneOfRule(EMPHASIS_MARKERS),
|
|
276
|
+
strongMarker: oneOfRule(EMPHASIS_MARKERS),
|
|
277
|
+
bulletMarker: oneOfRule(BULLET_MARKERS),
|
|
278
|
+
ruleMarker: oneOfRule(RULE_MARKERS),
|
|
279
|
+
skipTags: STRING_ARRAY_RULE,
|
|
280
|
+
skipClasses: STRING_ARRAY_RULE,
|
|
281
|
+
shouldSkipText: FUNCTION_RULE,
|
|
282
|
+
} as const satisfies Record<keyof CliOptions, ConfigValueRule>
|
|
283
|
+
|
|
233
284
|
// A config file may legitimately mix markdown-only and HTML-only keys for use
|
|
234
285
|
// across file types; transformContent strips the keys the sink doesn't accept.
|
|
235
|
-
const CLI_CONFIG_KEYS: readonly string[] =
|
|
236
|
-
|
|
237
|
-
|
|
286
|
+
const CLI_CONFIG_KEYS: readonly string[] = Object.keys(CONFIG_VALUE_RULES)
|
|
287
|
+
|
|
288
|
+
// Keys are checked first, so every key present has a rule.
|
|
289
|
+
function assertValidConfig(config: unknown, source: string): asserts config is CliOptions {
|
|
290
|
+
if (typeof config !== "object" || config === null || Array.isArray(config)) {
|
|
291
|
+
throw new UsageError(`${source} must contain an object of options.`)
|
|
292
|
+
}
|
|
293
|
+
assertKnownOptionKeys(config, CLI_CONFIG_KEYS, source)
|
|
294
|
+
for (const [key, value] of Object.entries(config)) {
|
|
295
|
+
if (value === undefined) continue
|
|
296
|
+
const rule: ConfigValueRule = CONFIG_VALUE_RULES[key as keyof CliOptions]
|
|
297
|
+
if (!rule.isValid(value)) {
|
|
298
|
+
throw new UsageError(
|
|
299
|
+
`Invalid value for "${key}" in ${source}: expected ${rule.expected}, got ${JSON.stringify(value)}.`,
|
|
300
|
+
)
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
238
304
|
|
|
239
305
|
// Config keys must match library option names (punctuationStyle, skipTags, …),
|
|
240
306
|
// not the kebab-cased CLI flag names.
|
|
@@ -248,11 +314,11 @@ async function loadConfig(
|
|
|
248
314
|
const result = configPath ? await explorer.load(configPath) : await explorer.search(cwd)
|
|
249
315
|
if (!result || result.isEmpty) return {}
|
|
250
316
|
try {
|
|
251
|
-
|
|
317
|
+
assertValidConfig(result.config, `config file ${result.filepath}`)
|
|
252
318
|
} catch (err) {
|
|
253
|
-
throw new UsageError((err as Error).message)
|
|
319
|
+
throw err instanceof UsageError ? err : new UsageError((err as Error).message)
|
|
254
320
|
}
|
|
255
|
-
return result.config
|
|
321
|
+
return result.config
|
|
256
322
|
}
|
|
257
323
|
|
|
258
324
|
function loadIgnore(cwd: string, ignorePath: string | undefined): Ignore {
|
|
@@ -273,9 +339,20 @@ function isLiteralPath(pattern: string, cwd: string): boolean {
|
|
|
273
339
|
return !hasGlobMetacharacters(pattern) || existsSync(resolve(cwd, pattern))
|
|
274
340
|
}
|
|
275
341
|
|
|
276
|
-
//
|
|
277
|
-
|
|
278
|
-
|
|
342
|
+
// Dependency and VCS trees are never project sources; glob expansion skips them.
|
|
343
|
+
const DEFAULT_GLOB_IGNORES = ["**/node_modules/**", "**/.git/**"]
|
|
344
|
+
|
|
345
|
+
function isIgnored(file: string, cwd: string, ig: Ignore): boolean {
|
|
346
|
+
const rel = relative(cwd, file)
|
|
347
|
+
return !rel.startsWith("..") && ig.ignores(rel)
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Literal positionals pass through to the read step so a missing file errors
|
|
351
|
+
// loudly. Ignore rules apply to literals and glob expansions alike, because
|
|
352
|
+
// tools like pre-commit pass every staged file by name. Each glob is expanded
|
|
353
|
+
// on its own, so one that matches nothing is a usage error even when another
|
|
354
|
+
// glob matches; one whose matches are all ignored only warns. Negated globs
|
|
355
|
+
// (`!pattern`) exclude from every other glob rather than matching on their own.
|
|
279
356
|
async function discoverFiles(
|
|
280
357
|
patterns: string[],
|
|
281
358
|
cwd: string,
|
|
@@ -284,17 +361,33 @@ async function discoverFiles(
|
|
|
284
361
|
): Promise<string[]> {
|
|
285
362
|
const literals = patterns.filter((p) => isLiteralPath(p, cwd))
|
|
286
363
|
const globs = patterns.filter((p) => !isLiteralPath(p, cwd))
|
|
287
|
-
const
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
364
|
+
const negatedGlobs = globs.filter((pattern) => pattern.startsWith("!"))
|
|
365
|
+
const positiveGlobs = globs.filter((pattern) => !pattern.startsWith("!"))
|
|
366
|
+
const expansions = await Promise.all(
|
|
367
|
+
positiveGlobs.map(async (pattern) => ({
|
|
368
|
+
pattern,
|
|
369
|
+
files: await glob([pattern, ...negatedGlobs], {
|
|
370
|
+
cwd,
|
|
371
|
+
absolute: true,
|
|
372
|
+
onlyFiles: true,
|
|
373
|
+
ignore: DEFAULT_GLOB_IGNORES,
|
|
374
|
+
}),
|
|
375
|
+
})),
|
|
376
|
+
)
|
|
377
|
+
const unmatched = positiveGlobs.length === 0
|
|
378
|
+
? globs
|
|
379
|
+
: expansions.filter(({ files }) => files.length === 0).map(({ pattern }) => pattern)
|
|
380
|
+
if (unmatched.length > 0) {
|
|
381
|
+
throw new UsageError(`no files matched: ${unmatched.join(" ")}`)
|
|
296
382
|
}
|
|
297
|
-
|
|
383
|
+
const keptExpanded: string[] = []
|
|
384
|
+
for (const { pattern, files } of expansions) {
|
|
385
|
+
const kept = files.filter((file) => !isIgnored(file, cwd, ig))
|
|
386
|
+
if (kept.length === 0) stderr.write(`Warning: every file matched by ${pattern} is ignored\n`)
|
|
387
|
+
keptExpanded.push(...kept)
|
|
388
|
+
}
|
|
389
|
+
const keptLiterals = literals.map((p) => resolve(cwd, p)).filter((file) => !isIgnored(file, cwd, ig))
|
|
390
|
+
return [...new Set([...keptLiterals, ...keptExpanded])]
|
|
298
391
|
}
|
|
299
392
|
|
|
300
393
|
interface CacheEntry {
|
|
@@ -347,10 +440,20 @@ function saveCache(location: string, cache: Cache): void {
|
|
|
347
440
|
|
|
348
441
|
// Replace a file's contents atomically: write a sibling temp file, then rename
|
|
349
442
|
// it over the target so an interrupted run can never leave a half-written file.
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
await
|
|
443
|
+
// The temp file replaces the symlink's target (not the link itself) and takes
|
|
444
|
+
// over the original file's permission bits; it is removed if anything fails.
|
|
445
|
+
export async function atomicWriteFile(path: string, contents: string): Promise<void> {
|
|
446
|
+
const targetPath = await realpath(path)
|
|
447
|
+
const { mode } = await stat(targetPath)
|
|
448
|
+
const tmpPath = `${targetPath}.${process.pid}.tmp`
|
|
449
|
+
try {
|
|
450
|
+
await writeFile(tmpPath, contents, { mode: mode & 0o7777 })
|
|
451
|
+
await chmod(tmpPath, mode & 0o7777)
|
|
452
|
+
await rename(tmpPath, targetPath)
|
|
453
|
+
} catch (err) {
|
|
454
|
+
await rm(tmpPath, { force: true })
|
|
455
|
+
throw err
|
|
456
|
+
}
|
|
354
457
|
}
|
|
355
458
|
|
|
356
459
|
// cwd-relative so the cache survives directory moves and cross-machine syncs.
|
|
@@ -382,30 +485,42 @@ function buildOptions(flags: ParsedFlags): CliOptions {
|
|
|
382
485
|
|
|
383
486
|
// The merged options may carry keys for the other sink (e.g. emphasisMarker
|
|
384
487
|
// alongside skipTags from one shared config); strip the inapplicable ones so
|
|
385
|
-
// each sink's strict key validation only sees keys it understands.
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
488
|
+
// each sink's strict key validation only sees keys it understands.
|
|
489
|
+
async function transformContent(
|
|
490
|
+
input: string,
|
|
491
|
+
type: FileType,
|
|
492
|
+
opts: CliOptions,
|
|
493
|
+
importPeer: PeerImporter | undefined,
|
|
494
|
+
): Promise<string> {
|
|
389
495
|
if (type === "md") {
|
|
390
|
-
const
|
|
496
|
+
const transformMarkdown = await loadSinkTransform("md", importPeer)
|
|
391
497
|
return transformMarkdown(input, omitKeys(opts, HTML_ONLY_OPTION_KEYS) as MarkdownOptions)
|
|
392
498
|
}
|
|
393
|
-
const
|
|
499
|
+
const transformHtml = await loadSinkTransform("html", importPeer)
|
|
394
500
|
return transformHtml(input, omitKeys(opts, MARKDOWN_ONLY_OPTION_KEYS) as HtmlOptions)
|
|
395
501
|
}
|
|
396
502
|
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
503
|
+
async function formatDocument(
|
|
504
|
+
original: string,
|
|
505
|
+
type: FileType,
|
|
506
|
+
opts: CliOptions,
|
|
507
|
+
importPeer: PeerImporter | undefined,
|
|
508
|
+
): Promise<string> {
|
|
509
|
+
return matchLineEndings(original, await transformContent(original, type, opts, importPeer))
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// The unified pipelines emit LF-only output. Markdown always ends in a newline;
|
|
513
|
+
// a full HTML document may end without one (its final newline can be dropped
|
|
514
|
+
// or sit inside <body>). Restore the source's line-ending style and
|
|
515
|
+
// trailing-newline presence so a CRLF file doesn't gain mixed endings (which
|
|
516
|
+
// would make --check report a change forever and --write churn on every run).
|
|
401
517
|
function matchLineEndings(original: string, formatted: string): string {
|
|
402
|
-
|
|
403
|
-
|
|
518
|
+
const lineEnding = original.includes("\r\n") ? "\r\n" : "\n"
|
|
519
|
+
const out = lineEnding === "\r\n"
|
|
404
520
|
? formatted.replace(/\r\n/g, "\n").replace(/\n/g, "\r\n")
|
|
405
521
|
: formatted
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
return out
|
|
522
|
+
if (!original.endsWith("\n")) return out.replace(/\r?\n$/, "")
|
|
523
|
+
return out.endsWith("\n") ? out : out + lineEnding
|
|
409
524
|
}
|
|
410
525
|
|
|
411
526
|
async function readStdin(stdin: AsyncIterable<string | Buffer>): Promise<string> {
|
|
@@ -443,7 +558,7 @@ export async function runCli(args: string[], io: CliIO): Promise<number> {
|
|
|
443
558
|
}
|
|
444
559
|
return await runValidated(buildFlags(values, tokens), positionals, io, version)
|
|
445
560
|
} catch (err) {
|
|
446
|
-
if (err instanceof UsageError) {
|
|
561
|
+
if (err instanceof UsageError || err instanceof MissingPeerDependencyError) {
|
|
447
562
|
io.stderr.write(`Error: ${err.message}\n`)
|
|
448
563
|
return 2
|
|
449
564
|
}
|
|
@@ -475,6 +590,10 @@ async function runValidated(
|
|
|
475
590
|
throw new UsageError("--write and --check are mutually exclusive.")
|
|
476
591
|
}
|
|
477
592
|
|
|
593
|
+
if (flags.stdinFilepath !== undefined && positionals.some((p) => p !== "-")) {
|
|
594
|
+
throw new UsageError("--stdin-filepath cannot be combined with file arguments.")
|
|
595
|
+
}
|
|
596
|
+
|
|
478
597
|
if (readsStdin(positionals, flags)) {
|
|
479
598
|
if (write) {
|
|
480
599
|
throw new UsageError("--write cannot be used when reading from stdin.")
|
|
@@ -489,7 +608,7 @@ async function runValidated(
|
|
|
489
608
|
throw new UsageError("Reading from stdin requires --type md|html or --stdin-filepath <path>.")
|
|
490
609
|
}
|
|
491
610
|
const input = await readStdin(io.stdin)
|
|
492
|
-
const output =
|
|
611
|
+
const output = await formatDocument(input, type, opts, io.importPeer)
|
|
493
612
|
if (check) return input === output ? 0 : 1
|
|
494
613
|
io.stdout.write(output)
|
|
495
614
|
return 0
|
|
@@ -502,6 +621,9 @@ async function runValidated(
|
|
|
502
621
|
|
|
503
622
|
const ig = loadIgnore(cwd, flags.ignorePath)
|
|
504
623
|
const files = await discoverFiles(positionals, cwd, ig, io.stderr)
|
|
624
|
+
// Resolve every file's type up front so an unsupported extension fails
|
|
625
|
+
// before any file has been rewritten.
|
|
626
|
+
const types = files.map((file) => inferFileType(file, flags.type))
|
|
505
627
|
|
|
506
628
|
// In stdout mode every file's content must be produced, so a cache hit
|
|
507
629
|
// could never skip work; only --write and --check use the cache.
|
|
@@ -512,8 +634,8 @@ async function runValidated(
|
|
|
512
634
|
const optionsHash = cache ? hashOptions(opts) : ""
|
|
513
635
|
|
|
514
636
|
let anyChanged = false
|
|
515
|
-
for (const path of files) {
|
|
516
|
-
const type =
|
|
637
|
+
for (const [index, path] of files.entries()) {
|
|
638
|
+
const type = types[index]
|
|
517
639
|
const original = await readFile(path, "utf8")
|
|
518
640
|
const key = cache ? cacheKey(path, cwd) : ""
|
|
519
641
|
if (cache) {
|
|
@@ -527,7 +649,7 @@ async function runValidated(
|
|
|
527
649
|
) continue
|
|
528
650
|
}
|
|
529
651
|
|
|
530
|
-
const formatted =
|
|
652
|
+
const formatted = await formatDocument(original, type, opts, io.importPeer)
|
|
531
653
|
if (!write && !check) {
|
|
532
654
|
io.stdout.write(formatted)
|
|
533
655
|
continue
|
|
@@ -551,9 +673,19 @@ async function runValidated(
|
|
|
551
673
|
return check && anyChanged ? 1 : 0
|
|
552
674
|
}
|
|
553
675
|
|
|
676
|
+
// npm links bins through symlinks, so argv[1] is the link, not the module file.
|
|
677
|
+
export function isMainModule(moduleUrl: string, entryPath: string | undefined): boolean {
|
|
678
|
+
if (entryPath === undefined) return false
|
|
679
|
+
try {
|
|
680
|
+
return realpathSync(fileURLToPath(moduleUrl)) === realpathSync(entryPath)
|
|
681
|
+
} catch (err) {
|
|
682
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return false
|
|
683
|
+
throw err
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
554
687
|
/* istanbul ignore next -- entry-point bootstrap; logic is covered via runCli */
|
|
555
|
-
|
|
556
|
-
if (process.argv[1] && fileURLToPath(import.meta.url) === realpathSync(process.argv[1])) {
|
|
688
|
+
if (isMainModule(import.meta.url, process.argv[1])) {
|
|
557
689
|
runCli(process.argv.slice(2), {
|
|
558
690
|
stdin: process.stdin,
|
|
559
691
|
stdout: process.stdout,
|
package/src/constants.ts
CHANGED
|
@@ -130,6 +130,9 @@ const BRACKET_PAIRS: readonly (readonly [string, string])[] = [
|
|
|
130
130
|
[UNICODE_SYMBOLS.LEFT_LENTICULAR_BRACKET, UNICODE_SYMBOLS.RIGHT_LENTICULAR_BRACKET],
|
|
131
131
|
]
|
|
132
132
|
|
|
133
|
+
/** Opening brackets, the counterparts of {@link CLOSING_BRACKETS}. */
|
|
134
|
+
export const OPENING_BRACKETS: readonly string[] = BRACKET_PAIRS.map(([opening]) => opening)
|
|
135
|
+
|
|
133
136
|
/**
|
|
134
137
|
* Punctuation that opens a construct and so binds rightward, to the text after
|
|
135
138
|
* it rather than the text before: brackets, the Spanish inverted marks, and the
|
|
@@ -142,7 +145,7 @@ const BRACKET_PAIRS: readonly (readonly [string, string])[] = [
|
|
|
142
145
|
* settle, so a consumer that wants to treat one as an opener owns that call.
|
|
143
146
|
*/
|
|
144
147
|
export const OPENING_PUNCTUATION: readonly string[] = [
|
|
145
|
-
...
|
|
148
|
+
...OPENING_BRACKETS,
|
|
146
149
|
UNICODE_SYMBOLS.INVERTED_EXCLAMATION,
|
|
147
150
|
UNICODE_SYMBOLS.INVERTED_QUESTION,
|
|
148
151
|
UNICODE_SYMBOLS.LEFT_DOUBLE_QUOTE,
|
package/src/dashes.ts
CHANGED
|
@@ -12,6 +12,7 @@ export interface DashOptions {
|
|
|
12
12
|
const {
|
|
13
13
|
EN_DASH, EM_DASH, MINUS, MULTIPLICATION, PLUS_MINUS,
|
|
14
14
|
LEFT_DOUBLE_QUOTE, RIGHT_DOUBLE_QUOTE, LEFT_SINGLE_QUOTE, RIGHT_SINGLE_QUOTE,
|
|
15
|
+
MODIFIER_LETTER_APOSTROPHE, LEFT_GUILLEMET,
|
|
15
16
|
} = UNICODE_SYMBOLS
|
|
16
17
|
|
|
17
18
|
// Prevents false-positive ranges in model names like "Llama-2-7B".
|
|
@@ -781,7 +782,7 @@ function minusSubtractionPrefixOk(match: RegExpExecArray, view: ProseView, prefi
|
|
|
781
782
|
return NUM_BODY_RE.test(view.text.slice(numStart, truncatedEnd))
|
|
782
783
|
}
|
|
783
784
|
|
|
784
|
-
const MINUS_BEFORE_CLASS_RE = new RegExp(`[\\s("'${LEFT_DOUBLE_QUOTE}${RIGHT_DOUBLE_QUOTE}${LEFT_SINGLE_QUOTE}${RIGHT_SINGLE_QUOTE}]`)
|
|
785
|
+
const MINUS_BEFORE_CLASS_RE = new RegExp(`[\\s("'${LEFT_DOUBLE_QUOTE}${RIGHT_DOUBLE_QUOTE}${LEFT_SINGLE_QUOTE}${RIGHT_SINGLE_QUOTE}${LEFT_GUILLEMET}]`)
|
|
785
786
|
// The folded word glyphs block like the word chars they fold from ("1x|-0"
|
|
786
787
|
// and "1×|-0" must agree), the folded math operators block like the operator
|
|
787
788
|
// chars they fold from (the `!=` → `≠` fold consumes the `=` that blocked
|
|
@@ -860,7 +861,7 @@ function convertParentheticalDashes(view: ProseView, style: DashStyle): void {
|
|
|
860
861
|
if (style === "british") convertUnspacedEmDashes(view, rendered)
|
|
861
862
|
}
|
|
862
863
|
|
|
863
|
-
const QUOTE_CHARS = `"'${LEFT_DOUBLE_QUOTE}${RIGHT_DOUBLE_QUOTE}${LEFT_SINGLE_QUOTE}${RIGHT_SINGLE_QUOTE}`
|
|
864
|
+
const QUOTE_CHARS = `"'${LEFT_DOUBLE_QUOTE}${RIGHT_DOUBLE_QUOTE}${LEFT_SINGLE_QUOTE}${RIGHT_SINGLE_QUOTE}${MODIFIER_LETTER_APOSTROPHE}`
|
|
864
865
|
|
|
865
866
|
const DASH_RUN_CHARS = new Set(["-", EN_DASH, EM_DASH])
|
|
866
867
|
|
package/src/html.ts
CHANGED
|
@@ -1,27 +1,59 @@
|
|
|
1
|
+
import { Tokenizer } from "parse5"
|
|
1
2
|
import { unified } from "unified"
|
|
2
3
|
import rehypeParse from "rehype-parse"
|
|
3
4
|
import rehypeStringify from "rehype-stringify"
|
|
4
5
|
import QuickLRU from "quick-lru"
|
|
5
6
|
|
|
6
7
|
import { rehypePunctilio, type RehypePunctilioOptions } from "./rehype.js"
|
|
7
|
-
import {
|
|
8
|
-
import { assertKnownOptionKeys, stableStringify } from "./utils.js"
|
|
8
|
+
import { HTML_OPTION_KEYS } from "./sink-option-keys.js"
|
|
9
|
+
import { assertKnownOptionKeys, omitKeys, stableStringify, transformPreservingBom } from "./utils.js"
|
|
10
|
+
|
|
11
|
+
export { HTML_ONLY_OPTION_KEYS, HTML_OPTION_KEYS } from "./sink-option-keys.js"
|
|
9
12
|
|
|
10
13
|
export interface HtmlOptions extends RehypePunctilioOptions {
|
|
11
14
|
/**
|
|
12
15
|
* Whether to parse the input as an HTML fragment (no implicit `<html>`,
|
|
13
16
|
* `<head>`, or `<body>` wrapping). Set to `false` to parse a complete
|
|
14
|
-
* document.
|
|
17
|
+
* document. When unset, input that begins with `<!doctype` or `<html`
|
|
18
|
+
* (after optional whitespace and comments) is parsed as a complete
|
|
19
|
+
* document and anything else as a fragment.
|
|
15
20
|
*/
|
|
16
21
|
fragment?: boolean
|
|
17
22
|
}
|
|
18
23
|
|
|
19
|
-
|
|
24
|
+
/** True when the first token that is not whitespace or a comment is a doctype
|
|
25
|
+
* or an `<html>` start tag. Stops tokenizing at that token. */
|
|
26
|
+
function startsFullDocument(input: string): boolean {
|
|
27
|
+
let isFullDocument: boolean | undefined
|
|
28
|
+
// Pausing takes effect after the current token, so a token the tokenizer
|
|
29
|
+
// emits in the same step (a doctype flushed behind text) must not overwrite
|
|
30
|
+
// the first verdict.
|
|
31
|
+
const decide = (verdict: boolean): void => {
|
|
32
|
+
if (isFullDocument !== undefined) return
|
|
33
|
+
isFullDocument = verdict
|
|
34
|
+
tokenizer.pause()
|
|
35
|
+
}
|
|
36
|
+
const tokenizer = new Tokenizer(
|
|
37
|
+
{},
|
|
38
|
+
{
|
|
39
|
+
onComment: () => {},
|
|
40
|
+
onWhitespaceCharacter: () => {},
|
|
41
|
+
onEof: () => {},
|
|
42
|
+
onDoctype: () => decide(true),
|
|
43
|
+
onStartTag: (token) => decide(token.tagName === "html"),
|
|
44
|
+
onEndTag: () => decide(false),
|
|
45
|
+
onCharacter: () => decide(false),
|
|
46
|
+
onNullCharacter: () => decide(false),
|
|
47
|
+
},
|
|
48
|
+
)
|
|
49
|
+
tokenizer.write(input, true)
|
|
50
|
+
return isFullDocument === true
|
|
51
|
+
}
|
|
20
52
|
|
|
21
|
-
function createProcessor(options: HtmlOptions) {
|
|
22
|
-
const
|
|
53
|
+
function createProcessor(options: HtmlOptions, fragment: boolean) {
|
|
54
|
+
const punctilioOptions = omitKeys(options, ["fragment"])
|
|
23
55
|
return unified()
|
|
24
|
-
.use(rehypeParse, { fragment
|
|
56
|
+
.use(rehypeParse, { fragment })
|
|
25
57
|
.use(rehypePunctilio, punctilioOptions)
|
|
26
58
|
.use(rehypeStringify)
|
|
27
59
|
}
|
|
@@ -42,23 +74,32 @@ export async function transformHtml(
|
|
|
42
74
|
options: HtmlOptions = {}
|
|
43
75
|
): Promise<string> {
|
|
44
76
|
assertKnownOptionKeys(options, HTML_OPTION_KEYS, "transformHtml")
|
|
77
|
+
return transformPreservingBom(input, (text) => processHtml(text, options))
|
|
78
|
+
}
|
|
45
79
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
return String(result)
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
const optionsKey = stableStringify(options)
|
|
80
|
+
/** Function-valued options aren't JSON-serializable, so the cache key
|
|
81
|
+
* can't distinguish them; those bypass the cache to avoid returning a
|
|
82
|
+
* processor wired to a different shouldSkipText. */
|
|
83
|
+
function getProcessor(options: HtmlOptions, fragment: boolean) {
|
|
84
|
+
if (typeof options.shouldSkipText === "function") return createProcessor(options, fragment)
|
|
55
85
|
|
|
86
|
+
const optionsKey = stableStringify({ ...options, fragment })
|
|
56
87
|
let processor = processorCache.get(optionsKey)
|
|
57
88
|
if (!processor) {
|
|
58
|
-
processor = createProcessor(options)
|
|
89
|
+
processor = createProcessor(options, fragment)
|
|
59
90
|
processorCache.set(optionsKey, processor)
|
|
60
91
|
}
|
|
92
|
+
return processor
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// The HTML parser files whitespace after `</html>` inside <body>. Moving the
|
|
96
|
+
// input's final newline back out keeps repeated runs from adding a newline
|
|
97
|
+
// inside <body> each time.
|
|
98
|
+
const NEWLINE_INSIDE_DOCUMENT_END_RE = /\n(?<documentEnd><\/body><\/html>)$/
|
|
61
99
|
|
|
62
|
-
|
|
63
|
-
|
|
100
|
+
async function processHtml(input: string, options: HtmlOptions): Promise<string> {
|
|
101
|
+
const fragment = options.fragment ?? !startsFullDocument(input)
|
|
102
|
+
const output = String(await getProcessor(options, fragment).process(input))
|
|
103
|
+
if (fragment || !input.endsWith("\n")) return output
|
|
104
|
+
return output.replace(NEWLINE_INSIDE_DOCUMENT_END_RE, "$<documentEnd>\n")
|
|
64
105
|
}
|
package/src/markdown.ts
CHANGED
|
@@ -4,9 +4,11 @@ import remarkGfm from "remark-gfm"
|
|
|
4
4
|
import remarkStringify from "remark-stringify"
|
|
5
5
|
import QuickLRU from "quick-lru"
|
|
6
6
|
|
|
7
|
+
import { MARKDOWN_OPTION_KEYS } from "./sink-option-keys.js"
|
|
7
8
|
import { remarkPunctilio, type RemarkPunctilioOptions } from "./remark.js"
|
|
8
|
-
import {
|
|
9
|
-
|
|
9
|
+
import { assertKnownOptionKeys, stableStringify, transformPreservingBom } from "./utils.js"
|
|
10
|
+
|
|
11
|
+
export { MARKDOWN_ONLY_OPTION_KEYS, MARKDOWN_OPTION_KEYS } from "./sink-option-keys.js"
|
|
10
12
|
|
|
11
13
|
/**
|
|
12
14
|
* Options for `transformMarkdown`. Inherits the Markdown-sink default of
|
|
@@ -33,8 +35,6 @@ export interface MarkdownOptions extends RemarkPunctilioOptions {
|
|
|
33
35
|
ruleMarker?: "-" | "*" | "_"
|
|
34
36
|
}
|
|
35
37
|
|
|
36
|
-
export { MARKDOWN_ONLY_OPTION_KEYS, MARKDOWN_OPTION_KEYS }
|
|
37
|
-
|
|
38
38
|
function createProcessor(options: MarkdownOptions) {
|
|
39
39
|
const {
|
|
40
40
|
emphasisMarker,
|
|
@@ -74,12 +74,41 @@ export function clearProcessorCache(): void {
|
|
|
74
74
|
processorCache.clear()
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
77
|
+
// Opening fence → fences that may close the block (YAML accepts `...` too).
|
|
78
|
+
// A Map, so a first line like `constructor` cannot resolve to an inherited
|
|
79
|
+
// Object.prototype member.
|
|
80
|
+
const FRONTMATTER_CLOSING_FENCES: ReadonlyMap<string, readonly string[]> = new Map([
|
|
81
|
+
["---", ["---", "..."]],
|
|
82
|
+
["+++", ["+++"]],
|
|
83
|
+
])
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Length of the YAML (`---`) or TOML (`+++`) front matter block at the very
|
|
87
|
+
* start of `input`, through its closing fence line and newline. 0 when the
|
|
88
|
+
* input has no terminated front matter block.
|
|
89
|
+
*/
|
|
90
|
+
function frontmatterLength(input: string): number {
|
|
91
|
+
let lineStart = 0
|
|
92
|
+
let closingFences: readonly string[] | undefined
|
|
93
|
+
while (lineStart < input.length) {
|
|
94
|
+
const newlineIndex = input.indexOf("\n", lineStart)
|
|
95
|
+
const lineEnd = newlineIndex === -1 ? input.length : newlineIndex + 1
|
|
96
|
+
const fence = input.slice(lineStart, lineEnd).trimEnd()
|
|
97
|
+
if (closingFences === undefined) {
|
|
98
|
+
closingFences = FRONTMATTER_CLOSING_FENCES.get(fence)
|
|
99
|
+
if (closingFences === undefined) return 0
|
|
100
|
+
} else if (closingFences.includes(fence)) {
|
|
101
|
+
return lineEnd
|
|
102
|
+
}
|
|
103
|
+
lineStart = lineEnd
|
|
104
|
+
}
|
|
105
|
+
return 0
|
|
106
|
+
}
|
|
82
107
|
|
|
108
|
+
/** Matches blank lines that separate front matter from the document body. */
|
|
109
|
+
const LEADING_BLANK_LINES_RE = /^(?:[ \t]*\r?\n)*/
|
|
110
|
+
|
|
111
|
+
async function processMarkdown(input: string, options: MarkdownOptions): Promise<string> {
|
|
83
112
|
const optionsKey = stableStringify(options)
|
|
84
113
|
|
|
85
114
|
let processor = processorCache.get(optionsKey)
|
|
@@ -91,3 +120,23 @@ export async function transformMarkdown(
|
|
|
91
120
|
const result = await processor.process(input)
|
|
92
121
|
return String(result)
|
|
93
122
|
}
|
|
123
|
+
|
|
124
|
+
export async function transformMarkdown(
|
|
125
|
+
input: string,
|
|
126
|
+
options: MarkdownOptions = {}
|
|
127
|
+
): Promise<string> {
|
|
128
|
+
assertKnownOptionKeys(options, MARKDOWN_OPTION_KEYS, "transformMarkdown")
|
|
129
|
+
return transformPreservingBom(input, (text) => transformWithFrontmatter(text, options))
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function transformWithFrontmatter(input: string, options: MarkdownOptions): Promise<string> {
|
|
133
|
+
const frontmatterEnd = frontmatterLength(input)
|
|
134
|
+
if (frontmatterEnd === 0) return processMarkdown(input, options)
|
|
135
|
+
|
|
136
|
+
// Front matter is data (YAML/TOML), not prose; remark would parse it as a
|
|
137
|
+
// thematic break plus a heading and rewrite it.
|
|
138
|
+
const body = input.slice(frontmatterEnd)
|
|
139
|
+
const blankLines = LEADING_BLANK_LINES_RE.exec(body)![0]
|
|
140
|
+
const transformedBody = await processMarkdown(body.slice(blankLines.length), options)
|
|
141
|
+
return input.slice(0, frontmatterEnd) + blankLines + transformedBody
|
|
142
|
+
}
|