@markii/core 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/corpus.js +1 -1
- package/dist/frontmatter.d.ts +50 -0
- package/dist/frontmatter.js +178 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +3 -2
- package/dist/parse.d.ts +12 -0
- package/dist/parse.js +14 -0
- package/dist/scripts.d.ts +68 -4
- package/dist/scripts.js +83 -4
- package/dist/to-hast.d.ts +39 -0
- package/dist/to-hast.js +120 -7
- package/package.json +4 -3
package/dist/corpus.js
CHANGED
|
@@ -3,7 +3,7 @@ import { dirname, join } from 'node:path';
|
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
/**
|
|
5
5
|
* The language-agnostic conformance corpus: `*.mk.md` inputs plus
|
|
6
|
-
* expected-AST `*.json` siblings, living at the repo root (see
|
|
6
|
+
* expected-AST `*.json` siblings, living at the repo root (see docs/spec.md
|
|
7
7
|
* §13). This module is deliberately parser-agnostic — it only
|
|
8
8
|
* loads/compares corpus data — so it's usable from any package's tests (or
|
|
9
9
|
* a future non-JS implementation's tooling), not just `@markii/core`'s own
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { Root } from 'mdast';
|
|
2
|
+
/**
|
|
3
|
+
* A document's leading YAML frontmatter block, as far as this package cares
|
|
4
|
+
* about it: the raw text between the `---` fences, plus the one
|
|
5
|
+
* format-defined key (`uses`) when it is written in one of the two simple
|
|
6
|
+
* list forms this module understands.
|
|
7
|
+
*
|
|
8
|
+
* `raw` is the block's exact source text, unparsed — a host that genuinely
|
|
9
|
+
* needs arbitrary YAML metadata (its own `title`, `tags`, ...) brings its own
|
|
10
|
+
* YAML parser and reads this string. `@markii/core` deliberately does not:
|
|
11
|
+
* see `readUses` below.
|
|
12
|
+
*/
|
|
13
|
+
export interface Frontmatter {
|
|
14
|
+
/** The raw YAML text between the `---` fences (no fences, no trailing newline). */
|
|
15
|
+
raw: string;
|
|
16
|
+
/**
|
|
17
|
+
* Pack names from a `uses:` list written in one of the two supported
|
|
18
|
+
* forms, or `undefined` when the key is absent, is written in any other
|
|
19
|
+
* shape, or contains anything that is not a plain name. An empty list
|
|
20
|
+
* (`uses: []`) is `[]` — "declared, and it names no packs" — which is
|
|
21
|
+
* distinguishable from `undefined` ("no usable declaration").
|
|
22
|
+
*/
|
|
23
|
+
uses?: string[];
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The document's leading YAML frontmatter, or `undefined` when it has none.
|
|
27
|
+
*
|
|
28
|
+
* Accepts either the source text or an already-parsed tree, so a caller that
|
|
29
|
+
* has parsed the document already (the common case for a renderer) pays no
|
|
30
|
+
* second parse. Recognition is the PARSER's, not this module's: frontmatter
|
|
31
|
+
* is whatever `parse` produced as a leading `yaml` node, so the accessor and
|
|
32
|
+
* the renderer can never disagree about whether a `---` block at the top of
|
|
33
|
+
* a file was metadata or a thematic break.
|
|
34
|
+
*
|
|
35
|
+
* Never throws — `uses` simply degrades to `undefined` for anything beyond
|
|
36
|
+
* the two list forms `readUses` documents. A tree that did not come from
|
|
37
|
+
* this package's `parse` (a host that transforms the AST, or hand-builds
|
|
38
|
+
* one) is treated the same way: a leading node that merely CLAIMS to be
|
|
39
|
+
* `yaml` without carrying string content is no frontmatter at all, rather
|
|
40
|
+
* than a `raw.split` on a number.
|
|
41
|
+
*/
|
|
42
|
+
export declare function extractFrontmatter(source: string | Root): Frontmatter | undefined;
|
|
43
|
+
/**
|
|
44
|
+
* Convenience wrapper over `extractFrontmatter`: just the `uses` list, or
|
|
45
|
+
* `undefined` when the document has no frontmatter, no `uses:` key, or a
|
|
46
|
+
* `uses:` value outside the two supported list forms. `uses` semantics
|
|
47
|
+
* (surfacing "pack not installed" in a host's UI) live with the host — this
|
|
48
|
+
* is only the reader.
|
|
49
|
+
*/
|
|
50
|
+
export declare function extractFrontmatterUses(source: string | Root): string[] | undefined;
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { parse } from './parse.js';
|
|
2
|
+
/**
|
|
3
|
+
* The one format-defined frontmatter key (docs/format.md, docs/packs.md), as
|
|
4
|
+
* a top-level `uses:` line: the key at column 0 (so a `uses:` nested under
|
|
5
|
+
* some other mapping is deliberately invisible to this reader), optional
|
|
6
|
+
* spacing, then whatever follows the colon on the same line.
|
|
7
|
+
*/
|
|
8
|
+
const USES_LINE = /^uses[ \t]*:(.*)$/;
|
|
9
|
+
/** One block-sequence item line: `- name`, capturing its indent and value. */
|
|
10
|
+
const LIST_ITEM_LINE = /^([ \t]*)-[ \t]+(.*)$/;
|
|
11
|
+
/**
|
|
12
|
+
* What a pack name may look like once quotes are stripped. Intentionally
|
|
13
|
+
* narrow: alphanumerics, `-`, `_`, `.` only (docs/packs.md — a namespace is
|
|
14
|
+
* a plain word, joined to a component name with `-` or `_`). Anything else —
|
|
15
|
+
* whitespace, `:`, `#`, brackets, braces, commas, a stray quote — means the
|
|
16
|
+
* value is not the simple scalar this reader claims to understand, so the
|
|
17
|
+
* whole list degrades to `undefined` rather than being half-read.
|
|
18
|
+
*
|
|
19
|
+
* A name like `__proto__` or `constructor` passes, and that is deliberate:
|
|
20
|
+
* this reader returns STRINGS in an array, never object keys, so a
|
|
21
|
+
* prototype-flavored name is ordinary data here. The registry lookups that
|
|
22
|
+
* eventually consume such a name are themselves null-prototype and
|
|
23
|
+
* `Object.hasOwn`-guarded (`@markii/react`'s `registry.ts`), which is where
|
|
24
|
+
* that class of defense belongs — not in a name filter that would have to
|
|
25
|
+
* guess every hostile spelling.
|
|
26
|
+
*/
|
|
27
|
+
const PACK_NAME = /^[A-Za-z0-9_][A-Za-z0-9._-]*$/;
|
|
28
|
+
/**
|
|
29
|
+
* Reads one flow-sequence or block-sequence item: trims it, strips ONE pair
|
|
30
|
+
* of matching surrounding quotes (`'a'` / `"a"` — YAML's two quoting styles,
|
|
31
|
+
* used here only to unwrap a name, never to interpret escapes), and returns
|
|
32
|
+
* it only if what remains is a plain pack name. Returns `undefined` for
|
|
33
|
+
* anything else, including an opening quote with no matching close.
|
|
34
|
+
*/
|
|
35
|
+
function readItem(text) {
|
|
36
|
+
const trimmed = text.trim();
|
|
37
|
+
if (trimmed === '')
|
|
38
|
+
return undefined;
|
|
39
|
+
const quote = trimmed[0];
|
|
40
|
+
let value = trimmed;
|
|
41
|
+
if (quote === '"' || quote === "'") {
|
|
42
|
+
if (trimmed.length < 2 || !trimmed.endsWith(quote))
|
|
43
|
+
return undefined;
|
|
44
|
+
value = trimmed.slice(1, -1);
|
|
45
|
+
}
|
|
46
|
+
return PACK_NAME.test(value) ? value : undefined;
|
|
47
|
+
}
|
|
48
|
+
/** Maps `readItem` over every raw item, failing the WHOLE list if any item fails. */
|
|
49
|
+
function readItems(rawItems) {
|
|
50
|
+
const values = [];
|
|
51
|
+
for (const rawItem of rawItems) {
|
|
52
|
+
const value = readItem(rawItem);
|
|
53
|
+
if (value === undefined)
|
|
54
|
+
return undefined;
|
|
55
|
+
values.push(value);
|
|
56
|
+
}
|
|
57
|
+
return values;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Reads the flow form — `uses: [a, b]` — from the text that follows the
|
|
61
|
+
* colon. The text must be exactly a bracketed sequence: a trailing comment
|
|
62
|
+
* (`uses: [a] # note`) or any other suffix is NOT this form and yields
|
|
63
|
+
* `undefined`, since recognizing it would mean tokenizing YAML rather than
|
|
64
|
+
* matching one fixed shape. `[]` yields the empty list.
|
|
65
|
+
*/
|
|
66
|
+
function readFlowSequence(text) {
|
|
67
|
+
if (!text.startsWith('[') || !text.endsWith(']'))
|
|
68
|
+
return undefined;
|
|
69
|
+
const inner = text.slice(1, -1).trim();
|
|
70
|
+
if (inner === '')
|
|
71
|
+
return [];
|
|
72
|
+
return readItems(inner.split(','));
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Reads the block form — a bare `uses:` followed by `- name` lines — from
|
|
76
|
+
* the lines after the key. Items must be contiguous (the first line that is
|
|
77
|
+
* not an item ends the sequence, blank lines included) and share one indent,
|
|
78
|
+
* which is what keeps a differently-indented continuation of some richer
|
|
79
|
+
* YAML structure from being mistaken for a flat list. No items, or a
|
|
80
|
+
* ragged/uneven sequence, yields `undefined`.
|
|
81
|
+
*/
|
|
82
|
+
function readBlockSequence(lines, start) {
|
|
83
|
+
const rawItems = [];
|
|
84
|
+
let indent;
|
|
85
|
+
for (const line of lines.slice(start)) {
|
|
86
|
+
const match = LIST_ITEM_LINE.exec(line);
|
|
87
|
+
const [, lineIndent, item] = match ?? [];
|
|
88
|
+
if (lineIndent === undefined || item === undefined)
|
|
89
|
+
break;
|
|
90
|
+
if (indent === undefined)
|
|
91
|
+
indent = lineIndent;
|
|
92
|
+
else if (lineIndent !== indent)
|
|
93
|
+
return undefined;
|
|
94
|
+
rawItems.push(item);
|
|
95
|
+
}
|
|
96
|
+
return rawItems.length > 0 ? readItems(rawItems) : undefined;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* The hand-rolled `uses:` reader — the whole reason `@markii/core` needs no
|
|
100
|
+
* YAML library, exactly as `@markii/bundle`'s `manifest.ts` needs no schema
|
|
101
|
+
* library. It recognizes TWO shapes and nothing else:
|
|
102
|
+
*
|
|
103
|
+
* ```yaml
|
|
104
|
+
* uses: [ana, gh] # flow sequence
|
|
105
|
+
* uses: # block sequence
|
|
106
|
+
* - ana
|
|
107
|
+
* - gh
|
|
108
|
+
* ```
|
|
109
|
+
*
|
|
110
|
+
* quotes and surrounding whitespace tolerated in both. Every other input —
|
|
111
|
+
* a `uses:` holding a scalar or a nested mapping, a duplicated `uses:` key,
|
|
112
|
+
* an indented (non-top-level) `uses:`, a value this reader cannot recognize,
|
|
113
|
+
* or frontmatter that is not valid YAML at all — returns `undefined`. It
|
|
114
|
+
* NEVER throws and never returns a partially-read list: `uses` is an
|
|
115
|
+
* informative hint (docs/packs.md — "this note uses pack `ana`, which is not
|
|
116
|
+
* installed"), so "I could not read this" and "there is nothing here" are
|
|
117
|
+
* the same, harmless answer, while a half-read list would produce a wrong
|
|
118
|
+
* warning about packs the note never named.
|
|
119
|
+
*/
|
|
120
|
+
function readUses(raw) {
|
|
121
|
+
const lines = raw.split(/\r?\n/);
|
|
122
|
+
let keyIndex = -1;
|
|
123
|
+
let rest = '';
|
|
124
|
+
for (const [index, line] of lines.entries()) {
|
|
125
|
+
const [, value] = USES_LINE.exec(line) ?? [];
|
|
126
|
+
if (value === undefined)
|
|
127
|
+
continue;
|
|
128
|
+
// A second top-level `uses:` makes the document ambiguous (YAML itself
|
|
129
|
+
// rejects duplicate keys); refuse to guess which one was meant.
|
|
130
|
+
if (keyIndex !== -1)
|
|
131
|
+
return undefined;
|
|
132
|
+
keyIndex = index;
|
|
133
|
+
rest = value;
|
|
134
|
+
}
|
|
135
|
+
if (keyIndex === -1)
|
|
136
|
+
return undefined;
|
|
137
|
+
const inline = rest.trim();
|
|
138
|
+
return inline === ''
|
|
139
|
+
? readBlockSequence(lines, keyIndex + 1)
|
|
140
|
+
: readFlowSequence(inline);
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* The document's leading YAML frontmatter, or `undefined` when it has none.
|
|
144
|
+
*
|
|
145
|
+
* Accepts either the source text or an already-parsed tree, so a caller that
|
|
146
|
+
* has parsed the document already (the common case for a renderer) pays no
|
|
147
|
+
* second parse. Recognition is the PARSER's, not this module's: frontmatter
|
|
148
|
+
* is whatever `parse` produced as a leading `yaml` node, so the accessor and
|
|
149
|
+
* the renderer can never disagree about whether a `---` block at the top of
|
|
150
|
+
* a file was metadata or a thematic break.
|
|
151
|
+
*
|
|
152
|
+
* Never throws — `uses` simply degrades to `undefined` for anything beyond
|
|
153
|
+
* the two list forms `readUses` documents. A tree that did not come from
|
|
154
|
+
* this package's `parse` (a host that transforms the AST, or hand-builds
|
|
155
|
+
* one) is treated the same way: a leading node that merely CLAIMS to be
|
|
156
|
+
* `yaml` without carrying string content is no frontmatter at all, rather
|
|
157
|
+
* than a `raw.split` on a number.
|
|
158
|
+
*/
|
|
159
|
+
export function extractFrontmatter(source) {
|
|
160
|
+
const tree = typeof source === 'string' ? parse(source) : source;
|
|
161
|
+
const first = tree.children[0];
|
|
162
|
+
if (first?.type !== 'yaml' || typeof first.value !== 'string') {
|
|
163
|
+
return undefined;
|
|
164
|
+
}
|
|
165
|
+
const raw = first.value;
|
|
166
|
+
const uses = readUses(raw);
|
|
167
|
+
return uses === undefined ? { raw } : { raw, uses };
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Convenience wrapper over `extractFrontmatter`: just the `uses` list, or
|
|
171
|
+
* `undefined` when the document has no frontmatter, no `uses:` key, or a
|
|
172
|
+
* `uses:` value outside the two supported list forms. `uses` semantics
|
|
173
|
+
* (surfacing "pack not installed" in a host's UI) live with the host — this
|
|
174
|
+
* is only the reader.
|
|
175
|
+
*/
|
|
176
|
+
export function extractFrontmatterUses(source) {
|
|
177
|
+
return extractFrontmatter(source)?.uses;
|
|
178
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
export { parse } from './parse.js';
|
|
2
|
-
export {
|
|
3
|
-
export {
|
|
2
|
+
export { extractFrontmatter, extractFrontmatterUses, type Frontmatter, } from './frontmatter.js';
|
|
3
|
+
export { toHast, nodeToHast, isSafeUrl, type MarkNode } from './to-hast.js';
|
|
4
|
+
export { extractScripts, isBareAttribute, isValidScriptName, parseMetaAttributes, type ScriptBlock, } from './scripts.js';
|
package/dist/index.js
CHANGED
|
@@ -5,5 +5,6 @@
|
|
|
5
5
|
// via `@markii/react`) never has to reason about Node built-ins reachable from
|
|
6
6
|
// its entry point.
|
|
7
7
|
export { parse } from './parse.js';
|
|
8
|
-
export {
|
|
9
|
-
export {
|
|
8
|
+
export { extractFrontmatter, extractFrontmatterUses, } from './frontmatter.js';
|
|
9
|
+
export { toHast, nodeToHast, isSafeUrl } from './to-hast.js';
|
|
10
|
+
export { extractScripts, isBareAttribute, isValidScriptName, parseMetaAttributes, } from './scripts.js';
|
package/dist/parse.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { Root } from 'mdast';
|
|
|
2
2
|
/**
|
|
3
3
|
* Parses Mark text into an mdast AST. CommonMark plus GitHub-Flavored
|
|
4
4
|
* Markdown (`remark-gfm`: tables, task lists, strikethrough, autolinks) plus
|
|
5
|
+
* optional leading YAML frontmatter (`remark-frontmatter`) plus
|
|
5
6
|
* the generic directive grammar (`remark-directive`) — nothing more. GFM and
|
|
6
7
|
* directives are independent micromark syntax extensions (GFM extends the
|
|
7
8
|
* table / list-item / inline grammar; directives add their own `:`/`::`/`:::`
|
|
@@ -13,5 +14,16 @@ import type { Root } from 'mdast';
|
|
|
13
14
|
* and children; interpreting a name is the renderer's job; GFM nodes are
|
|
14
15
|
* standard mdast — `table`, `listItem.checked`, `delete` — needing no
|
|
15
16
|
* component/registry coupling at all).
|
|
17
|
+
*
|
|
18
|
+
* Frontmatter is the YAML preset only (`---` fences; the TOML `+++` form is
|
|
19
|
+
* deliberately not enabled) and, per micromark's frontmatter extension, is
|
|
20
|
+
* recognized ONLY as the very first construct of the document. Everywhere
|
|
21
|
+
* else `---` keeps its ordinary CommonMark meaning — a thematic break, or a
|
|
22
|
+
* setext heading underline — and an unterminated opening `---` degrades to
|
|
23
|
+
* exactly that too, never to an error (conformance fixtures 19-23). The
|
|
24
|
+
* resulting `yaml` node carries the raw block text and nothing more: this
|
|
25
|
+
* module does not interpret YAML, and `@markii/core` depends on no YAML
|
|
26
|
+
* library. `frontmatter.ts`'s hand-rolled accessor reads the one
|
|
27
|
+
* format-defined key (`uses`) off that raw text for the simple list forms.
|
|
16
28
|
*/
|
|
17
29
|
export declare function parse(text: string): Root;
|
package/dist/parse.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { unified } from 'unified';
|
|
2
2
|
import remarkParse from 'remark-parse';
|
|
3
3
|
import remarkDirective from 'remark-directive';
|
|
4
|
+
import remarkFrontmatter from 'remark-frontmatter';
|
|
4
5
|
import remarkGfm from 'remark-gfm';
|
|
5
6
|
/**
|
|
6
7
|
* Parses Mark text into an mdast AST. CommonMark plus GitHub-Flavored
|
|
7
8
|
* Markdown (`remark-gfm`: tables, task lists, strikethrough, autolinks) plus
|
|
9
|
+
* optional leading YAML frontmatter (`remark-frontmatter`) plus
|
|
8
10
|
* the generic directive grammar (`remark-directive`) — nothing more. GFM and
|
|
9
11
|
* directives are independent micromark syntax extensions (GFM extends the
|
|
10
12
|
* table / list-item / inline grammar; directives add their own `:`/`::`/`:::`
|
|
@@ -16,10 +18,22 @@ import remarkGfm from 'remark-gfm';
|
|
|
16
18
|
* and children; interpreting a name is the renderer's job; GFM nodes are
|
|
17
19
|
* standard mdast — `table`, `listItem.checked`, `delete` — needing no
|
|
18
20
|
* component/registry coupling at all).
|
|
21
|
+
*
|
|
22
|
+
* Frontmatter is the YAML preset only (`---` fences; the TOML `+++` form is
|
|
23
|
+
* deliberately not enabled) and, per micromark's frontmatter extension, is
|
|
24
|
+
* recognized ONLY as the very first construct of the document. Everywhere
|
|
25
|
+
* else `---` keeps its ordinary CommonMark meaning — a thematic break, or a
|
|
26
|
+
* setext heading underline — and an unterminated opening `---` degrades to
|
|
27
|
+
* exactly that too, never to an error (conformance fixtures 19-23). The
|
|
28
|
+
* resulting `yaml` node carries the raw block text and nothing more: this
|
|
29
|
+
* module does not interpret YAML, and `@markii/core` depends on no YAML
|
|
30
|
+
* library. `frontmatter.ts`'s hand-rolled accessor reads the one
|
|
31
|
+
* format-defined key (`uses`) off that raw text for the simple list forms.
|
|
19
32
|
*/
|
|
20
33
|
export function parse(text) {
|
|
21
34
|
const processor = unified()
|
|
22
35
|
.use(remarkParse)
|
|
36
|
+
.use(remarkFrontmatter)
|
|
23
37
|
.use(remarkDirective)
|
|
24
38
|
.use(remarkGfm);
|
|
25
39
|
return processor.parse(text);
|
package/dist/scripts.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Code, Root } from 'mdast';
|
|
2
2
|
/**
|
|
3
|
-
* Slice 1 of the scripting-usability layer (
|
|
3
|
+
* Slice 1 of the scripting-usability layer (docs/scripting.md): this module only
|
|
4
4
|
* *reads* script blocks out of an already-parsed AST — it never executes
|
|
5
5
|
* anything, imports no Lua/wasmoon, and knows nothing about a value store.
|
|
6
6
|
* A script block is, at the parse layer, an ordinary fenced code block (see
|
|
@@ -15,14 +15,46 @@ import type { Code, Root } from 'mdast';
|
|
|
15
15
|
* long-script file (§8: "the block becomes a one-line reference,
|
|
16
16
|
* ` ```lua {src=scripts/etl.lua name=stars}` ` with an empty body"); `code`
|
|
17
17
|
* is still the fence's own body (empty for a `src=` reference).
|
|
18
|
+
*
|
|
19
|
+
* `publish`, when present, is always `true` — docs/scripting.md: "**Publishing is
|
|
20
|
+
* declarative**: the bare `publish` attribute on the script fence, no API to
|
|
21
|
+
* call." "Bare" is load-bearing: it is set ONLY when the fence spells the
|
|
22
|
+
* key with no `=value` at all (` ```lua {name=gh publish}` `, per
|
|
23
|
+
* `parseMetaAttributes`'s bare-key convention of yielding `''`). Any valued
|
|
24
|
+
* spelling — `publish=true`, `publish=yes`, `publish=1`, even `publish=false`
|
|
25
|
+
* or `publish=""` — is a DIFFERENT, undefined attribute, not the one the
|
|
26
|
+
* format specifies, and is silently ignored (the block simply does not
|
|
27
|
+
* publish), matching the format's "unrecognized input degrades, never
|
|
28
|
+
* throws" rule. This is deliberately fail-closed rather than lenient:
|
|
29
|
+
* publishing "writes beyond the note" (§8), so guessing at an unrecognized
|
|
30
|
+
* spelling risks writing to the shared vault when the author didn't mean to
|
|
31
|
+
* — silently NOT publishing is always the safe wrong answer, silently
|
|
32
|
+
* publishing never is. In particular `publish=false` must never be treated
|
|
33
|
+
* as an opt-out toggle that flips a default-on behavior; there is no such
|
|
34
|
+
* toggle, only the presence or absence of the one bare form.
|
|
35
|
+
*
|
|
36
|
+
* The field is only ever set when true, so a non-publishing block's shape is
|
|
37
|
+
* byte-identical to a `ScriptBlock` from before this field existed (no
|
|
38
|
+
* `publish: false` litter). Use `Object.hasOwn`/`'publish' in block` to test
|
|
39
|
+
* for absence, not falsiness.
|
|
18
40
|
*/
|
|
19
41
|
export interface ScriptBlock {
|
|
20
42
|
name: string;
|
|
21
43
|
lang: string;
|
|
22
44
|
src?: string;
|
|
23
45
|
code: string;
|
|
46
|
+
publish?: true;
|
|
24
47
|
position?: Code['position'];
|
|
25
48
|
}
|
|
49
|
+
/**
|
|
50
|
+
* Whether `name` is a legal script `name` (docs/scripting.md). Dots are
|
|
51
|
+
* deliberately excluded from the charset — `data=`/`:value[]` read a dot in
|
|
52
|
+
* a name as path traversal (`repo.stars` means "field `stars` of value
|
|
53
|
+
* `repo`"), which would make a dotted *script* name unreachable from either
|
|
54
|
+
* attribute. Fully anchored (`^`…`$`), so a trailing newline or any other
|
|
55
|
+
* stray character fails the match rather than being silently tolerated.
|
|
56
|
+
*/
|
|
57
|
+
export declare function isValidScriptName(name: string): boolean;
|
|
26
58
|
/**
|
|
27
59
|
* Parses a fence `meta` string's `{...}` attribute group into a flat
|
|
28
60
|
* key/value map (bare `key` with no `=value` yields `''`). Returns an empty,
|
|
@@ -40,11 +72,43 @@ export interface ScriptBlock {
|
|
|
40
72
|
* this purpose (see its `preserveCodeMeta` plugin).
|
|
41
73
|
*/
|
|
42
74
|
export declare function parseMetaAttributes(meta: string | null | undefined): Record<string, string>;
|
|
75
|
+
/**
|
|
76
|
+
* Whether `key` appears in `meta`'s `{...}` attribute group written
|
|
77
|
+
* genuinely bare (`key` with no `=value` at all — none of the three value
|
|
78
|
+
* alternatives in `ATTRIBUTE_TOKEN` matched). This is the ONE shared
|
|
79
|
+
* mechanism for a bare-only boolean fence-meta attribute (docs/scripting.md:
|
|
80
|
+
* "Boolean fence-meta attributes (`publish`, the reference renderer's
|
|
81
|
+
* `open`) are bare-only: writing any value — `publish=true` no less than
|
|
82
|
+
* `publish=false` — is not the boolean form and counts as absent. Fail
|
|
83
|
+
* closed: an unrecognized spelling must never enable behavior."). Used by
|
|
84
|
+
* `extractScripts` below for `publish`, and by `@markii/react`'s `PreElement`
|
|
85
|
+
* for `open` (see `ScriptBlock.publish`'s doc comment for why the
|
|
86
|
+
* bare/valued distinction matters).
|
|
87
|
+
*
|
|
88
|
+
* `parseMetaAttributes` collapses a bare key and an explicitly-empty quoted
|
|
89
|
+
* value (`key=""`) to the same `''` result in its flat map — the right
|
|
90
|
+
* choice for its general-purpose contract, but it erases exactly the
|
|
91
|
+
* distinction a bare-only attribute needs (`{publish}` must enable
|
|
92
|
+
* publishing; `{publish=""}` must not — and likewise `{open}` vs.
|
|
93
|
+
* `{open=""}`/`{open=true}`/`{open=false}`). So this walks the same token
|
|
94
|
+
* grammar directly instead of going through that already-flattened map.
|
|
95
|
+
* Like `parseMetaAttributes`, a key repeated in the group is "last
|
|
96
|
+
* occurrence wins" (the loop keeps overwriting `bare` for each match of
|
|
97
|
+
* `key`).
|
|
98
|
+
*
|
|
99
|
+
* Exported so a renderer (e.g. `@markii/react`) that defines its own
|
|
100
|
+
* bare-only boolean fence-meta attribute reuses this exact grammar instead
|
|
101
|
+
* of duplicating (and risking drift from) the bare/valued distinction here.
|
|
102
|
+
*/
|
|
103
|
+
export declare function isBareAttribute(meta: string | null | undefined, key: string): boolean;
|
|
43
104
|
/**
|
|
44
105
|
* Walks a parsed mdast `Root` and returns every script block, in document
|
|
45
106
|
* order. A `code` node is a script iff its `meta` carries a `{...}`
|
|
46
|
-
* attribute group with a non-empty `name
|
|
47
|
-
*
|
|
48
|
-
*
|
|
107
|
+
* attribute group with a non-empty `name` that also matches the script-name
|
|
108
|
+
* charset (docs/scripting.md, `isValidScriptName`); a code block with no meta,
|
|
109
|
+
* meta with no `name`, or a `name` outside the charset (e.g. a dotted
|
|
110
|
+
* `repo.stars`) is ordinary code and is skipped — same display-only
|
|
111
|
+
* degradation either way, never an error and never a console message. Pure
|
|
112
|
+
* AST inspection — no execution, no I/O, no knowledge of any value store.
|
|
49
113
|
*/
|
|
50
114
|
export declare function extractScripts(tree: Root): ScriptBlock[];
|
package/dist/scripts.js
CHANGED
|
@@ -32,6 +32,28 @@ function findAttributeGroup(source) {
|
|
|
32
32
|
* `key="quoted value"`, or `key='quoted value'`. Whitespace-separated.
|
|
33
33
|
*/
|
|
34
34
|
const ATTRIBUTE_TOKEN = /([A-Za-z_][\w-]*)(?:=(?:"([^"]*)"|'([^']*)'|(\S+)))?/g;
|
|
35
|
+
/**
|
|
36
|
+
* The full charset a script `name` may use (docs/scripting.md). Deliberately NOT
|
|
37
|
+
* `g`-flagged: a `g`-flagged `RegExp` carries `lastIndex` state across
|
|
38
|
+
* `.test()` calls (the exec-loop cursor from a previous match survives to
|
|
39
|
+
* the next call on the same instance), which silently makes alternating
|
|
40
|
+
* calls report wrong results — a real bug class. `ATTRIBUTE_TOKEN` above
|
|
41
|
+
* needs `g` because it's driven with `.exec()` in a loop and resets
|
|
42
|
+
* `lastIndex` itself each time; `isValidScriptName` has no such loop, so it
|
|
43
|
+
* gets a plain, stateless pattern instead.
|
|
44
|
+
*/
|
|
45
|
+
const SCRIPT_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_-]*$/;
|
|
46
|
+
/**
|
|
47
|
+
* Whether `name` is a legal script `name` (docs/scripting.md). Dots are
|
|
48
|
+
* deliberately excluded from the charset — `data=`/`:value[]` read a dot in
|
|
49
|
+
* a name as path traversal (`repo.stars` means "field `stars` of value
|
|
50
|
+
* `repo`"), which would make a dotted *script* name unreachable from either
|
|
51
|
+
* attribute. Fully anchored (`^`…`$`), so a trailing newline or any other
|
|
52
|
+
* stray character fails the match rather than being silently tolerated.
|
|
53
|
+
*/
|
|
54
|
+
export function isValidScriptName(name) {
|
|
55
|
+
return SCRIPT_NAME_PATTERN.test(name);
|
|
56
|
+
}
|
|
35
57
|
/**
|
|
36
58
|
* Parses a fence `meta` string's `{...}` attribute group into a flat
|
|
37
59
|
* key/value map (bare `key` with no `=value` yields `''`). Returns an empty,
|
|
@@ -65,19 +87,69 @@ export function parseMetaAttributes(meta) {
|
|
|
65
87
|
}
|
|
66
88
|
return attrs;
|
|
67
89
|
}
|
|
90
|
+
/**
|
|
91
|
+
* Whether `key` appears in `meta`'s `{...}` attribute group written
|
|
92
|
+
* genuinely bare (`key` with no `=value` at all — none of the three value
|
|
93
|
+
* alternatives in `ATTRIBUTE_TOKEN` matched). This is the ONE shared
|
|
94
|
+
* mechanism for a bare-only boolean fence-meta attribute (docs/scripting.md:
|
|
95
|
+
* "Boolean fence-meta attributes (`publish`, the reference renderer's
|
|
96
|
+
* `open`) are bare-only: writing any value — `publish=true` no less than
|
|
97
|
+
* `publish=false` — is not the boolean form and counts as absent. Fail
|
|
98
|
+
* closed: an unrecognized spelling must never enable behavior."). Used by
|
|
99
|
+
* `extractScripts` below for `publish`, and by `@markii/react`'s `PreElement`
|
|
100
|
+
* for `open` (see `ScriptBlock.publish`'s doc comment for why the
|
|
101
|
+
* bare/valued distinction matters).
|
|
102
|
+
*
|
|
103
|
+
* `parseMetaAttributes` collapses a bare key and an explicitly-empty quoted
|
|
104
|
+
* value (`key=""`) to the same `''` result in its flat map — the right
|
|
105
|
+
* choice for its general-purpose contract, but it erases exactly the
|
|
106
|
+
* distinction a bare-only attribute needs (`{publish}` must enable
|
|
107
|
+
* publishing; `{publish=""}` must not — and likewise `{open}` vs.
|
|
108
|
+
* `{open=""}`/`{open=true}`/`{open=false}`). So this walks the same token
|
|
109
|
+
* grammar directly instead of going through that already-flattened map.
|
|
110
|
+
* Like `parseMetaAttributes`, a key repeated in the group is "last
|
|
111
|
+
* occurrence wins" (the loop keeps overwriting `bare` for each match of
|
|
112
|
+
* `key`).
|
|
113
|
+
*
|
|
114
|
+
* Exported so a renderer (e.g. `@markii/react`) that defines its own
|
|
115
|
+
* bare-only boolean fence-meta attribute reuses this exact grammar instead
|
|
116
|
+
* of duplicating (and risking drift from) the bare/valued distinction here.
|
|
117
|
+
*/
|
|
118
|
+
export function isBareAttribute(meta, key) {
|
|
119
|
+
if (!meta)
|
|
120
|
+
return false;
|
|
121
|
+
const group = findAttributeGroup(meta);
|
|
122
|
+
if (group === undefined)
|
|
123
|
+
return false;
|
|
124
|
+
ATTRIBUTE_TOKEN.lastIndex = 0;
|
|
125
|
+
let match;
|
|
126
|
+
let bare = false;
|
|
127
|
+
while ((match = ATTRIBUTE_TOKEN.exec(group)) !== null) {
|
|
128
|
+
if (match[1] !== key)
|
|
129
|
+
continue;
|
|
130
|
+
bare =
|
|
131
|
+
match[2] === undefined &&
|
|
132
|
+
match[3] === undefined &&
|
|
133
|
+
match[4] === undefined;
|
|
134
|
+
}
|
|
135
|
+
return bare;
|
|
136
|
+
}
|
|
68
137
|
/**
|
|
69
138
|
* Walks a parsed mdast `Root` and returns every script block, in document
|
|
70
139
|
* order. A `code` node is a script iff its `meta` carries a `{...}`
|
|
71
|
-
* attribute group with a non-empty `name
|
|
72
|
-
*
|
|
73
|
-
*
|
|
140
|
+
* attribute group with a non-empty `name` that also matches the script-name
|
|
141
|
+
* charset (docs/scripting.md, `isValidScriptName`); a code block with no meta,
|
|
142
|
+
* meta with no `name`, or a `name` outside the charset (e.g. a dotted
|
|
143
|
+
* `repo.stars`) is ordinary code and is skipped — same display-only
|
|
144
|
+
* degradation either way, never an error and never a console message. Pure
|
|
145
|
+
* AST inspection — no execution, no I/O, no knowledge of any value store.
|
|
74
146
|
*/
|
|
75
147
|
export function extractScripts(tree) {
|
|
76
148
|
const blocks = [];
|
|
77
149
|
visit(tree, 'code', (node) => {
|
|
78
150
|
const attrs = parseMetaAttributes(node.meta);
|
|
79
151
|
const name = attrs.name;
|
|
80
|
-
if (!name)
|
|
152
|
+
if (!name || !isValidScriptName(name))
|
|
81
153
|
return;
|
|
82
154
|
const block = {
|
|
83
155
|
name,
|
|
@@ -86,6 +158,13 @@ export function extractScripts(tree) {
|
|
|
86
158
|
};
|
|
87
159
|
if (attrs.src)
|
|
88
160
|
block.src = attrs.src;
|
|
161
|
+
// Bare-only, per `ScriptBlock.publish`'s doc comment and
|
|
162
|
+
// `isBareAttribute`'s: a plain `attrs.publish === ''` check can't tell
|
|
163
|
+
// `{publish}` from `{publish=""}` (both flatten to `''`), so this asks
|
|
164
|
+
// the token grammar directly instead of trusting the flattened map.
|
|
165
|
+
if (isBareAttribute(node.meta, 'publish')) {
|
|
166
|
+
block.publish = true;
|
|
167
|
+
}
|
|
89
168
|
if (node.position)
|
|
90
169
|
block.position = node.position;
|
|
91
170
|
blocks.push(block);
|
package/dist/to-hast.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { RootContent } from 'mdast';
|
|
1
2
|
import type { Root as HastRoot } from 'hast';
|
|
2
3
|
/**
|
|
3
4
|
* Modelled on react-markdown's `defaultUrlTransform`, though our allowlist
|
|
@@ -38,3 +39,41 @@ export declare function isSafeUrl(url: string): boolean;
|
|
|
38
39
|
* script marker).
|
|
39
40
|
*/
|
|
40
41
|
export declare function toHast(text: string): HastRoot;
|
|
42
|
+
/**
|
|
43
|
+
* One mdast node as this package's `parse` produces it (a member of a
|
|
44
|
+
* `Root`'s `children` array — `mdast`'s own `RootContent` union). Re-exported
|
|
45
|
+
* under this package's naming so a consumer (e.g. `@markii/react`'s
|
|
46
|
+
* `renderMarkNode`) can type a "single already-parsed node" parameter without
|
|
47
|
+
* adding its own `@types/mdast` dependency — `@markii/core` already depends
|
|
48
|
+
* on it.
|
|
49
|
+
*/
|
|
50
|
+
export type MarkNode = RootContent;
|
|
51
|
+
/**
|
|
52
|
+
* Converts a single already-parsed mdast node (as produced by this package's
|
|
53
|
+
* `parse`, e.g. one top-level child of its `Root`) to a sanitized hast tree,
|
|
54
|
+
* running the exact SAME pipeline as `toHast` — see `createProcessor` above,
|
|
55
|
+
* the one shared plugin-list source of truth — over a synthetic mdast `root`
|
|
56
|
+
* wrapping just this node. Directive tagging, code-meta preservation, and URL
|
|
57
|
+
* sanitizing (`sanitizeUrls`) all run identically to the whole-document path;
|
|
58
|
+
* the only difference is skipping `processor.parse` (there is no text to
|
|
59
|
+
* parse — `node` is already an AST) and wrapping in a throwaway root instead
|
|
60
|
+
* of using the document's own.
|
|
61
|
+
*
|
|
62
|
+
* Pure: `node` is deep-cloned (`structuredClone` — mdast nodes are plain
|
|
63
|
+
* JSON-safe data) before the pipeline runs, so the plugins' in-place
|
|
64
|
+
* `data.hName`/`data.hProperties` mutations never touch the caller's node.
|
|
65
|
+
*
|
|
66
|
+
* Hardened: because this entry point takes an AST rather than text, the
|
|
67
|
+
* clone is also stripped of every node's `data` — the caller-controllable
|
|
68
|
+
* hast-override channel — before the pipeline runs, so a hand-built or
|
|
69
|
+
* transformed node cannot dictate the emitted tag/attributes (e.g. a
|
|
70
|
+
* `data.hName: 'script'` injection). See `stripHastOverrides`.
|
|
71
|
+
*
|
|
72
|
+
* Never throws, matching `toHast`'s and Architecture rule 3's
|
|
73
|
+
* never-throw-on-odd-input guarantee: an inline/text-level node at the root
|
|
74
|
+
* of the synthetic tree, a `code` node with malformed `meta`, or a
|
|
75
|
+
* container-directive subtree left over from an unclosed fence may all
|
|
76
|
+
* produce degraded hast (in the worst case, an empty root — `emptyHastRoot`),
|
|
77
|
+
* but the call itself cannot fail.
|
|
78
|
+
*/
|
|
79
|
+
export declare function nodeToHast(node: MarkNode): HastRoot;
|
package/dist/to-hast.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { unified } from 'unified';
|
|
2
2
|
import remarkParse from 'remark-parse';
|
|
3
3
|
import remarkDirective from 'remark-directive';
|
|
4
|
+
import remarkFrontmatter from 'remark-frontmatter';
|
|
4
5
|
import remarkGfm from 'remark-gfm';
|
|
5
6
|
import remarkRehype from 'remark-rehype';
|
|
6
7
|
import { visit } from 'unist-util-visit';
|
|
@@ -146,6 +147,37 @@ function sanitizeUrls(tree) {
|
|
|
146
147
|
}
|
|
147
148
|
});
|
|
148
149
|
}
|
|
150
|
+
/**
|
|
151
|
+
* The ONE source of truth for the mdast->hast pipeline's plugin list, shared
|
|
152
|
+
* by `toHast` (whole-document) and `nodeToHast` (single-node) below —
|
|
153
|
+
* exactly one `.use()` chain exists in this module so the two entry points
|
|
154
|
+
* cannot drift apart on directive tagging, code-meta preservation, or the
|
|
155
|
+
* remark-rehype conversion itself. Building a fresh processor per call
|
|
156
|
+
* (rather than a shared module-level instance) matches `unified`'s own
|
|
157
|
+
* immutable-processor model (`.use()` returns a new processor) and keeps
|
|
158
|
+
* each call's `.parse`/`.runSync` free of any state left over from a
|
|
159
|
+
* previous call.
|
|
160
|
+
*
|
|
161
|
+
* The plugin list mirrors `parse.ts` exactly (frontmatter, directives, GFM),
|
|
162
|
+
* so what `parse` accepts and what this pipeline renders can never disagree
|
|
163
|
+
* about the grammar. Frontmatter needs no tagging step of its own: a leading
|
|
164
|
+
* `---` block becomes an mdast `yaml` node, and `mdast-util-to-hast`'s
|
|
165
|
+
* default handler for `yaml` is its `ignore` handler, so the block is
|
|
166
|
+
* DROPPED from the hast tree rather than leaking into the rendered document
|
|
167
|
+
* as text (pinned by `to-hast.test.ts`, since that drop is a rendering
|
|
168
|
+
* guarantee of the format, not an incidental upstream default). Metadata is
|
|
169
|
+
* read from the AST via `frontmatter.ts`, never rendered.
|
|
170
|
+
*/
|
|
171
|
+
function createProcessor() {
|
|
172
|
+
return unified()
|
|
173
|
+
.use(remarkParse)
|
|
174
|
+
.use(remarkFrontmatter)
|
|
175
|
+
.use(remarkDirective)
|
|
176
|
+
.use(remarkGfm)
|
|
177
|
+
.use(tagDirectiveNodes)
|
|
178
|
+
.use(preserveCodeMeta)
|
|
179
|
+
.use(remarkRehype);
|
|
180
|
+
}
|
|
149
181
|
/**
|
|
150
182
|
* Converts Mark text straight to a sanitized hast tree: parse (mdast, with
|
|
151
183
|
* directive + GFM syntax extensions) -> tag directive nodes + preserve
|
|
@@ -162,15 +194,96 @@ function sanitizeUrls(tree) {
|
|
|
162
194
|
* script marker).
|
|
163
195
|
*/
|
|
164
196
|
export function toHast(text) {
|
|
165
|
-
const processor =
|
|
166
|
-
.use(remarkParse)
|
|
167
|
-
.use(remarkDirective)
|
|
168
|
-
.use(remarkGfm)
|
|
169
|
-
.use(tagDirectiveNodes)
|
|
170
|
-
.use(preserveCodeMeta)
|
|
171
|
-
.use(remarkRehype);
|
|
197
|
+
const processor = createProcessor();
|
|
172
198
|
const mdastTree = processor.parse(text);
|
|
173
199
|
const hastTree = processor.runSync(mdastTree);
|
|
174
200
|
sanitizeUrls(hastTree);
|
|
175
201
|
return hastTree;
|
|
176
202
|
}
|
|
203
|
+
/**
|
|
204
|
+
* Deletes `data` from every node in `tree`, in place.
|
|
205
|
+
*
|
|
206
|
+
* Security-relevant, and specific to the single-node path. mdast `data` is
|
|
207
|
+
* the documented channel for overriding hast output — `mdast-util-to-hast`
|
|
208
|
+
* reads `data.hName`, `data.hProperties`, and `data.hChildren` and will emit
|
|
209
|
+
* *any* tag with *any* attributes a node asks for. `toHast` can never be
|
|
210
|
+
* handed such a node: it takes TEXT and parses it itself, and
|
|
211
|
+
* `remark-parse`/`remark-directive`/`remark-gfm` never populate `data` on the
|
|
212
|
+
* nodes they produce. `nodeToHast` instead accepts an already-built AST from
|
|
213
|
+
* the caller, so without this step a node carrying
|
|
214
|
+
* `data.hName = 'script'` + `data.hProperties = { src: 'javascript:...' }`
|
|
215
|
+
* would convert to a real `<script src="javascript:...">` hast element —
|
|
216
|
+
* bypassing `sanitizeUrls` entirely, since that only guards `a[href]` and
|
|
217
|
+
* `img[src]`. Any host that transforms the AST between `parse` and rendering
|
|
218
|
+
* (or renders a node built from untrusted input) would inherit an injection
|
|
219
|
+
* path that the document path simply does not have.
|
|
220
|
+
*
|
|
221
|
+
* Deleting `data` WHOLESALE, rather than allowlisting away the three known
|
|
222
|
+
* `h*` keys, is the deliberate choice: it is fail-closed by construction (a
|
|
223
|
+
* future `mdast-util-to-hast` release that honors a fourth `data.h*` key
|
|
224
|
+
* cannot silently reopen this hole, whereas a hardcoded three-name denylist
|
|
225
|
+
* would), and it costs nothing in fidelity — since parser-produced nodes
|
|
226
|
+
* carry no `data` at all, stripping it reproduces exactly the shape the
|
|
227
|
+
* document path feeds the pipeline. The pipeline's own tagging
|
|
228
|
+
* (`tagDirectiveNodes`, `preserveCodeMeta`) runs AFTER this and repopulates
|
|
229
|
+
* `data` itself, so directives and code fences are unaffected.
|
|
230
|
+
*/
|
|
231
|
+
function stripHastOverrides(tree) {
|
|
232
|
+
visit(tree, (node) => {
|
|
233
|
+
if (node.data !== undefined)
|
|
234
|
+
delete node.data;
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* An empty hast root, returned by `nodeToHast` when the pipeline itself
|
|
239
|
+
* throws on odd input (Architecture rule 3's never-throw guarantee, extended
|
|
240
|
+
* to the single-node path) — degraded output, never an exception.
|
|
241
|
+
*/
|
|
242
|
+
function emptyHastRoot() {
|
|
243
|
+
return { type: 'root', children: [] };
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Converts a single already-parsed mdast node (as produced by this package's
|
|
247
|
+
* `parse`, e.g. one top-level child of its `Root`) to a sanitized hast tree,
|
|
248
|
+
* running the exact SAME pipeline as `toHast` — see `createProcessor` above,
|
|
249
|
+
* the one shared plugin-list source of truth — over a synthetic mdast `root`
|
|
250
|
+
* wrapping just this node. Directive tagging, code-meta preservation, and URL
|
|
251
|
+
* sanitizing (`sanitizeUrls`) all run identically to the whole-document path;
|
|
252
|
+
* the only difference is skipping `processor.parse` (there is no text to
|
|
253
|
+
* parse — `node` is already an AST) and wrapping in a throwaway root instead
|
|
254
|
+
* of using the document's own.
|
|
255
|
+
*
|
|
256
|
+
* Pure: `node` is deep-cloned (`structuredClone` — mdast nodes are plain
|
|
257
|
+
* JSON-safe data) before the pipeline runs, so the plugins' in-place
|
|
258
|
+
* `data.hName`/`data.hProperties` mutations never touch the caller's node.
|
|
259
|
+
*
|
|
260
|
+
* Hardened: because this entry point takes an AST rather than text, the
|
|
261
|
+
* clone is also stripped of every node's `data` — the caller-controllable
|
|
262
|
+
* hast-override channel — before the pipeline runs, so a hand-built or
|
|
263
|
+
* transformed node cannot dictate the emitted tag/attributes (e.g. a
|
|
264
|
+
* `data.hName: 'script'` injection). See `stripHastOverrides`.
|
|
265
|
+
*
|
|
266
|
+
* Never throws, matching `toHast`'s and Architecture rule 3's
|
|
267
|
+
* never-throw-on-odd-input guarantee: an inline/text-level node at the root
|
|
268
|
+
* of the synthetic tree, a `code` node with malformed `meta`, or a
|
|
269
|
+
* container-directive subtree left over from an unclosed fence may all
|
|
270
|
+
* produce degraded hast (in the worst case, an empty root — `emptyHastRoot`),
|
|
271
|
+
* but the call itself cannot fail.
|
|
272
|
+
*/
|
|
273
|
+
export function nodeToHast(node) {
|
|
274
|
+
try {
|
|
275
|
+
const cloned = structuredClone(node);
|
|
276
|
+
const root = { type: 'root', children: [cloned] };
|
|
277
|
+
// Before the pipeline runs: drop any caller-supplied hast overrides
|
|
278
|
+
// (`data.hName`/`hProperties`/`hChildren`) so only this pipeline's own
|
|
279
|
+
// tagging can influence the hast output. See `stripHastOverrides`.
|
|
280
|
+
stripHastOverrides(root);
|
|
281
|
+
const processor = createProcessor();
|
|
282
|
+
const hastTree = processor.runSync(root);
|
|
283
|
+
sanitizeUrls(hastTree);
|
|
284
|
+
return hastTree;
|
|
285
|
+
}
|
|
286
|
+
catch {
|
|
287
|
+
return emptyHastRoot();
|
|
288
|
+
}
|
|
289
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@markii/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Framework-agnostic reference parser for Mark (.mk.md): CommonMark + directives to mdast, and mdast to a sanitized hast tree. Zero React dependency.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"markdown",
|
|
@@ -51,8 +51,11 @@
|
|
|
51
51
|
"corpus:regen": "node scripts/regen-corpus.ts"
|
|
52
52
|
},
|
|
53
53
|
"dependencies": {
|
|
54
|
+
"@types/hast": "^3.0.5",
|
|
55
|
+
"@types/mdast": "^4.0.4",
|
|
54
56
|
"mdast-util-directive": "^3.1.0",
|
|
55
57
|
"remark-directive": "^4.0.0",
|
|
58
|
+
"remark-frontmatter": "^5.0.0",
|
|
56
59
|
"remark-gfm": "^4.0.1",
|
|
57
60
|
"remark-parse": "^11.0.0",
|
|
58
61
|
"remark-rehype": "^11.1.2",
|
|
@@ -60,8 +63,6 @@
|
|
|
60
63
|
"unist-util-visit": "^5.1.0"
|
|
61
64
|
},
|
|
62
65
|
"devDependencies": {
|
|
63
|
-
"@types/hast": "^3.0.5",
|
|
64
|
-
"@types/mdast": "^4.0.4",
|
|
65
66
|
"prettier": "^3.9.6"
|
|
66
67
|
}
|
|
67
68
|
}
|