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/README.md +11 -1
- 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/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/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/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 +2 -1
- package/src/cli.ts +180 -48
- package/src/html.ts +60 -19
- package/src/markdown.ts +58 -9
- package/src/peer-dependencies.ts +58 -0
- package/src/prettier-plugin.ts +1 -2
- package/src/utils.ts +14 -0
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
|
+
}
|
|
@@ -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
|
+
}
|
package/src/prettier-plugin.ts
CHANGED
|
@@ -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 "./
|
|
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> = {}
|