@markii/core 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 sadigaxund
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,5 @@
1
+ # @markii/core
2
+
3
+ The framework-agnostic reference parser for [Mark](https://github.com/sadigaxund/markii) (`.mk.md`): CommonMark + GitHub-Flavored Markdown plus a generic directive syntax, parsed to an mdast tree and converted to a sanitized hast tree. Zero React dependency — component-agnostic by design, so any renderer (e.g. `@markii/react`) can consume its output.
4
+
5
+ See the [repository](https://github.com/sadigaxund/markii) for the format spec and the reference library as a whole.
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Resolves the repo-root `conformance/` directory relative to this module's
3
+ * own file location (not `process.cwd()`), so it works the same whether
4
+ * tests run from the repo root or from within a single workspace (each
5
+ * workspace's `vitest run` / `node` invocation has a different cwd).
6
+ */
7
+ export declare function conformanceDir(): string;
8
+ export interface CorpusCase {
9
+ /** Fixture name, e.g. `"01-plain-markdown"` (no extension). */
10
+ name: string;
11
+ /** Absolute path to the `.mk.md` input file. */
12
+ smdPath: string;
13
+ /** Absolute path to the sibling expected-AST `.json` file. */
14
+ jsonPath: string;
15
+ /** Raw `.mk.md` source text. */
16
+ input: string;
17
+ /** Parsed expected-AST JSON (already position-free). */
18
+ expected: unknown;
19
+ }
20
+ /**
21
+ * Base fixture names (no extension) for every `*.mk.md` file in `dir`,
22
+ * sorted. `.mk.md` is a COMPOUND extension (two dots) — the sibling name is
23
+ * derived by stripping the full `DOC_EXTENSION` suffix, not by slicing off
24
+ * whatever `path.extname()` would report (which would only strip `.md` and
25
+ * leave a stray `.mk` in the fixture name).
26
+ */
27
+ export declare function listCorpusNames(dir?: string): string[];
28
+ /**
29
+ * Loads every `<name>.mk.md` / `<name>.json` pair from `dir` (default: the
30
+ * repo-root `conformance/` directory). A `.mk.md` file with no matching
31
+ * `.json` sibling throws (surfacing the missing fixture loudly rather than
32
+ * silently skipping a corpus case).
33
+ */
34
+ export declare function loadCorpusCases(dir?: string): CorpusCase[];
35
+ /**
36
+ * Recursively strips every `position` field from a parsed tree (unist nodes
37
+ * carry `position` with line/column/offset info that's irrelevant to
38
+ * structural conformance and would make corpus fixtures brittle to
39
+ * reformat). Generic over any plain-data tree shape, not just mdast, so it
40
+ * doubles as a hast-position-stripper if a future corpus level needs one.
41
+ */
42
+ export declare function stripPositions<T>(value: T): T;
43
+ /**
44
+ * Serializes `value` as pretty-printed JSON with a deterministic, sorted key
45
+ * order (so regenerated expected-AST fixtures diff cleanly and don't depend
46
+ * on incidental object-construction order) and a trailing newline.
47
+ */
48
+ export declare function stableStringify(value: unknown): string;
package/dist/corpus.js ADDED
@@ -0,0 +1,97 @@
1
+ import { readFileSync, readdirSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ /**
5
+ * The language-agnostic conformance corpus: `*.mk.md` inputs plus
6
+ * expected-AST `*.json` siblings, living at the repo root (see DESIGN.md
7
+ * §13). This module is deliberately parser-agnostic — it only
8
+ * loads/compares corpus data — so it's usable from any package's tests (or
9
+ * a future non-JS implementation's tooling), not just `@markii/core`'s own
10
+ * parser tests.
11
+ */
12
+ /** The Mark document extension. Compound (two dots) — never use `path.extname()` to strip it. */
13
+ const DOC_EXTENSION = '.mk.md';
14
+ /**
15
+ * Resolves the repo-root `conformance/` directory relative to this module's
16
+ * own file location (not `process.cwd()`), so it works the same whether
17
+ * tests run from the repo root or from within a single workspace (each
18
+ * workspace's `vitest run` / `node` invocation has a different cwd).
19
+ */
20
+ export function conformanceDir() {
21
+ const here = dirname(fileURLToPath(import.meta.url));
22
+ // packages/markii-core/src -> packages/markii-core -> packages -> repo root
23
+ return join(here, '..', '..', '..', 'conformance');
24
+ }
25
+ /**
26
+ * Base fixture names (no extension) for every `*.mk.md` file in `dir`,
27
+ * sorted. `.mk.md` is a COMPOUND extension (two dots) — the sibling name is
28
+ * derived by stripping the full `DOC_EXTENSION` suffix, not by slicing off
29
+ * whatever `path.extname()` would report (which would only strip `.md` and
30
+ * leave a stray `.mk` in the fixture name).
31
+ */
32
+ export function listCorpusNames(dir = conformanceDir()) {
33
+ return readdirSync(dir)
34
+ .filter((entry) => entry.endsWith(DOC_EXTENSION))
35
+ .map((entry) => entry.slice(0, -DOC_EXTENSION.length))
36
+ .sort();
37
+ }
38
+ /**
39
+ * Loads every `<name>.mk.md` / `<name>.json` pair from `dir` (default: the
40
+ * repo-root `conformance/` directory). A `.mk.md` file with no matching
41
+ * `.json` sibling throws (surfacing the missing fixture loudly rather than
42
+ * silently skipping a corpus case).
43
+ */
44
+ export function loadCorpusCases(dir = conformanceDir()) {
45
+ return listCorpusNames(dir).map((name) => {
46
+ const smdPath = join(dir, `${name}${DOC_EXTENSION}`);
47
+ const jsonPath = join(dir, `${name}.json`);
48
+ const input = readFileSync(smdPath, 'utf8');
49
+ const expected = JSON.parse(readFileSync(jsonPath, 'utf8'));
50
+ return { name, smdPath, jsonPath, input, expected };
51
+ });
52
+ }
53
+ /**
54
+ * Recursively strips every `position` field from a parsed tree (unist nodes
55
+ * carry `position` with line/column/offset info that's irrelevant to
56
+ * structural conformance and would make corpus fixtures brittle to
57
+ * reformat). Generic over any plain-data tree shape, not just mdast, so it
58
+ * doubles as a hast-position-stripper if a future corpus level needs one.
59
+ */
60
+ export function stripPositions(value) {
61
+ if (Array.isArray(value)) {
62
+ return value.map((item) => stripPositions(item));
63
+ }
64
+ if (value !== null && typeof value === 'object') {
65
+ const result = {};
66
+ for (const [key, val] of Object.entries(value)) {
67
+ if (key === 'position')
68
+ continue;
69
+ result[key] = stripPositions(val);
70
+ }
71
+ return result;
72
+ }
73
+ return value;
74
+ }
75
+ /** Deep-clones `value` with every object's keys sorted alphabetically. */
76
+ function sortKeysDeep(value) {
77
+ if (Array.isArray(value)) {
78
+ return value.map((item) => sortKeysDeep(item));
79
+ }
80
+ if (value !== null && typeof value === 'object') {
81
+ const source = value;
82
+ const sorted = {};
83
+ for (const key of Object.keys(source).sort()) {
84
+ sorted[key] = sortKeysDeep(source[key]);
85
+ }
86
+ return sorted;
87
+ }
88
+ return value;
89
+ }
90
+ /**
91
+ * Serializes `value` as pretty-printed JSON with a deterministic, sorted key
92
+ * order (so regenerated expected-AST fixtures diff cleanly and don't depend
93
+ * on incidental object-construction order) and a trailing newline.
94
+ */
95
+ export function stableStringify(value) {
96
+ return `${JSON.stringify(sortKeysDeep(value), null, 2)}\n`;
97
+ }
@@ -0,0 +1,3 @@
1
+ export { parse } from './parse.js';
2
+ export { toHast, isSafeUrl } from './to-hast.js';
3
+ export { extractScripts, parseMetaAttributes, type ScriptBlock, } from './scripts.js';
package/dist/index.js ADDED
@@ -0,0 +1,9 @@
1
+ // This is @markii/core's browser-safe entry point: parsing and hast conversion
2
+ // only. The conformance-corpus runner (`./corpus`) touches `node:fs` and is
3
+ // Node-only tooling for tests/scripts — it lives at the `@markii/core/corpus`
4
+ // subpath instead of here so a browser bundler consuming `@markii/core` (e.g.
5
+ // via `@markii/react`) never has to reason about Node built-ins reachable from
6
+ // its entry point.
7
+ export { parse } from './parse.js';
8
+ export { toHast, isSafeUrl } from './to-hast.js';
9
+ export { extractScripts, parseMetaAttributes, } from './scripts.js';
@@ -0,0 +1,17 @@
1
+ import type { Root } from 'mdast';
2
+ /**
3
+ * Parses Mark text into an mdast AST. CommonMark plus GitHub-Flavored
4
+ * Markdown (`remark-gfm`: tables, task lists, strikethrough, autolinks) plus
5
+ * the generic directive grammar (`remark-directive`) — nothing more. GFM and
6
+ * directives are independent micromark syntax extensions (GFM extends the
7
+ * table / list-item / inline grammar; directives add their own `:`/`::`/`:::`
8
+ * grammar) and compose without interference regardless of `.use()` order —
9
+ * confirmed empirically by the conformance corpus (a document combining a
10
+ * GFM table and a `:::` directive parses both correctly). This module is
11
+ * component-agnostic: it has no knowledge of the registry or any component,
12
+ * and must stay that way (directive nodes carry only a name, attributes,
13
+ * and children; interpreting a name is the renderer's job; GFM nodes are
14
+ * standard mdast — `table`, `listItem.checked`, `delete` — needing no
15
+ * component/registry coupling at all).
16
+ */
17
+ export declare function parse(text: string): Root;
package/dist/parse.js ADDED
@@ -0,0 +1,26 @@
1
+ import { unified } from 'unified';
2
+ import remarkParse from 'remark-parse';
3
+ import remarkDirective from 'remark-directive';
4
+ import remarkGfm from 'remark-gfm';
5
+ /**
6
+ * Parses Mark text into an mdast AST. CommonMark plus GitHub-Flavored
7
+ * Markdown (`remark-gfm`: tables, task lists, strikethrough, autolinks) plus
8
+ * the generic directive grammar (`remark-directive`) — nothing more. GFM and
9
+ * directives are independent micromark syntax extensions (GFM extends the
10
+ * table / list-item / inline grammar; directives add their own `:`/`::`/`:::`
11
+ * grammar) and compose without interference regardless of `.use()` order —
12
+ * confirmed empirically by the conformance corpus (a document combining a
13
+ * GFM table and a `:::` directive parses both correctly). This module is
14
+ * component-agnostic: it has no knowledge of the registry or any component,
15
+ * and must stay that way (directive nodes carry only a name, attributes,
16
+ * and children; interpreting a name is the renderer's job; GFM nodes are
17
+ * standard mdast — `table`, `listItem.checked`, `delete` — needing no
18
+ * component/registry coupling at all).
19
+ */
20
+ export function parse(text) {
21
+ const processor = unified()
22
+ .use(remarkParse)
23
+ .use(remarkDirective)
24
+ .use(remarkGfm);
25
+ return processor.parse(text);
26
+ }
@@ -0,0 +1,50 @@
1
+ import type { Code, Root } from 'mdast';
2
+ /**
3
+ * Slice 1 of the scripting-usability layer (DESIGN.md §8): this module only
4
+ * *reads* script blocks out of an already-parsed AST — it never executes
5
+ * anything, imports no Lua/wasmoon, and knows nothing about a value store.
6
+ * A script block is, at the parse layer, an ordinary fenced code block (see
7
+ * `to-hast.ts`/§8: "plain markdown viewers just show the code") — the only
8
+ * thing that distinguishes it from any other code fence is a `{...}`
9
+ * attribute group in its `meta` string carrying a `name`.
10
+ */
11
+ /**
12
+ * One script block found in a document, in document order. `lang` is the
13
+ * fence's language tag (`"lua"` for ` ```lua {name=...}` `, `""` if the
14
+ * fence had no language). `src`, when present, points at a bundle-relative
15
+ * long-script file (§8: "the block becomes a one-line reference,
16
+ * ` ```lua {src=scripts/etl.lua name=stars}` ` with an empty body"); `code`
17
+ * is still the fence's own body (empty for a `src=` reference).
18
+ */
19
+ export interface ScriptBlock {
20
+ name: string;
21
+ lang: string;
22
+ src?: string;
23
+ code: string;
24
+ position?: Code['position'];
25
+ }
26
+ /**
27
+ * Parses a fence `meta` string's `{...}` attribute group into a flat
28
+ * key/value map (bare `key` with no `=value` yields `''`). Returns an empty,
29
+ * null-prototype object when `meta` has no attribute group at all — the
30
+ * caller treats a missing `name` key the same way either way (not a
31
+ * script), but the null prototype keeps a lookup like `attrs.constructor`
32
+ * from ever resolving to an inherited `Object.prototype` member, matching
33
+ * the defensive pattern already used for the registry (`registry.ts`) and
34
+ * the URL-attribute lookup (`to-hast.ts`).
35
+ *
36
+ * Exported so a renderer (e.g. `@markii/react`) that wants to tell a script
37
+ * block from ordinary code can reuse this exact grammar instead of
38
+ * duplicating the brace/quote/token-scanning logic — `to-hast.ts` preserves
39
+ * the raw fence `meta` string onto the hast `<code>` element for exactly
40
+ * this purpose (see its `preserveCodeMeta` plugin).
41
+ */
42
+ export declare function parseMetaAttributes(meta: string | null | undefined): Record<string, string>;
43
+ /**
44
+ * Walks a parsed mdast `Root` and returns every script block, in document
45
+ * 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.
49
+ */
50
+ export declare function extractScripts(tree: Root): ScriptBlock[];
@@ -0,0 +1,94 @@
1
+ import { visit } from 'unist-util-visit';
2
+ /**
3
+ * Finds the first brace-delimited attribute group (`{...}`) in `source`,
4
+ * returning its inner text (without the braces), or `undefined` if none is
5
+ * present. Braces inside a quoted attribute value are ignored while
6
+ * scanning for the closing `}`, so `{title="a }b" name=x}` still finds the
7
+ * real close brace rather than the one inside the quotes.
8
+ */
9
+ function findAttributeGroup(source) {
10
+ const start = source.indexOf('{');
11
+ if (start === -1)
12
+ return undefined;
13
+ let quote;
14
+ for (let i = start + 1; i < source.length; i++) {
15
+ const ch = source[i];
16
+ if (quote) {
17
+ if (ch === quote)
18
+ quote = undefined;
19
+ continue;
20
+ }
21
+ if (ch === '"' || ch === "'") {
22
+ quote = ch;
23
+ continue;
24
+ }
25
+ if (ch === '}')
26
+ return source.slice(start + 1, i);
27
+ }
28
+ return undefined;
29
+ }
30
+ /**
31
+ * Token grammar inside a `{...}` attribute group: `key`, `key=bareValue`,
32
+ * `key="quoted value"`, or `key='quoted value'`. Whitespace-separated.
33
+ */
34
+ const ATTRIBUTE_TOKEN = /([A-Za-z_][\w-]*)(?:=(?:"([^"]*)"|'([^']*)'|(\S+)))?/g;
35
+ /**
36
+ * Parses a fence `meta` string's `{...}` attribute group into a flat
37
+ * key/value map (bare `key` with no `=value` yields `''`). Returns an empty,
38
+ * null-prototype object when `meta` has no attribute group at all — the
39
+ * caller treats a missing `name` key the same way either way (not a
40
+ * script), but the null prototype keeps a lookup like `attrs.constructor`
41
+ * from ever resolving to an inherited `Object.prototype` member, matching
42
+ * the defensive pattern already used for the registry (`registry.ts`) and
43
+ * the URL-attribute lookup (`to-hast.ts`).
44
+ *
45
+ * Exported so a renderer (e.g. `@markii/react`) that wants to tell a script
46
+ * block from ordinary code can reuse this exact grammar instead of
47
+ * duplicating the brace/quote/token-scanning logic — `to-hast.ts` preserves
48
+ * the raw fence `meta` string onto the hast `<code>` element for exactly
49
+ * this purpose (see its `preserveCodeMeta` plugin).
50
+ */
51
+ export function parseMetaAttributes(meta) {
52
+ const attrs = Object.create(null);
53
+ if (!meta)
54
+ return attrs;
55
+ const group = findAttributeGroup(meta);
56
+ if (group === undefined)
57
+ return attrs;
58
+ ATTRIBUTE_TOKEN.lastIndex = 0;
59
+ let match;
60
+ while ((match = ATTRIBUTE_TOKEN.exec(group)) !== null) {
61
+ const key = match[1];
62
+ if (!key)
63
+ continue;
64
+ attrs[key] = match[2] ?? match[3] ?? match[4] ?? '';
65
+ }
66
+ return attrs;
67
+ }
68
+ /**
69
+ * Walks a parsed mdast `Root` and returns every script block, in document
70
+ * 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.
74
+ */
75
+ export function extractScripts(tree) {
76
+ const blocks = [];
77
+ visit(tree, 'code', (node) => {
78
+ const attrs = parseMetaAttributes(node.meta);
79
+ const name = attrs.name;
80
+ if (!name)
81
+ return;
82
+ const block = {
83
+ name,
84
+ lang: node.lang ?? '',
85
+ code: node.value,
86
+ };
87
+ if (attrs.src)
88
+ block.src = attrs.src;
89
+ if (node.position)
90
+ block.position = node.position;
91
+ blocks.push(block);
92
+ });
93
+ return blocks;
94
+ }
@@ -0,0 +1,40 @@
1
+ import type { Root as HastRoot } from 'hast';
2
+ /**
3
+ * Modelled on react-markdown's `defaultUrlTransform`, though our allowlist
4
+ * differs from theirs (we add `tel`; we drop `ircs` and `xmpp`) — see
5
+ * `SAFE_URL_PROTOCOLS` above for the actual list. A URL with no scheme
6
+ * (relative, fragment-only `#...`, or query-only `?...`) is always allowed;
7
+ * a URL with a scheme is allowed only if that scheme is in
8
+ * `SAFE_URL_PROTOCOLS`. Finding the "scheme" the same way a browser does —
9
+ * text before the first `:`, but only when that `:` comes before any `/`,
10
+ * `?`, or `#` — is what correctly rejects tricks like a leading space
11
+ * (`" javascript:..."`, whose slice-before-colon is `" javascript"`, which
12
+ * never matches a bare protocol name) while still allowing a same-origin
13
+ * path that happens to contain a colon later on.
14
+ *
15
+ * Exported (not just used by `sanitizeUrls` below) because a directive
16
+ * *attribute* that a renderer maps straight to a DOM URL prop — e.g.
17
+ * `@markii/react`'s `figure` component putting a `src` attribute into
18
+ * `<img src>` — never passes through `sanitizeUrls`, which only walks the
19
+ * hast tree `remark-rehype` itself produces from markdown links/images.
20
+ * Such a renderer must run the attribute value through this same check
21
+ * itself, rather than re-implementing URL-scheme parsing, to get the exact
22
+ * same guarantee.
23
+ */
24
+ export declare function isSafeUrl(url: string): boolean;
25
+ /**
26
+ * Converts Mark text straight to a sanitized hast tree: parse (mdast, with
27
+ * directive + GFM syntax extensions) -> tag directive nodes + preserve
28
+ * code-fence meta for hast conversion -> remark-rehype (hast) -> strip
29
+ * unsafe URLs. GFM constructs (tables, task lists, strikethrough, autolinks)
30
+ * need no tagging step of their own — `remark-rehype`'s default handlers
31
+ * already turn them into standard `<table>`/`<input type=checkbox>`/`<del>`/
32
+ * `<a>` hast elements, and `sanitizeUrls` below covers every `<a href>`/
33
+ * `<img src>` regardless of whether it came from a GFM autolink or an
34
+ * ordinary link. This is the framework-agnostic half of rendering —
35
+ * `@markii/react` (or any other renderer) turns this hast tree into its own
36
+ * component tree, resolving `<mk-directive>` elements through a registry and
37
+ * `data-mk-meta` on `<code>` elements however it likes (e.g. a collapsed
38
+ * script marker).
39
+ */
40
+ export declare function toHast(text: string): HastRoot;
@@ -0,0 +1,176 @@
1
+ import { unified } from 'unified';
2
+ import remarkParse from 'remark-parse';
3
+ import remarkDirective from 'remark-directive';
4
+ import remarkGfm from 'remark-gfm';
5
+ import remarkRehype from 'remark-rehype';
6
+ import { visit } from 'unist-util-visit';
7
+ /** The hast tag name our directive-tagging plugin marks directive nodes with. */
8
+ const DIRECTIVE_TAG = 'mk-directive';
9
+ function isDirectiveNode(node) {
10
+ return (node.type === 'containerDirective' ||
11
+ node.type === 'leafDirective' ||
12
+ node.type === 'textDirective');
13
+ }
14
+ /**
15
+ * Small remark plugin: tags every directive node with `data.hName` /
16
+ * `data.hProperties` so that `remark-rehype`'s default mdast->hast
17
+ * conversion turns it into a `<mk-directive>` hast element carrying the
18
+ * directive's name, raw attributes, and shape (inline vs block) as data-*
19
+ * properties. Inner markdown is left untouched, so it converts to hast
20
+ * exactly like any other node — this is what makes it become
21
+ * already-rendered `children` on the renderer side.
22
+ */
23
+ const tagDirectiveNodes = () => (tree) => {
24
+ visit(tree, (node) => {
25
+ if (!isDirectiveNode(node))
26
+ return;
27
+ node.data = {
28
+ ...node.data,
29
+ hName: DIRECTIVE_TAG,
30
+ hProperties: {
31
+ 'data-mk-name': node.name,
32
+ 'data-mk-attrs': JSON.stringify(normalizeAttributes(node.attributes)),
33
+ 'data-mk-kind': node.type,
34
+ },
35
+ };
36
+ });
37
+ };
38
+ /**
39
+ * mdast-util-directive represents a bare (valueless) attribute, e.g.
40
+ * `{collapsed}`, as an empty string. The registry-facing contract instead
41
+ * uses `null` for "present but valueless" — this normalization is what
42
+ * lands in the serialized `data-mk-attrs` JSON that the renderer parses
43
+ * back out.
44
+ */
45
+ function normalizeAttributes(attributes) {
46
+ const result = {};
47
+ for (const [key, value] of Object.entries(attributes ?? {})) {
48
+ result[key] = value === '' || value == null ? null : value;
49
+ }
50
+ return result;
51
+ }
52
+ /**
53
+ * The hast/DOM attribute a fenced code block's raw `meta` string (e.g.
54
+ * `"{name=stars}"`) is preserved onto, since `mdast-util-to-hast`'s default
55
+ * `code` handler drops mdast `meta` entirely when building the hast
56
+ * `<code>` element.
57
+ */
58
+ const CODE_META_ATTR = 'data-mk-meta';
59
+ /**
60
+ * Small remark plugin: copies a code fence's raw `meta` string onto the
61
+ * mdast `code` node's `data.hProperties`, so it survives the mdast->hast
62
+ * conversion as a `data-mk-meta` attribute on the resulting `<code>`
63
+ * element (`mdast-util-to-hast`'s `code` handler applies `hProperties` to
64
+ * the `<code>` it builds, not the wrapping `<pre>` — see its
65
+ * `handlers/code.js`). This is deliberately generic meta *preservation*,
66
+ * not script detection: `@markii/core` stays framework-agnostic and knows
67
+ * nothing about "script blocks" here. A renderer that cares (e.g.
68
+ * `@markii/react`) reads this attribute and parses it with this package's
69
+ * `parseMetaAttributes` (`scripts.ts`) to decide whether a code block is a
70
+ * script; a renderer that doesn't care ignores the attribute and the block
71
+ * still renders as plain `<pre><code>` either way.
72
+ */
73
+ const preserveCodeMeta = () => (tree) => {
74
+ visit(tree, 'code', (node) => {
75
+ if (!node.meta)
76
+ return;
77
+ node.data = {
78
+ ...node.data,
79
+ hProperties: { [CODE_META_ATTR]: node.meta },
80
+ };
81
+ });
82
+ };
83
+ /** URL schemes allowed in `href`/`src`, matched case-insensitively. */
84
+ const SAFE_URL_PROTOCOLS = new Set(['http', 'https', 'mailto', 'tel']);
85
+ /**
86
+ * Modelled on react-markdown's `defaultUrlTransform`, though our allowlist
87
+ * differs from theirs (we add `tel`; we drop `ircs` and `xmpp`) — see
88
+ * `SAFE_URL_PROTOCOLS` above for the actual list. A URL with no scheme
89
+ * (relative, fragment-only `#...`, or query-only `?...`) is always allowed;
90
+ * a URL with a scheme is allowed only if that scheme is in
91
+ * `SAFE_URL_PROTOCOLS`. Finding the "scheme" the same way a browser does —
92
+ * text before the first `:`, but only when that `:` comes before any `/`,
93
+ * `?`, or `#` — is what correctly rejects tricks like a leading space
94
+ * (`" javascript:..."`, whose slice-before-colon is `" javascript"`, which
95
+ * never matches a bare protocol name) while still allowing a same-origin
96
+ * path that happens to contain a colon later on.
97
+ *
98
+ * Exported (not just used by `sanitizeUrls` below) because a directive
99
+ * *attribute* that a renderer maps straight to a DOM URL prop — e.g.
100
+ * `@markii/react`'s `figure` component putting a `src` attribute into
101
+ * `<img src>` — never passes through `sanitizeUrls`, which only walks the
102
+ * hast tree `remark-rehype` itself produces from markdown links/images.
103
+ * Such a renderer must run the attribute value through this same check
104
+ * itself, rather than re-implementing URL-scheme parsing, to get the exact
105
+ * same guarantee.
106
+ */
107
+ export function isSafeUrl(url) {
108
+ const colon = url.indexOf(':');
109
+ if (colon === -1)
110
+ return true;
111
+ const slash = url.indexOf('/');
112
+ const questionMark = url.indexOf('?');
113
+ const numberSign = url.indexOf('#');
114
+ const hasSchemeBeforeDelimiter = (slash === -1 || colon < slash) &&
115
+ (questionMark === -1 || colon < questionMark) &&
116
+ (numberSign === -1 || colon < numberSign);
117
+ if (!hasSchemeBeforeDelimiter)
118
+ return true;
119
+ return SAFE_URL_PROTOCOLS.has(url.slice(0, colon).toLowerCase());
120
+ }
121
+ /**
122
+ * hast tag name -> the one attribute on it that carries a URL. Null-prototype
123
+ * (rather than a plain object literal) so a hast tag named `constructor`,
124
+ * `toString`, etc. must miss this lookup rather than resolve to an
125
+ * inherited `Object.prototype` member.
126
+ */
127
+ const URL_ATTRIBUTE_BY_TAG = Object.assign(Object.create(null), { a: 'href', img: 'src' });
128
+ /**
129
+ * Strips `href` on `<a>` and `src` on `<img>` when they hold an unsafe URL
130
+ * (e.g. `javascript:`, `data:text/html`), mutating the hast tree in place.
131
+ * Runs after `remark-rehype`, so it covers every link/image the document
132
+ * produces regardless of source (CommonMark autolinks, `[text](url)`, raw
133
+ * `<a href>` — remark-rehype normalizes all of them to hast `element` nodes
134
+ * by this point). The element and its children are kept — only the
135
+ * dangerous attribute is dropped — so link text still renders, per the
136
+ * fallback-not-failure spirit of Architecture rule 3.
137
+ */
138
+ function sanitizeUrls(tree) {
139
+ visit(tree, 'element', (node) => {
140
+ const attr = URL_ATTRIBUTE_BY_TAG[node.tagName];
141
+ if (!attr)
142
+ return;
143
+ const value = node.properties[attr];
144
+ if (typeof value === 'string' && !isSafeUrl(value)) {
145
+ delete node.properties[attr];
146
+ }
147
+ });
148
+ }
149
+ /**
150
+ * Converts Mark text straight to a sanitized hast tree: parse (mdast, with
151
+ * directive + GFM syntax extensions) -> tag directive nodes + preserve
152
+ * code-fence meta for hast conversion -> remark-rehype (hast) -> strip
153
+ * unsafe URLs. GFM constructs (tables, task lists, strikethrough, autolinks)
154
+ * need no tagging step of their own — `remark-rehype`'s default handlers
155
+ * already turn them into standard `<table>`/`<input type=checkbox>`/`<del>`/
156
+ * `<a>` hast elements, and `sanitizeUrls` below covers every `<a href>`/
157
+ * `<img src>` regardless of whether it came from a GFM autolink or an
158
+ * ordinary link. This is the framework-agnostic half of rendering —
159
+ * `@markii/react` (or any other renderer) turns this hast tree into its own
160
+ * component tree, resolving `<mk-directive>` elements through a registry and
161
+ * `data-mk-meta` on `<code>` elements however it likes (e.g. a collapsed
162
+ * script marker).
163
+ */
164
+ 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);
172
+ const mdastTree = processor.parse(text);
173
+ const hastTree = processor.runSync(mdastTree);
174
+ sanitizeUrls(hastTree);
175
+ return hastTree;
176
+ }
package/package.json ADDED
@@ -0,0 +1,67 @@
1
+ {
2
+ "name": "@markii/core",
3
+ "version": "0.1.0",
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
+ "keywords": [
6
+ "markdown",
7
+ "mark",
8
+ "mk.md",
9
+ "mdast",
10
+ "hast",
11
+ "unified",
12
+ "directive",
13
+ "parser"
14
+ ],
15
+ "license": "MIT",
16
+ "author": "sadigaxund",
17
+ "homepage": "https://github.com/sadigaxund/markii#readme",
18
+ "bugs": "https://github.com/sadigaxund/markii/issues",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/sadigaxund/markii.git",
22
+ "directory": "packages/markii-core"
23
+ },
24
+ "type": "module",
25
+ "main": "./dist/index.js",
26
+ "module": "./dist/index.js",
27
+ "types": "./dist/index.d.ts",
28
+ "exports": {
29
+ ".": {
30
+ "types": "./dist/index.d.ts",
31
+ "import": "./dist/index.js",
32
+ "default": "./dist/index.js"
33
+ },
34
+ "./corpus": {
35
+ "types": "./dist/corpus.d.ts",
36
+ "import": "./dist/corpus.js",
37
+ "default": "./dist/corpus.js"
38
+ }
39
+ },
40
+ "files": [
41
+ "dist"
42
+ ],
43
+ "publishConfig": {
44
+ "access": "public"
45
+ },
46
+ "scripts": {
47
+ "test": "vitest run",
48
+ "build": "tsc --noEmit -p tsconfig.json",
49
+ "build:dist": "rm -rf dist && tsc -p tsconfig.build.json",
50
+ "lint": "eslint .",
51
+ "corpus:regen": "node scripts/regen-corpus.ts"
52
+ },
53
+ "dependencies": {
54
+ "mdast-util-directive": "^3.1.0",
55
+ "remark-directive": "^4.0.0",
56
+ "remark-gfm": "^4.0.1",
57
+ "remark-parse": "^11.0.0",
58
+ "remark-rehype": "^11.1.2",
59
+ "unified": "^11.0.5",
60
+ "unist-util-visit": "^5.1.0"
61
+ },
62
+ "devDependencies": {
63
+ "@types/hast": "^3.0.5",
64
+ "@types/mdast": "^4.0.4",
65
+ "prettier": "^3.9.6"
66
+ }
67
+ }