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.
Files changed (62) hide show
  1. package/README.md +12 -2
  2. package/dist/cli.d.ts +5 -0
  3. package/dist/cli.d.ts.map +1 -1
  4. package/dist/cli.js +156 -48
  5. package/dist/cli.js.map +1 -1
  6. package/dist/constants.d.ts +2 -0
  7. package/dist/constants.d.ts.map +1 -1
  8. package/dist/constants.js +3 -1
  9. package/dist/constants.js.map +1 -1
  10. package/dist/dashes.d.ts.map +1 -1
  11. package/dist/dashes.js +3 -3
  12. package/dist/dashes.js.map +1 -1
  13. package/dist/html.d.ts +4 -3
  14. package/dist/html.d.ts.map +1 -1
  15. package/dist/html.js +55 -17
  16. package/dist/html.js.map +1 -1
  17. package/dist/markdown.d.ts +1 -2
  18. package/dist/markdown.d.ts.map +1 -1
  19. package/dist/markdown.js +52 -5
  20. package/dist/markdown.js.map +1 -1
  21. package/dist/nbsp.d.ts.map +1 -1
  22. package/dist/nbsp.js +26 -39
  23. package/dist/nbsp.js.map +1 -1
  24. package/dist/peer-dependencies.d.ts +21 -0
  25. package/dist/peer-dependencies.d.ts.map +1 -0
  26. package/dist/peer-dependencies.js +40 -0
  27. package/dist/peer-dependencies.js.map +1 -0
  28. package/dist/prettier-plugin.d.ts.map +1 -1
  29. package/dist/prettier-plugin.js +1 -2
  30. package/dist/prettier-plugin.js.map +1 -1
  31. package/dist/prose-view.d.ts +7 -0
  32. package/dist/prose-view.d.ts.map +1 -1
  33. package/dist/prose-view.js +11 -1
  34. package/dist/prose-view.js.map +1 -1
  35. package/dist/quote-classifier.d.ts.map +1 -1
  36. package/dist/quote-classifier.js +67 -20
  37. package/dist/quote-classifier.js.map +1 -1
  38. package/dist/symbols.d.ts.map +1 -1
  39. package/dist/symbols.js +7 -3
  40. package/dist/symbols.js.map +1 -1
  41. package/dist/transform-options.d.ts +3 -2
  42. package/dist/transform-options.d.ts.map +1 -1
  43. package/dist/transform-options.js +6 -0
  44. package/dist/transform-options.js.map +1 -1
  45. package/dist/utils.d.ts +5 -0
  46. package/dist/utils.d.ts.map +1 -1
  47. package/dist/utils.js +10 -0
  48. package/dist/utils.js.map +1 -1
  49. package/package.json +4 -2
  50. package/src/cli.ts +180 -48
  51. package/src/constants.ts +4 -1
  52. package/src/dashes.ts +3 -2
  53. package/src/html.ts +60 -19
  54. package/src/markdown.ts +58 -9
  55. package/src/nbsp.ts +26 -40
  56. package/src/peer-dependencies.ts +58 -0
  57. package/src/prettier-plugin.ts +1 -2
  58. package/src/prose-view.ts +12 -1
  59. package/src/quote-classifier.ts +68 -19
  60. package/src/symbols.ts +8 -4
  61. package/src/transform-options.ts +11 -2
  62. 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 { HTML_ONLY_OPTION_KEYS, HTML_OPTION_KEYS, MARKDOWN_ONLY_OPTION_KEYS, MARKDOWN_OPTION_KEYS } from "./sink-option-keys.js"
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 input as a full HTML document
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
- ...new Set([...MARKDOWN_OPTION_KEYS, ...HTML_OPTION_KEYS]),
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
- assertKnownOptionKeys(result.config as object, CLI_CONFIG_KEYS, `config file ${result.filepath}`)
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 as CliOptions
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
- // Literal positionals pass through untouched so they error loudly if missing and
277
- // bypass ignore rules (naming a file explicitly is an intent to format it).
278
- // Ignore rules and the zero-match warning apply only to glob-expanded paths.
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 expanded = globs.length > 0
288
- ? await glob(globs, { cwd, absolute: true, onlyFiles: true })
289
- : []
290
- const kept = expanded.filter((file) => {
291
- const rel = relative(cwd, file)
292
- return rel.startsWith("..") || !ig.ignores(rel)
293
- })
294
- if (globs.length > 0 && kept.length === 0) {
295
- stderr.write(`Warning: no files matched: ${globs.join(" ")}\n`)
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
- return [...new Set([...literals.map((p) => resolve(cwd, p)), ...kept])]
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
- async function atomicWriteFile(path: string, contents: string): Promise<void> {
351
- const tmpPath = `${path}.${process.pid}.tmp`
352
- await writeFile(tmpPath, contents)
353
- await rename(tmpPath, path)
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. The sinks
386
- // load lazily because they import the optional unified peers, which --help,
387
- // --version and usage errors must not need.
388
- async function transformContent(input: string, type: FileType, opts: CliOptions): Promise<string> {
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 { transformMarkdown } = await import("./markdown.js")
496
+ const transformMarkdown = await loadSinkTransform("md", importPeer)
391
497
  return transformMarkdown(input, omitKeys(opts, HTML_ONLY_OPTION_KEYS) as MarkdownOptions)
392
498
  }
393
- const { transformHtml } = await import("./html.js")
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
- // The unified pipelines emit LF-only output and unconditionally append a
398
- // trailing newline. Restore the source's line-ending style and trailing-newline
399
- // presence so a CRLF file doesn't gain mixed endings (which would make --check
400
- // report a change forever and --write churn on every run).
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
- // Normalize the LF output back to CRLF when the source used it.
403
- let out = original.includes("\r\n")
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
- // Drop the appended trailing newline if the original had none.
407
- if (!/\n$/.test(original)) out = out.replace(/\r?\n$/, "")
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 = matchLineEndings(input, await transformContent(input, type, opts))
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 = inferFileType(path, flags.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 = matchLineEndings(original, await transformContent(original, type, opts))
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
- // npm runs the bin through a node_modules/.bin symlink, so compare real paths.
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
- ...BRACKET_PAIRS.map(([opening]) => opening),
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 { HTML_ONLY_OPTION_KEYS, HTML_OPTION_KEYS } from "./sink-option-keys.js"
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. Default: `true`.
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
- export { HTML_ONLY_OPTION_KEYS, HTML_OPTION_KEYS }
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 { fragment, ...punctilioOptions } = options
53
+ function createProcessor(options: HtmlOptions, fragment: boolean) {
54
+ const punctilioOptions = omitKeys(options, ["fragment"])
23
55
  return unified()
24
- .use(rehypeParse, { fragment: fragment ?? true })
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
- // Function-valued options aren't JSON-serializable, so the cache key
47
- // can't distinguish them. Bypass the cache to avoid returning a
48
- // processor wired to a different shouldSkipText.
49
- if (typeof options.shouldSkipText === "function") {
50
- const result = await createProcessor(options).process(input)
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
- const result = await processor.process(input)
63
- return String(result)
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 { MARKDOWN_ONLY_OPTION_KEYS, MARKDOWN_OPTION_KEYS } from "./sink-option-keys.js"
9
- import { assertKnownOptionKeys, stableStringify } from "./utils.js"
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
- export async function transformMarkdown(
78
- input: string,
79
- options: MarkdownOptions = {}
80
- ): Promise<string> {
81
- assertKnownOptionKeys(options, MARKDOWN_OPTION_KEYS, "transformMarkdown")
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
+ }