@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 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`; 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 (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`; 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 (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 = unified()
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.1.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
  }