@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 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 DESIGN.md
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 { toHast, isSafeUrl } from './to-hast.js';
3
- export { extractScripts, parseMetaAttributes, type ScriptBlock, } from './scripts.js';
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 { toHast, isSafeUrl } from './to-hast.js';
9
- export { extractScripts, parseMetaAttributes, } from './scripts.js';
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 (DESIGN.md §8): this module only
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`; a code block with no meta, or
47
- * meta with no `name`, is ordinary code and is skipped. Pure AST
48
- * inspection — no execution, no I/O, no knowledge of any value store.
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`; a code block with no meta, or
72
- * meta with no `name`, is ordinary code and is skipped. Pure AST
73
- * inspection — no execution, no I/O, no knowledge of any value store.
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 = unified()
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.1.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
  }