@orkestrel/markdown 0.0.1

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 Orkestrel
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,103 @@
1
+ # @orkestrel/markdown
2
+
3
+ A zero-surprise, types-first markdown parser — a hand-written scanner turns
4
+ GitHub-Flavored Markdown into a typed AST (a discriminated union keyed by
5
+ `element`), and a separate renderer projects that AST to sanitized, XSS-safe
6
+ HTML. Total and depth-capped throughout: malformed or pathologically deep
7
+ input degrades to literal text instead of throwing. Part of the `@orkestrel`
8
+ line.
9
+
10
+ ## Install
11
+
12
+ ```sh
13
+ npm install @orkestrel/markdown
14
+ ```
15
+
16
+ ## Requirements
17
+
18
+ - Node.js >= 24
19
+ - ESM-only (no CommonJS build)
20
+
21
+ ## Usage
22
+
23
+ ```ts
24
+ import { createMarkdown, renderHTML } from '@orkestrel/markdown'
25
+
26
+ const markdown = createMarkdown('# Hi\n\nRead the [guide](./guide.md) for more, *thanks*.')
27
+ markdown.document
28
+ // { element: 'document', children: [...] } — the typed, render-agnostic AST
29
+
30
+ renderHTML(markdown.document)
31
+ // '<h1>Hi</h1>\n<p>Read the <a href="./guide.md">guide</a> for more, <em>thanks</em>.</p>'
32
+ ```
33
+
34
+ `createMarkdown(markdown)` (or `new Markdown(markdown)`) runs a two-phase
35
+ parse (block phase, then inline phase) and stores the result as a stateful
36
+ workspace's `document` — a render-agnostic `MarkdownDocument`. The workspace
37
+ also exposes `find` / `filter` / `map` / `reduce` / `fold` / `stream` /
38
+ iteration over the AST. `renderHTML(node)` HTML-escapes all text and
39
+ attributes and sanitizes link `href`s (an unsafe scheme like `javascript:` or
40
+ `data:` is dropped), so even hostile content cannot inject markup or script.
41
+ `renderMarkdown(node)` writes canonical markdown source back out — a
42
+ `parseDocument(renderMarkdown(doc))` round-trip always deep-equals `doc`. A
43
+ fold projects the AST to any shape (a plain string, a DOM tree, a count)
44
+ through one total, per-element handler table, with no writer coupling built
45
+ in.
46
+
47
+ ## Validating untrusted ASTs
48
+
49
+ A parsed or deserialized AST crossing a trust boundary (an RPC payload, a
50
+ cached document) can be checked without throwing:
51
+
52
+ ```ts
53
+ import { isMarkdownNode } from '@orkestrel/markdown'
54
+
55
+ isMarkdownNode({ element: 'text', value: 'hi' }) // true
56
+ isMarkdownNode({ element: 'bogus' }) // false
57
+ ```
58
+
59
+ `isMarkdownNode`, `isMarkdownDocument`, `isBlockNode`, and `isInlineNode` are
60
+ total guards — safe to call on cyclic or adversarial input, even deeply
61
+ nested structures.
62
+
63
+ ## Contract-backed leaf shapes
64
+
65
+ The non-recursive leaf nodes (`TextNode`, `CodeSpanNode`, `CodeBlockNode`,
66
+ `ThematicBreakNode`) each have a compiled contract — a guard, parser, JSON
67
+ Schema, and seeded generator from one shape declaration, built on
68
+ `@orkestrel/contract`:
69
+
70
+ ```ts
71
+ import { createTextContract } from '@orkestrel/markdown'
72
+
73
+ const text = createTextContract()
74
+ text.schema // the compiled JSON Schema
75
+ text.generate() // a seeded, schema-valid TextNode
76
+ ```
77
+
78
+ ## Safety notes
79
+
80
+ - `renderHTML`'s `href`s are restricted to a safe scheme allowlist (`http`,
81
+ `https`, `mailto`, `tel`, or scheme-less/relative/anchor links) — anything
82
+ else is dropped.
83
+ - All of `renderHTML`'s rendered text and attributes are HTML-escaped.
84
+ `renderMarkdown` is not an HTML boundary — it writes markdown source, not
85
+ markup — so no HTML-escaping applies there.
86
+ - Parsing and rendering are depth-capped (`MAX_DEPTH`); past that depth the
87
+ parser/writer degrades to literal text instead of recursing further or
88
+ throwing.
89
+
90
+ ## Guide
91
+
92
+ For the full surface — the AST shape, the two-phase parse, GFM tables, and
93
+ the contract-backed leaf shapes — see
94
+ [`guides/src/markdown.md`](guides/src/markdown.md).
95
+
96
+ ## Package
97
+
98
+ Published as a single typed entry point per the `exports` field in
99
+ `package.json`.
100
+
101
+ ## License
102
+
103
+ MIT © [Orkestrel](https://github.com/orkestrel) — see [LICENSE](./LICENSE).
@@ -0,0 +1,86 @@
1
+ import type { BlockNode, MarkdownDocument, MarkdownHandlers, MarkdownInterface, MarkdownNode, MarkdownRewriteHandler } from './types.js';
2
+ /**
3
+ * A stateful, parsed markdown document - wraps a typed {@link MarkdownDocument} AST
4
+ * with the query (`find` / `filter` / `reduce` / iteration), rewrite (`map`), fold, and
5
+ * streaming operations {@link MarkdownInterface} declares.
6
+ *
7
+ * @remarks
8
+ * - **Construction.** Given a `string`, the constructor runs {@link parseDocument} (the
9
+ * block phase then the inline phase) to build the AST. Given a {@link MarkdownDocument},
10
+ * the document is adopted AS-IS and is NOT re-validated - a caller adopting an
11
+ * untrusted value should gate it with `isMarkdownDocument` first.
12
+ * - **Immutable.** {@link map} never mutates the stored AST - it returns a NEW `Markdown`
13
+ * instance; the document root invariant (`element: 'document'`) always holds.
14
+ * - **Traversal order.** {@link walk} and the `find` / `filter` / `reduce` queries built
15
+ * on it walk the AST depth-first, pre-order, root-inclusive (via {@link walkNodes});
16
+ * `stream` is shallow - only the document's direct block children.
17
+ *
18
+ * @example
19
+ * ```ts
20
+ * import { Markdown, isHeadingNode, renderMarkdown } from '@src/core'
21
+ *
22
+ * const markdown = new Markdown('# Title\n\nA **bold** [link](https://x.dev).')
23
+ * const heading = markdown.find(isHeadingNode) // the HeadingNode, or undefined
24
+ * const shouted = markdown.map((node) =>
25
+ * node.element === 'text' ? { element: 'text', value: node.value.toUpperCase() } : node,
26
+ * )
27
+ * renderMarkdown(shouted.document) // '# TITLE\n\nA **BOLD** [LINK](https://x.dev).'
28
+ * ```
29
+ */
30
+ export declare class Markdown implements MarkdownInterface {
31
+ #private;
32
+ constructor(input: string | MarkdownDocument);
33
+ /** The stored {@link MarkdownDocument} AST root. */
34
+ get document(): MarkdownDocument;
35
+ /**
36
+ * THE deep traversal - a lazy, depth-first, pre-order, root-inclusive generator
37
+ * over every {@link MarkdownNode} in the document. `find` / `filter` / `reduce`
38
+ * all iterate this single traversal.
39
+ *
40
+ * @example
41
+ * ```ts
42
+ * for (const node of markdown.walk()) {
43
+ * // every node, depth-first, pre-order, root-inclusive
44
+ * }
45
+ *
46
+ * // also consumable by for-await - JS accepts a sync iterable in for-await
47
+ * for await (const node of markdown.walk()) {
48
+ * // same sequence, no separate async iterator needed
49
+ * }
50
+ * ```
51
+ */
52
+ walk(): Generator<MarkdownNode>;
53
+ find<T extends MarkdownNode>(guard: (node: MarkdownNode) => node is T): T | undefined;
54
+ find(predicate: (node: MarkdownNode) => boolean): MarkdownNode | undefined;
55
+ filter<T extends MarkdownNode>(guard: (node: MarkdownNode) => node is T): readonly T[];
56
+ filter(predicate: (node: MarkdownNode) => boolean): readonly MarkdownNode[];
57
+ /** Rewrites the AST bottom-up (copy-on-write) and returns a new {@link Markdown}. */
58
+ map(rewrite: MarkdownRewriteHandler): MarkdownInterface;
59
+ /** Folds the AST depth-first, pre-order into an accumulator. */
60
+ reduce<T>(callback: (accumulator: T, node: MarkdownNode) => T, initial: T): T;
61
+ /** Runs a total catamorphism over the document using a {@link MarkdownHandlers} table. */
62
+ fold<T>(handlers: MarkdownHandlers<T>): T;
63
+ /**
64
+ * A web-standard {@link ReadableStream} over the document's top-level block nodes
65
+ * (shallow, source order) - a fresh, pull-based source per call: one block is
66
+ * enqueued per `pull`, so a slow reader's backpressure is respected. Cancellable,
67
+ * async-iterable wherever the platform supports it (Node, Deno), and pipeable
68
+ * through any {@link TransformStream} / {@link WritableStream}.
69
+ *
70
+ * @example
71
+ * ```ts
72
+ * // universal - works in every ReadableStream-supporting environment
73
+ * const reader = markdown.stream().getReader()
74
+ * for (let result = await reader.read(); !result.done; result = await reader.read()) {
75
+ * console.log(result.value) // one BlockNode
76
+ * }
77
+ *
78
+ * // Node / Deno / Firefox support async iteration of ReadableStream natively;
79
+ * // other environments should use the reader loop above instead.
80
+ * for await (const block of markdown.stream()) {
81
+ * console.log(block)
82
+ * }
83
+ * ```
84
+ */
85
+ stream(): ReadableStream<BlockNode>;
86
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The URL schemes `renderHTML` permits on a link `href` - anything else (notably
3
+ * `javascript:`, `data:`, `vbscript:`, `file:`) is dropped to an empty `href` so a
4
+ * hostile link can never execute. Frozen, lower-case; a relative / anchor /
5
+ * scheme-less `href` (no `scheme:` prefix) is always allowed.
6
+ */
7
+ export declare const SAFE_URL_SCHEMES: ReadonlySet<string>;
8
+ /**
9
+ * The maximum recursion depth the parse pipeline (`parseDocument` and its
10
+ * `parsers.ts` helpers) and the `helpers.ts` traversal / render functions
11
+ * (`renderHTML`, `renderMarkdown`, `walkNodes`, `foldNode`) honor before degrading to
12
+ * literal text - bounds blockquote nesting, inline nesting (emphasis / links), and
13
+ * traversal/render recursion so pathological or hostile input (deeply nested
14
+ * blockquotes, runaway emphasis) cannot exhaust the call stack. Past this depth the
15
+ * parser treats the remaining content as literal text instead of recursing further.
16
+ */
17
+ export declare const MAX_DEPTH = 64;
@@ -0,0 +1,92 @@
1
+ import type { ContractInterface } from '@orkestrel/contract';
2
+ import type { CodeBlockNode, CodeSpanNode, MarkdownDocument, MarkdownInterface, TextNode, ThematicBreakNode } from './types.js';
3
+ /**
4
+ * Create a stateful markdown handle from a markdown string or an already-parsed
5
+ * {@link MarkdownDocument} - a typed AST plus the query, rewrite, and fold operations
6
+ * {@link MarkdownInterface} exposes.
7
+ *
8
+ * @remarks
9
+ * Given a `string`, runs a block phase (headings / paragraphs / lists / GFM tables /
10
+ * fenced code / blockquotes / thematic breaks) then an inline phase (emphasis /
11
+ * inline code / links) to build a render-agnostic {@link MarkdownDocument}. Given a
12
+ * {@link MarkdownDocument}, adopts it AS-IS without re-validation - gate an untrusted
13
+ * value with `isMarkdownDocument` first. Pure + total parse (malformed markdown
14
+ * degrades to text, never throws) and zero-dependency - a hand-written scanner, no
15
+ * regex-only structural parse, linear-time (no ReDoS).
16
+ *
17
+ * @param input - A markdown string to parse, or an already-parsed {@link MarkdownDocument}
18
+ * @returns A working {@link MarkdownInterface}
19
+ *
20
+ * @example
21
+ * ```ts
22
+ * import { createMarkdown } from '@src/core'
23
+ *
24
+ * const markdown = createMarkdown('# Hi\n\nRead the [guide](./guide.md).')
25
+ * markdown.document.children[0] // { element: 'heading', ... }
26
+ * ```
27
+ */
28
+ export declare function createMarkdown(input: string | MarkdownDocument): MarkdownInterface;
29
+ /**
30
+ * Compile the {@link textShape} into a {@link ContractInterface} for
31
+ * {@link TextNode} - a guard, coercing parser, JSON Schema, and seeded
32
+ * generator from one shape declaration (AGENTS §14).
33
+ *
34
+ * @returns A `TextNode` contract bundling `schema` / `is` / `parse` / `generate`
35
+ *
36
+ * @example
37
+ * ```ts
38
+ * import { createTextContract } from '@src/core'
39
+ *
40
+ * const text = createTextContract()
41
+ * text.is({ element: 'text', value: 'hi' }) // true
42
+ * ```
43
+ */
44
+ export declare function createTextContract(): ContractInterface<TextNode>;
45
+ /**
46
+ * Compile the {@link codeSpanShape} into a {@link ContractInterface} for
47
+ * {@link CodeSpanNode} - a guard, coercing parser, JSON Schema, and seeded
48
+ * generator from one shape declaration (AGENTS §14).
49
+ *
50
+ * @returns A `CodeSpanNode` contract bundling `schema` / `is` / `parse` / `generate`
51
+ *
52
+ * @example
53
+ * ```ts
54
+ * import { createCodeSpanContract } from '@src/core'
55
+ *
56
+ * const codeSpan = createCodeSpanContract()
57
+ * codeSpan.is({ element: 'codeSpan', value: 'const x = 1' }) // true
58
+ * ```
59
+ */
60
+ export declare function createCodeSpanContract(): ContractInterface<CodeSpanNode>;
61
+ /**
62
+ * Compile the {@link codeBlockShape} into a {@link ContractInterface} for
63
+ * {@link CodeBlockNode} - a guard, coercing parser, JSON Schema, and seeded
64
+ * generator from one shape declaration (AGENTS §14).
65
+ *
66
+ * @returns A `CodeBlockNode` contract bundling `schema` / `is` / `parse` / `generate`
67
+ *
68
+ * @example
69
+ * ```ts
70
+ * import { createCodeBlockContract } from '@src/core'
71
+ *
72
+ * const codeBlock = createCodeBlockContract()
73
+ * codeBlock.is({ element: 'codeBlock', code: 'x' }) // true
74
+ * ```
75
+ */
76
+ export declare function createCodeBlockContract(): ContractInterface<CodeBlockNode>;
77
+ /**
78
+ * Compile the {@link thematicBreakShape} into a {@link ContractInterface} for
79
+ * {@link ThematicBreakNode} - a guard, coercing parser, JSON Schema, and
80
+ * seeded generator from one shape declaration (AGENTS §14).
81
+ *
82
+ * @returns A `ThematicBreakNode` contract bundling `schema` / `is` / `parse` / `generate`
83
+ *
84
+ * @example
85
+ * ```ts
86
+ * import { createThematicBreakContract } from '@src/core'
87
+ *
88
+ * const thematicBreak = createThematicBreakContract()
89
+ * thematicBreak.is({ element: 'thematicBreak' }) // true
90
+ * ```
91
+ */
92
+ export declare function createThematicBreakContract(): ContractInterface<ThematicBreakNode>;