@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 +21 -0
- package/README.md +5 -0
- package/dist/corpus.d.ts +48 -0
- package/dist/corpus.js +97 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +9 -0
- package/dist/parse.d.ts +17 -0
- package/dist/parse.js +26 -0
- package/dist/scripts.d.ts +50 -0
- package/dist/scripts.js +94 -0
- package/dist/to-hast.d.ts +40 -0
- package/dist/to-hast.js +176 -0
- package/package.json +67 -0
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.
|
package/dist/corpus.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/index.d.ts
ADDED
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';
|
package/dist/parse.d.ts
ADDED
|
@@ -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[];
|
package/dist/scripts.js
ADDED
|
@@ -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;
|
package/dist/to-hast.js
ADDED
|
@@ -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
|
+
}
|