@markii/core 0.1.0 → 0.2.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/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/scripts.d.ts +67 -3
- package/dist/scripts.js +83 -4
- package/dist/to-hast.d.ts +39 -0
- package/dist/to-hast.js +108 -7
- package/package.json +3 -3
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
export { parse } from './parse.js';
|
|
2
|
-
export { toHast, isSafeUrl } from './to-hast.js';
|
|
3
|
-
export { extractScripts, parseMetaAttributes, type ScriptBlock, } from './scripts.js';
|
|
2
|
+
export { toHast, nodeToHast, isSafeUrl, type MarkNode } from './to-hast.js';
|
|
3
|
+
export { extractScripts, isBareAttribute, isValidScriptName, parseMetaAttributes, type ScriptBlock, } from './scripts.js';
|
package/dist/index.js
CHANGED
|
@@ -5,5 +5,5 @@
|
|
|
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 { toHast, isSafeUrl } from './to-hast.js';
|
|
9
|
-
export { extractScripts, parseMetaAttributes, } from './scripts.js';
|
|
8
|
+
export { toHast, nodeToHast, isSafeUrl } from './to-hast.js';
|
|
9
|
+
export { extractScripts, isBareAttribute, isValidScriptName, parseMetaAttributes, } from './scripts.js';
|
package/dist/scripts.d.ts
CHANGED
|
@@ -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` — DESIGN.md §8: "**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` (DESIGN.md §8). 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 (DESIGN.md §8:
|
|
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 (DESIGN.md §8, `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 (DESIGN.md §8). 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` (DESIGN.md §8). 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 (DESIGN.md §8:
|
|
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 (DESIGN.md §8, `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
|
@@ -146,6 +146,26 @@ function sanitizeUrls(tree) {
|
|
|
146
146
|
}
|
|
147
147
|
});
|
|
148
148
|
}
|
|
149
|
+
/**
|
|
150
|
+
* The ONE source of truth for the mdast->hast pipeline's plugin list, shared
|
|
151
|
+
* by `toHast` (whole-document) and `nodeToHast` (single-node) below —
|
|
152
|
+
* exactly one `.use()` chain exists in this module so the two entry points
|
|
153
|
+
* cannot drift apart on directive tagging, code-meta preservation, or the
|
|
154
|
+
* remark-rehype conversion itself. Building a fresh processor per call
|
|
155
|
+
* (rather than a shared module-level instance) matches `unified`'s own
|
|
156
|
+
* immutable-processor model (`.use()` returns a new processor) and keeps
|
|
157
|
+
* each call's `.parse`/`.runSync` free of any state left over from a
|
|
158
|
+
* previous call.
|
|
159
|
+
*/
|
|
160
|
+
function createProcessor() {
|
|
161
|
+
return unified()
|
|
162
|
+
.use(remarkParse)
|
|
163
|
+
.use(remarkDirective)
|
|
164
|
+
.use(remarkGfm)
|
|
165
|
+
.use(tagDirectiveNodes)
|
|
166
|
+
.use(preserveCodeMeta)
|
|
167
|
+
.use(remarkRehype);
|
|
168
|
+
}
|
|
149
169
|
/**
|
|
150
170
|
* Converts Mark text straight to a sanitized hast tree: parse (mdast, with
|
|
151
171
|
* directive + GFM syntax extensions) -> tag directive nodes + preserve
|
|
@@ -162,15 +182,96 @@ function sanitizeUrls(tree) {
|
|
|
162
182
|
* script marker).
|
|
163
183
|
*/
|
|
164
184
|
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);
|
|
185
|
+
const processor = createProcessor();
|
|
172
186
|
const mdastTree = processor.parse(text);
|
|
173
187
|
const hastTree = processor.runSync(mdastTree);
|
|
174
188
|
sanitizeUrls(hastTree);
|
|
175
189
|
return hastTree;
|
|
176
190
|
}
|
|
191
|
+
/**
|
|
192
|
+
* Deletes `data` from every node in `tree`, in place.
|
|
193
|
+
*
|
|
194
|
+
* Security-relevant, and specific to the single-node path. mdast `data` is
|
|
195
|
+
* the documented channel for overriding hast output — `mdast-util-to-hast`
|
|
196
|
+
* reads `data.hName`, `data.hProperties`, and `data.hChildren` and will emit
|
|
197
|
+
* *any* tag with *any* attributes a node asks for. `toHast` can never be
|
|
198
|
+
* handed such a node: it takes TEXT and parses it itself, and
|
|
199
|
+
* `remark-parse`/`remark-directive`/`remark-gfm` never populate `data` on the
|
|
200
|
+
* nodes they produce. `nodeToHast` instead accepts an already-built AST from
|
|
201
|
+
* the caller, so without this step a node carrying
|
|
202
|
+
* `data.hName = 'script'` + `data.hProperties = { src: 'javascript:...' }`
|
|
203
|
+
* would convert to a real `<script src="javascript:...">` hast element —
|
|
204
|
+
* bypassing `sanitizeUrls` entirely, since that only guards `a[href]` and
|
|
205
|
+
* `img[src]`. Any host that transforms the AST between `parse` and rendering
|
|
206
|
+
* (or renders a node built from untrusted input) would inherit an injection
|
|
207
|
+
* path that the document path simply does not have.
|
|
208
|
+
*
|
|
209
|
+
* Deleting `data` WHOLESALE, rather than allowlisting away the three known
|
|
210
|
+
* `h*` keys, is the deliberate choice: it is fail-closed by construction (a
|
|
211
|
+
* future `mdast-util-to-hast` release that honors a fourth `data.h*` key
|
|
212
|
+
* cannot silently reopen this hole, whereas a hardcoded three-name denylist
|
|
213
|
+
* would), and it costs nothing in fidelity — since parser-produced nodes
|
|
214
|
+
* carry no `data` at all, stripping it reproduces exactly the shape the
|
|
215
|
+
* document path feeds the pipeline. The pipeline's own tagging
|
|
216
|
+
* (`tagDirectiveNodes`, `preserveCodeMeta`) runs AFTER this and repopulates
|
|
217
|
+
* `data` itself, so directives and code fences are unaffected.
|
|
218
|
+
*/
|
|
219
|
+
function stripHastOverrides(tree) {
|
|
220
|
+
visit(tree, (node) => {
|
|
221
|
+
if (node.data !== undefined)
|
|
222
|
+
delete node.data;
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* An empty hast root, returned by `nodeToHast` when the pipeline itself
|
|
227
|
+
* throws on odd input (Architecture rule 3's never-throw guarantee, extended
|
|
228
|
+
* to the single-node path) — degraded output, never an exception.
|
|
229
|
+
*/
|
|
230
|
+
function emptyHastRoot() {
|
|
231
|
+
return { type: 'root', children: [] };
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Converts a single already-parsed mdast node (as produced by this package's
|
|
235
|
+
* `parse`, e.g. one top-level child of its `Root`) to a sanitized hast tree,
|
|
236
|
+
* running the exact SAME pipeline as `toHast` — see `createProcessor` above,
|
|
237
|
+
* the one shared plugin-list source of truth — over a synthetic mdast `root`
|
|
238
|
+
* wrapping just this node. Directive tagging, code-meta preservation, and URL
|
|
239
|
+
* sanitizing (`sanitizeUrls`) all run identically to the whole-document path;
|
|
240
|
+
* the only difference is skipping `processor.parse` (there is no text to
|
|
241
|
+
* parse — `node` is already an AST) and wrapping in a throwaway root instead
|
|
242
|
+
* of using the document's own.
|
|
243
|
+
*
|
|
244
|
+
* Pure: `node` is deep-cloned (`structuredClone` — mdast nodes are plain
|
|
245
|
+
* JSON-safe data) before the pipeline runs, so the plugins' in-place
|
|
246
|
+
* `data.hName`/`data.hProperties` mutations never touch the caller's node.
|
|
247
|
+
*
|
|
248
|
+
* Hardened: because this entry point takes an AST rather than text, the
|
|
249
|
+
* clone is also stripped of every node's `data` — the caller-controllable
|
|
250
|
+
* hast-override channel — before the pipeline runs, so a hand-built or
|
|
251
|
+
* transformed node cannot dictate the emitted tag/attributes (e.g. a
|
|
252
|
+
* `data.hName: 'script'` injection). See `stripHastOverrides`.
|
|
253
|
+
*
|
|
254
|
+
* Never throws, matching `toHast`'s and Architecture rule 3's
|
|
255
|
+
* never-throw-on-odd-input guarantee: an inline/text-level node at the root
|
|
256
|
+
* of the synthetic tree, a `code` node with malformed `meta`, or a
|
|
257
|
+
* container-directive subtree left over from an unclosed fence may all
|
|
258
|
+
* produce degraded hast (in the worst case, an empty root — `emptyHastRoot`),
|
|
259
|
+
* but the call itself cannot fail.
|
|
260
|
+
*/
|
|
261
|
+
export function nodeToHast(node) {
|
|
262
|
+
try {
|
|
263
|
+
const cloned = structuredClone(node);
|
|
264
|
+
const root = { type: 'root', children: [cloned] };
|
|
265
|
+
// Before the pipeline runs: drop any caller-supplied hast overrides
|
|
266
|
+
// (`data.hName`/`hProperties`/`hChildren`) so only this pipeline's own
|
|
267
|
+
// tagging can influence the hast output. See `stripHastOverrides`.
|
|
268
|
+
stripHastOverrides(root);
|
|
269
|
+
const processor = createProcessor();
|
|
270
|
+
const hastTree = processor.runSync(root);
|
|
271
|
+
sanitizeUrls(hastTree);
|
|
272
|
+
return hastTree;
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
return emptyHastRoot();
|
|
276
|
+
}
|
|
277
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@markii/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.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,6 +51,8 @@
|
|
|
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",
|
|
56
58
|
"remark-gfm": "^4.0.1",
|
|
@@ -60,8 +62,6 @@
|
|
|
60
62
|
"unist-util-visit": "^5.1.0"
|
|
61
63
|
},
|
|
62
64
|
"devDependencies": {
|
|
63
|
-
"@types/hast": "^3.0.5",
|
|
64
|
-
"@types/mdast": "^4.0.4",
|
|
65
65
|
"prettier": "^3.9.6"
|
|
66
66
|
}
|
|
67
67
|
}
|