@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 +21 -0
- package/README.md +103 -0
- package/dist/src/core/Markdown.d.ts +86 -0
- package/dist/src/core/constants.d.ts +17 -0
- package/dist/src/core/factories.d.ts +92 -0
- package/dist/src/core/helpers.d.ts +362 -0
- package/dist/src/core/index.d.ts +8 -0
- package/dist/src/core/index.js +2833 -0
- package/dist/src/core/index.js.map +1 -0
- package/dist/src/core/parsers.d.ts +51 -0
- package/dist/src/core/shapers.d.ts +104 -0
- package/dist/src/core/types.d.ts +269 -0
- package/dist/src/core/validators.d.ts +190 -0
- package/package.json +73 -0
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>;
|