punctilio 5.4.8 → 5.4.9

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/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
+ }
@@ -0,0 +1,58 @@
1
+ import type { HtmlOptions } from "./html.js"
2
+ import type { MarkdownOptions } from "./markdown.js"
3
+
4
+ export type SinkType = "md" | "html"
5
+
6
+ /** Imports a bare package specifier; injectable so tests can simulate an uninstalled peer. */
7
+ export type PeerImporter = (packageName: string) => Promise<unknown>
8
+
9
+ export interface SinkTransformers {
10
+ md: (input: string, options: MarkdownOptions) => Promise<string>
11
+ html: (input: string, options: HtmlOptions) => Promise<string>
12
+ }
13
+
14
+ /** The optional peer dependencies each sink needs at runtime. */
15
+ export const SINK_PEER_DEPENDENCIES: Readonly<Record<SinkType, readonly string[]>> = {
16
+ md: ["unified", "remark-parse", "remark-gfm", "remark-stringify"],
17
+ html: ["unified", "rehype-parse", "rehype-stringify"],
18
+ }
19
+
20
+ /** Thrown when an optional peer dependency needed for the requested file type is not installed. */
21
+ export class MissingPeerDependencyError extends Error {}
22
+
23
+ const MODULE_NOT_FOUND_CODES = new Set(["ERR_MODULE_NOT_FOUND", "MODULE_NOT_FOUND"])
24
+
25
+ function isMissingPackage(err: unknown, packageName: string): boolean {
26
+ const { code, message } = err as NodeJS.ErrnoException
27
+ return MODULE_NOT_FOUND_CODES.has(code ?? "") && message.includes(`'${packageName}'`)
28
+ }
29
+
30
+ const importPackage: PeerImporter = (packageName) => import(packageName)
31
+
32
+ /**
33
+ * Lazily loads the transform for `type`, so only the peers for the file type
34
+ * being processed need to be installed.
35
+ * @throws MissingPeerDependencyError naming every absent peer and the install command.
36
+ */
37
+ export async function loadSinkTransform<T extends SinkType>(
38
+ type: T,
39
+ importPeer: PeerImporter = importPackage,
40
+ ): Promise<SinkTransformers[T]> {
41
+ const missing: string[] = []
42
+ for (const packageName of SINK_PEER_DEPENDENCIES[type]) {
43
+ try {
44
+ await importPeer(packageName)
45
+ } catch (err) {
46
+ if (!isMissingPackage(err, packageName)) throw err
47
+ missing.push(packageName)
48
+ }
49
+ }
50
+ if (missing.length > 0) {
51
+ throw new MissingPeerDependencyError(
52
+ `Processing ${type === "md" ? "Markdown" : "HTML"} files requires ${missing.join(", ")}, which ` +
53
+ `${missing.length === 1 ? "is" : "are"} not installed. Install with: npm install ${missing.join(" ")}`,
54
+ )
55
+ }
56
+ if (type === "md") return (await import("./markdown.js")).transformMarkdown as SinkTransformers[T]
57
+ return (await import("./html.js")).transformHtml as SinkTransformers[T]
58
+ }
@@ -5,8 +5,7 @@ import type { Parser, Plugin } from "prettier"
5
5
  import type { Root } from "mdast"
6
6
  import { cosmiconfig } from "cosmiconfig"
7
7
 
8
- import { HTML_ONLY_OPTION_KEYS } from "./html.js"
9
- import { MARKDOWN_ONLY_OPTION_KEYS } from "./markdown.js"
8
+ import { HTML_ONLY_OPTION_KEYS, MARKDOWN_ONLY_OPTION_KEYS } from "./sink-option-keys.js"
10
9
  import { remarkPunctilio, type RemarkPunctilioOptions } from "./remark.js"
11
10
  import { omitKeys } from "./utils.js"
12
11
 
package/src/utils.ts CHANGED
@@ -41,6 +41,20 @@ export function assertKnownOptionKeys(
41
41
  }
42
42
  }
43
43
 
44
+ const BYTE_ORDER_MARK = "\uFEFF"
45
+
46
+ /**
47
+ * Runs `transform` on `input` without its leading BOM, which would otherwise
48
+ * be parsed as content, and restores the BOM on the result. @internal
49
+ */
50
+ export async function transformPreservingBom(
51
+ input: string,
52
+ transform: (inputWithoutBom: string) => Promise<string>,
53
+ ): Promise<string> {
54
+ if (!input.startsWith(BYTE_ORDER_MARK)) return transform(input)
55
+ return BYTE_ORDER_MARK + (await transform(input.slice(BYTE_ORDER_MARK.length)))
56
+ }
57
+
44
58
  /** Returns a copy of `obj` without the listed keys. @internal */
45
59
  export function omitKeys<T extends object>(obj: T, keys: readonly string[]): Partial<T> {
46
60
  const result: Record<string, unknown> = {}