@jarenjs/md 0.34.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/README.md +520 -0
- package/dist/types/ast.d.ts +181 -0
- package/dist/types/bake.d.ts +61 -0
- package/dist/types/compiler.d.ts +141 -0
- package/dist/types/component/index.d.ts +101 -0
- package/dist/types/directives.d.ts +126 -0
- package/dist/types/entities.d.ts +40 -0
- package/dist/types/footnotes.d.ts +83 -0
- package/dist/types/frontmatter.d.ts +67 -0
- package/dist/types/html.d.ts +72 -0
- package/dist/types/index.d.ts +30 -0
- package/dist/types/loader.d.ts +84 -0
- package/dist/types/mdx.d.ts +45 -0
- package/dist/types/parser.d.ts +116 -0
- package/dist/types/plugins/highlight.d.ts +64 -0
- package/dist/types/plugins/index.d.ts +64 -0
- package/dist/types/plugins/mermaid.d.ts +12 -0
- package/dist/types/scanner.d.ts +240 -0
- package/dist/types/to-html.d.ts +104 -0
- package/dist/types/to-md.d.ts +23 -0
- package/dist/types/to-vnode.d.ts +161 -0
- package/dist/types/utils.d.ts +63 -0
- package/docs/LOADER.md +92 -0
- package/docs/MD-FORMAT.md +502 -0
- package/docs/PLUGINS.md +277 -0
- package/package.json +80 -0
- package/schemas/jaren-md-ast.schema.json +296 -0
- package/src/ast.js +346 -0
- package/src/bake.js +104 -0
- package/src/compiler.js +167 -0
- package/src/component/index.js +191 -0
- package/src/directives.js +371 -0
- package/src/entities.js +107 -0
- package/src/footnotes.js +180 -0
- package/src/frontmatter.js +947 -0
- package/src/html.js +281 -0
- package/src/index.js +76 -0
- package/src/loader.js +0 -0
- package/src/mdx.js +219 -0
- package/src/parser.js +1685 -0
- package/src/plugins/highlight.js +325 -0
- package/src/plugins/index.js +75 -0
- package/src/plugins/mermaid.js +14 -0
- package/src/scanner.js +832 -0
- package/src/to-html.js +425 -0
- package/src/to-md.js +396 -0
- package/src/to-vnode.js +766 -0
- package/src/utils.js +107 -0
- package/styles/md.css +238 -0
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Line-level scanners for the block parser.
|
|
3
|
+
*
|
|
4
|
+
* Pure, allocation-conscious functions that classify one (detabbed)
|
|
5
|
+
* line at a char-code level: does a construct start here, and where
|
|
6
|
+
* does its content begin? The parser in parser.js owns all state; this
|
|
7
|
+
* module owns none. Every regular expression is compiled once at
|
|
8
|
+
* module load — nothing in here builds a pattern per call.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* Thematic break: three or more `*`, `-` or `_` (same character),
|
|
12
|
+
* interleaved with spaces, nothing else on the line.
|
|
13
|
+
* @param {string} line
|
|
14
|
+
* @param {number} start first non-space offset
|
|
15
|
+
* @returns {boolean}
|
|
16
|
+
*/
|
|
17
|
+
export declare function scanThematicBreak(line: string, start: number): boolean;
|
|
18
|
+
/**
|
|
19
|
+
* ATX heading: `#{1,6}` followed by space or end of line. Returns the
|
|
20
|
+
* depth and the heading text (closing `#` run stripped), or null.
|
|
21
|
+
* @param {string} line
|
|
22
|
+
* @param {number} start first non-space offset
|
|
23
|
+
* @returns {{ depth: number, text: string } | null}
|
|
24
|
+
*/
|
|
25
|
+
export declare function scanAtxHeading(line: string, start: number): {
|
|
26
|
+
depth: number;
|
|
27
|
+
text: string;
|
|
28
|
+
} | null;
|
|
29
|
+
/**
|
|
30
|
+
* Code fence opener: three or more backticks or tildes. A backtick
|
|
31
|
+
* fence's info string may not contain a backtick.
|
|
32
|
+
* @param {string} line
|
|
33
|
+
* @param {number} start first non-space offset
|
|
34
|
+
* @returns {{ marker: number, length: number, info: string } | null}
|
|
35
|
+
*/
|
|
36
|
+
export declare function scanFenceOpen(line: string, start: number): {
|
|
37
|
+
marker: number;
|
|
38
|
+
length: number;
|
|
39
|
+
info: string;
|
|
40
|
+
} | null;
|
|
41
|
+
/**
|
|
42
|
+
* Does this line close a fence opened with `marker` × `length`?
|
|
43
|
+
* @param {string} line
|
|
44
|
+
* @param {number} marker
|
|
45
|
+
* @param {number} length
|
|
46
|
+
* @returns {boolean}
|
|
47
|
+
*/
|
|
48
|
+
export declare function scanFenceClose(line: string, marker: number, length: number): boolean;
|
|
49
|
+
/**
|
|
50
|
+
* Split a fence info string into `lang` (first word) and `meta` (the
|
|
51
|
+
* rest), resolving backslash escapes and character references in both.
|
|
52
|
+
* @param {string} info
|
|
53
|
+
* @returns {{ lang: string|null, meta: string|null }}
|
|
54
|
+
*/
|
|
55
|
+
export declare function splitFenceInfo(info: string): {
|
|
56
|
+
lang: string | null;
|
|
57
|
+
meta: string | null;
|
|
58
|
+
};
|
|
59
|
+
/**
|
|
60
|
+
* Blockquote marker at `offset`: `>` with an optional following space.
|
|
61
|
+
* Returns the content offset, or -1.
|
|
62
|
+
* @param {string} line
|
|
63
|
+
* @param {number} offset first non-space offset
|
|
64
|
+
* @returns {number}
|
|
65
|
+
*/
|
|
66
|
+
export declare function scanBlockquote(line: string, offset: number): number;
|
|
67
|
+
/**
|
|
68
|
+
* List marker: `-`/`+`/`*` bullet or `1.`/`1)` ordered (start ≤ 9
|
|
69
|
+
* digits), followed by a space or line end. Returns the marker
|
|
70
|
+
* geometry the parser turns into a list container, or null.
|
|
71
|
+
* @param {string} line
|
|
72
|
+
* @param {number} start first non-space offset
|
|
73
|
+
* @returns {{ ordered: boolean, bullet: string, start: number,
|
|
74
|
+
* delimiter: string, contentOffset: number } | null}
|
|
75
|
+
*/
|
|
76
|
+
export declare function scanListMarker(line: string, start: number): {
|
|
77
|
+
ordered: boolean;
|
|
78
|
+
bullet: string;
|
|
79
|
+
start: number;
|
|
80
|
+
delimiter: string;
|
|
81
|
+
contentOffset: number;
|
|
82
|
+
} | null;
|
|
83
|
+
/**
|
|
84
|
+
* Setext underline under an open paragraph: `=` run (depth 1) or `-`
|
|
85
|
+
* run (depth 2), possibly space-padded. Returns 0 when neither.
|
|
86
|
+
* @param {string} line
|
|
87
|
+
* @param {number} start first non-space offset
|
|
88
|
+
* @returns {number}
|
|
89
|
+
*/
|
|
90
|
+
export declare function scanSetextUnderline(line: string, start: number): number;
|
|
91
|
+
/**
|
|
92
|
+
* GFM table delimiter row: cells of `---`, `:--`, `--:`, `:-:` split
|
|
93
|
+
* by pipes. Returns the alignment array, or null.
|
|
94
|
+
* @param {string} line
|
|
95
|
+
* @returns {(string|null)[] | null}
|
|
96
|
+
*/
|
|
97
|
+
export declare function scanTableDelimiter(line: string): (string | null)[] | null;
|
|
98
|
+
/**
|
|
99
|
+
* Split a table row into raw cell strings on unescaped `|`, honoring
|
|
100
|
+
* `\|` and pipes inside backtick code spans. Leading and trailing
|
|
101
|
+
* empty cells from outer pipes are dropped. Returns null when the line
|
|
102
|
+
* contains no pipe at all.
|
|
103
|
+
* @param {string} line
|
|
104
|
+
* @returns {string[] | null}
|
|
105
|
+
*/
|
|
106
|
+
export declare function splitTableRow(line: string): string[] | null;
|
|
107
|
+
/**
|
|
108
|
+
* Classify an HTML block opener at `start` (CommonMark types 1–7);
|
|
109
|
+
* 0 means no HTML block starts here. Type 7 is only valid when no
|
|
110
|
+
* paragraph is open — the caller passes `paragraphOpen`.
|
|
111
|
+
* @param {string} line
|
|
112
|
+
* @param {number} start first non-space offset
|
|
113
|
+
* @param {boolean} paragraphOpen
|
|
114
|
+
* @returns {number}
|
|
115
|
+
*/
|
|
116
|
+
export declare function scanHtmlBlockStart(line: string, start: number, paragraphOpen: boolean): number;
|
|
117
|
+
/**
|
|
118
|
+
* Does this line end an HTML block of `kind`? Types 6/7 end on the
|
|
119
|
+
* following blank line (the parser checks that); types 1–5 end on a
|
|
120
|
+
* content condition, which may sit on the opening line itself.
|
|
121
|
+
* @param {number} kind
|
|
122
|
+
* @param {string} line
|
|
123
|
+
* @returns {boolean}
|
|
124
|
+
*/
|
|
125
|
+
export declare function scanHtmlBlockEnd(kind: number, line: string): boolean;
|
|
126
|
+
/**
|
|
127
|
+
* Is this char code a space or tab?
|
|
128
|
+
* @param {number} c
|
|
129
|
+
* @returns {boolean}
|
|
130
|
+
*/
|
|
131
|
+
export declare function isSpaceCode(c: number): boolean;
|
|
132
|
+
/**
|
|
133
|
+
* Link reference definition at the start of a closed paragraph's text:
|
|
134
|
+
* `[label]: destination "title"` (title optional, may be single-,
|
|
135
|
+
* double- or paren-quoted; destination may be `<>`-wrapped). Returns
|
|
136
|
+
* the definition and the offset after it, or null.
|
|
137
|
+
* @param {string} text the paragraph's raw text
|
|
138
|
+
* @param {number} pos
|
|
139
|
+
* @returns {{ label: string, url: string, title: string|null, end: number } | null}
|
|
140
|
+
*/
|
|
141
|
+
export declare function scanLinkDefinition(text: string, pos: number): {
|
|
142
|
+
label: string;
|
|
143
|
+
url: string;
|
|
144
|
+
title: string | null;
|
|
145
|
+
end: number;
|
|
146
|
+
} | null;
|
|
147
|
+
/**
|
|
148
|
+
* Scan a link destination at `pos`: `<...>` wrapped or a run of
|
|
149
|
+
* non-space characters with balanced parens.
|
|
150
|
+
* @param {string} text
|
|
151
|
+
* @param {number} pos
|
|
152
|
+
* @returns {{ url: string, end: number } | null}
|
|
153
|
+
*/
|
|
154
|
+
export declare function scanLinkDestination(text: string, pos: number): {
|
|
155
|
+
url: string;
|
|
156
|
+
end: number;
|
|
157
|
+
} | null;
|
|
158
|
+
/**
|
|
159
|
+
* Scan a link title at `pos`: `"..."`, `'...'` or `(...)`.
|
|
160
|
+
* @param {string} text
|
|
161
|
+
* @param {number} pos
|
|
162
|
+
* @returns {{ title: string, end: number } | null}
|
|
163
|
+
*/
|
|
164
|
+
export declare function scanLinkTitle(text: string, pos: number): {
|
|
165
|
+
title: string;
|
|
166
|
+
end: number;
|
|
167
|
+
} | null;
|
|
168
|
+
/** ASCII punctuation membership (emphasis flanking, backslash escapes). */
|
|
169
|
+
export declare const ASCII_PUNCT: Uint8Array<ArrayBuffer>;
|
|
170
|
+
/**
|
|
171
|
+
* Is this code point a whitespace character in the spec's sense?
|
|
172
|
+
* @param {number} point
|
|
173
|
+
* @returns {boolean}
|
|
174
|
+
*/
|
|
175
|
+
export declare function isUnicodeWhitespace(point: number): boolean;
|
|
176
|
+
/**
|
|
177
|
+
* Is this code point a punctuation character in the spec's sense (P* or S*)?
|
|
178
|
+
* @param {number} point
|
|
179
|
+
* @returns {boolean}
|
|
180
|
+
*/
|
|
181
|
+
export declare function isUnicodePunctuation(point: number): boolean;
|
|
182
|
+
/**
|
|
183
|
+
* The whole code point ending at `pos`, so a run preceded by an astral
|
|
184
|
+
* symbol classifies on the symbol and not on a surrogate half.
|
|
185
|
+
* @param {string} src @param {number} pos
|
|
186
|
+
* @returns {number}
|
|
187
|
+
*/
|
|
188
|
+
export declare function codePointBefore(src: string, pos: number): number;
|
|
189
|
+
/**
|
|
190
|
+
* Normalize a link label for matching: trim, collapse internal
|
|
191
|
+
* whitespace runs to one space, and case fold.
|
|
192
|
+
*
|
|
193
|
+
* The fold is lower→upper→lower, not `toLowerCase()`: the spec asks for
|
|
194
|
+
* Unicode case folding, under which `ẞ` matches `SS`, while lower-casing
|
|
195
|
+
* alone maps `ẞ` to `ß` and never meets `ss`. The round trip routes both
|
|
196
|
+
* spellings through the same expansion (`ẞ`→`ß`→`SS`→`ss`, and `fi`→`fi`),
|
|
197
|
+
* which is as close to the full fold as a zero-dependency package gets
|
|
198
|
+
* without shipping the table.
|
|
199
|
+
* @param {string} label
|
|
200
|
+
* @returns {string}
|
|
201
|
+
*/
|
|
202
|
+
export declare function normalizeLabel(label: string): string;
|
|
203
|
+
/**
|
|
204
|
+
* Footnote definition opener: `[^label]:` and the spaces after it.
|
|
205
|
+
* @param {string} line
|
|
206
|
+
* @param {number} start first non-space offset
|
|
207
|
+
* @returns {{ label: string, contentOffset: number } | null}
|
|
208
|
+
*/
|
|
209
|
+
export declare function scanFootnoteDefinition(line: string, start: number): {
|
|
210
|
+
label: string;
|
|
211
|
+
contentOffset: number;
|
|
212
|
+
} | null;
|
|
213
|
+
/**
|
|
214
|
+
* Footnote reference: `[^label]` in inline text.
|
|
215
|
+
* @param {string} text
|
|
216
|
+
* @param {number} start offset of the `[`
|
|
217
|
+
* @returns {{ label: string, end: number } | null}
|
|
218
|
+
*/
|
|
219
|
+
export declare function scanFootnoteReference(text: string, start: number): {
|
|
220
|
+
label: string;
|
|
221
|
+
end: number;
|
|
222
|
+
} | null;
|
|
223
|
+
/**
|
|
224
|
+
* Find every extended autolink in one text value (GFM §Autolinks): bare
|
|
225
|
+
* `www.…`, `http://…`, `https://…`, `ftp://…` and email addresses.
|
|
226
|
+
* Returns the matches in order, or `null` when there are none — the
|
|
227
|
+
* common answer, and the one that costs nothing.
|
|
228
|
+
*
|
|
229
|
+
* This works on a TEXT VALUE and not on the source, which is what makes
|
|
230
|
+
* the entity rule meaningful: `©` has already become `©` by the
|
|
231
|
+
* time we look, so the only `&…;` left to exclude is one that was never
|
|
232
|
+
* an entity in the first place.
|
|
233
|
+
* @param {string} value
|
|
234
|
+
* @returns {{ start: number, end: number, url: string }[] | null}
|
|
235
|
+
*/
|
|
236
|
+
export declare function scanAutolinkLiterals(value: string): {
|
|
237
|
+
start: number;
|
|
238
|
+
end: number;
|
|
239
|
+
url: string;
|
|
240
|
+
}[] | null;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file AST → HTML string, directly.
|
|
3
|
+
*
|
|
4
|
+
* The package's second emitter, and deliberately not a wrapper around
|
|
5
|
+
* the first (ARCHITECTURE.md "Two emitters"). `mdToVnode` builds a
|
|
6
|
+
* PATCHABLE TREE — keyed, memoized, reference-stable — whose safety is
|
|
7
|
+
* structural: a vnode has no slot for unescaped author markup, which is
|
|
8
|
+
* why a lone `</div>` cannot survive it. `toHtml` builds BYTES, and a
|
|
9
|
+
* string can hold half an element, so the raw-HTML corner of CommonMark
|
|
10
|
+
* is reachable here and only here.
|
|
11
|
+
*
|
|
12
|
+
* What the two share is factored, not copied: the escapers are
|
|
13
|
+
* `@jarenjs/view`'s (the same ones its SSR renderer uses, which is what
|
|
14
|
+
* makes the two outputs byte-identical for markup a vnode can express),
|
|
15
|
+
* the URL policy is `@jarenjs/view/helpers`, heading slugs come from
|
|
16
|
+
* `@jarenjs/core/string` through `utils.js`, and plugin dispatch is the
|
|
17
|
+
* parser's own table.
|
|
18
|
+
*
|
|
19
|
+
* Escaping is the default and `html: 'raw'` is per-call: the default
|
|
20
|
+
* cannot emit unescaped author content, and no jaren surface passes
|
|
21
|
+
* `'raw'`.
|
|
22
|
+
*/
|
|
23
|
+
export type MdNode = import('./ast.js').MdNode;
|
|
24
|
+
export type MdHtmlOptions = {
|
|
25
|
+
/**
|
|
26
|
+
* raw-HTML policy (default
|
|
27
|
+
* `'escape'`): show the markup as literal text, drop it (the vnode
|
|
28
|
+
* path's default), or pass it through verbatim. **`'raw'` emits author
|
|
29
|
+
* content as live markup and is for trusted input only** — a document
|
|
30
|
+
* from a user, a fetch or a model must never be rendered with it.
|
|
31
|
+
*/
|
|
32
|
+
html?: 'escape' | 'skip' | 'raw';
|
|
33
|
+
/**
|
|
34
|
+
* link/image URL
|
|
35
|
+
* filter, replacing the default deny-list; return the URL to emit or
|
|
36
|
+
* `null` to drop the attribute. It applies in EVERY `html` mode,
|
|
37
|
+
* `'raw'` included: `'raw'` is a statement about HTML blocks, not a
|
|
38
|
+
* blanket trust, so a markdown `[x](javascript:…)` is still filtered.
|
|
39
|
+
*/
|
|
40
|
+
sanitizeUrl?: (url: string) => (string | null);
|
|
41
|
+
/**
|
|
42
|
+
* plugin set (must match the parse set for
|
|
43
|
+
* claimed nodes); a plugin contributes `toHtml(node, ctx)` here the
|
|
44
|
+
* way it contributes `render` to the vnode path.
|
|
45
|
+
*/
|
|
46
|
+
plugins?: any[];
|
|
47
|
+
/**
|
|
48
|
+
* GitHub-compatible `id` per heading
|
|
49
|
+
* (default `false`; see MD-FORMAT.md §4.5 for why it is opt-in).
|
|
50
|
+
*/
|
|
51
|
+
headingIds?: boolean;
|
|
52
|
+
/**
|
|
53
|
+
* prepended to every heading id and
|
|
54
|
+
* anchor href (default `''`); set it for markdown you did not author.
|
|
55
|
+
*/
|
|
56
|
+
slugPrefix?: string;
|
|
57
|
+
/**
|
|
58
|
+
* append a `#` permalink to each
|
|
59
|
+
* heading (default `false`). Requires `headingIds`.
|
|
60
|
+
*/
|
|
61
|
+
headingAnchors?: boolean;
|
|
62
|
+
/**
|
|
63
|
+
* the accessible name of the
|
|
64
|
+
* appended footnotes section (default `'Footnotes'`); a localized page
|
|
65
|
+
* sets it, since it is the one string the emitter writes that a reader
|
|
66
|
+
* can hear.
|
|
67
|
+
*/
|
|
68
|
+
footnotesLabel?: string;
|
|
69
|
+
/**
|
|
70
|
+
* wrapping element, written as it appears in
|
|
71
|
+
* the start tag (`'article class="md"'`). Default `null`: bare
|
|
72
|
+
* fragment HTML, which is what a consumer concatenating into a
|
|
73
|
+
* template wants.
|
|
74
|
+
*/
|
|
75
|
+
wrap?: string;
|
|
76
|
+
};
|
|
77
|
+
export type HtmlCtx = {
|
|
78
|
+
tables: any;
|
|
79
|
+
html: 'escape' | 'skip' | 'raw';
|
|
80
|
+
sanitizeUrl: (url: string) => (string | null);
|
|
81
|
+
headingIds: boolean;
|
|
82
|
+
slugPrefix: string;
|
|
83
|
+
headingAnchors: boolean;
|
|
84
|
+
slugs: Map<string, number>;
|
|
85
|
+
options: MdHtmlOptions;
|
|
86
|
+
footnotes: import('./footnotes.js').Footnotes | null;
|
|
87
|
+
footnotePrefix: string;
|
|
88
|
+
footnotesLabel: string;
|
|
89
|
+
};
|
|
90
|
+
/**
|
|
91
|
+
* Render an MdDocument (or a CompiledMd, an AST array, a single node)
|
|
92
|
+
* to an HTML string.
|
|
93
|
+
*
|
|
94
|
+
* @example
|
|
95
|
+
* toHtml(parseMarkdown('# Hi')); // '<h1>Hi</h1>'
|
|
96
|
+
* toHtml(parseMarkdown('<b>x</b>')); // '<b>x</b>'
|
|
97
|
+
* toHtml(doc, { html: 'raw' }); // trusted input only
|
|
98
|
+
* toHtml(doc, { wrap: 'article class="md"' }); // wrapped
|
|
99
|
+
*
|
|
100
|
+
* @param {any} docOrAst MdDocument, CompiledMd, MdNode[] or MdNode
|
|
101
|
+
* @param {MdHtmlOptions} [options]
|
|
102
|
+
* @returns {string}
|
|
103
|
+
*/
|
|
104
|
+
export declare function toHtml(docOrAst: any, options?: MdHtmlOptions): string;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Canonical Markdown printer: AST → text (MD-FORMAT.md §5).
|
|
3
|
+
*
|
|
4
|
+
* The printer is the round-trip half of the package: canonical output
|
|
5
|
+
* re-parses to a deep-equal AST. Canonical choices: ATX headings, `-`
|
|
6
|
+
* bullets, `1.`/`2.` ordered markers renumbered from `start`, backtick
|
|
7
|
+
* fences, `*`/`**` emphasis, inline links, backslash hard breaks and
|
|
8
|
+
* piped tables. Dispatch is one prebuilt table per node class — no
|
|
9
|
+
* per-node type chains.
|
|
10
|
+
*/
|
|
11
|
+
export type MdNode = import('./ast.js').MdNode;
|
|
12
|
+
/**
|
|
13
|
+
* Print an MdDocument (or a bare AST array / single node) to canonical
|
|
14
|
+
* Markdown. Frontmatter re-emits as a `---json` block by default —
|
|
15
|
+
* exact, syntax-neutral round-trips (MD-FORMAT.md §5).
|
|
16
|
+
*
|
|
17
|
+
* @param {any} docOrAst
|
|
18
|
+
* @param {{ frontmatter?: boolean }} [options]
|
|
19
|
+
* @returns {string}
|
|
20
|
+
*/
|
|
21
|
+
export declare function toMarkdown(docOrAst: any, options?: {
|
|
22
|
+
frontmatter?: boolean;
|
|
23
|
+
}): string;
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file AST → @jarenjs/view vnodes, and the hydrating renderer.
|
|
3
|
+
*
|
|
4
|
+
* The emitter is a prebuilt dispatch table keyed on node type; plugin
|
|
5
|
+
* `render` entries shadow the core entries (that is how the highlight
|
|
6
|
+
* plugin takes over `code` nodes). Two identity guarantees feed the
|
|
7
|
+
* view patcher's O(1) fast paths (VIEW-FORMAT.md §5.1):
|
|
8
|
+
*
|
|
9
|
+
* - per-node memo: the same AST node reference emits the same vnode
|
|
10
|
+
* reference, so unchanged subtrees of a JSLT-transformed document
|
|
11
|
+
* patch in O(1);
|
|
12
|
+
* - content-hash keys: block vnodes are keyed by a hash of their
|
|
13
|
+
* content, so moved blocks reorder instead of rebuilding.
|
|
14
|
+
*
|
|
15
|
+
* Raw HTML nodes are dropped by default; `options.html: 'text'` shows
|
|
16
|
+
* them literally, and `'vnode'` PARSES them through an allow-list
|
|
17
|
+
* (`parseHtmlFragment`, or an injected `parseHtml`). The vnode format
|
|
18
|
+
* has no unescaped output in any of the three, which is what makes
|
|
19
|
+
* dropping the safe default for untrusted Markdown. Link and image URLs are
|
|
20
|
+
* filtered on the same principle: a destination whose scheme can execute
|
|
21
|
+
* (`javascript:`, `vbscript:`) or stand in for a document
|
|
22
|
+
* (`data:text/html`, `file:`) loses its attribute rather than reaching
|
|
23
|
+
* the page. The AST keeps the URL verbatim, so `toMarkdown` still
|
|
24
|
+
* round-trips what the author wrote — only the vnode is filtered.
|
|
25
|
+
*/
|
|
26
|
+
export type MdNode = import('./ast.js').MdNode;
|
|
27
|
+
export type MdDocument = import('./ast.js').MdDocument;
|
|
28
|
+
export type MdVnodeOptions = {
|
|
29
|
+
/**
|
|
30
|
+
* plugin set (must match the parse set for claimed nodes)
|
|
31
|
+
*/
|
|
32
|
+
plugins?: any[];
|
|
33
|
+
/**
|
|
34
|
+
* raw HTML handling (default
|
|
35
|
+
* 'skip'): drop it, show it as literal text, or parse it to vnodes
|
|
36
|
+
*/
|
|
37
|
+
html?: 'skip' | 'text' | 'vnode';
|
|
38
|
+
/**
|
|
39
|
+
* the parser `html: 'vnode'`
|
|
40
|
+
* uses (default `parseHtmlFragment` from `@jarenjs/md/html`) — the
|
|
41
|
+
* injection point for a host's own sanitizer. It returns a LIST of
|
|
42
|
+
* vnodes (empty or null when nothing survived); an array is always
|
|
43
|
+
* read as a list, because a vnode is an array too and the two would
|
|
44
|
+
* otherwise be indistinguishable.
|
|
45
|
+
*/
|
|
46
|
+
parseHtml?: (html: string) => any;
|
|
47
|
+
/**
|
|
48
|
+
* link/image URL
|
|
49
|
+
* filter, replacing the default deny-list; return the URL to emit, or
|
|
50
|
+
* `null` to drop the attribute. Supply one only to widen the policy for
|
|
51
|
+
* trusted content (a custom scheme, say) — it is the whole guard.
|
|
52
|
+
*/
|
|
53
|
+
sanitizeUrl?: (url: string) => (string | null);
|
|
54
|
+
/**
|
|
55
|
+
* give every heading a GitHub-compatible
|
|
56
|
+
* `id` so `[see below](#the-section)` lands (default `false`). The
|
|
57
|
+
* default is OFF ON PURPOSE and must stay that way: CommonMark
|
|
58
|
+
* specifies `<h1>Foo</h1>`, so an id emitted by default would fail
|
|
59
|
+
* every heading example in the conformance corpus and make the
|
|
60
|
+
* package's published score a lie. A host that wants anchors asks for
|
|
61
|
+
* them; the spec path stays honest.
|
|
62
|
+
*/
|
|
63
|
+
headingIds?: boolean;
|
|
64
|
+
/**
|
|
65
|
+
* prepended to every heading id and
|
|
66
|
+
* anchor href (default `''`). A host rendering markdown it did not
|
|
67
|
+
* author into a page it owns sets this — GitHub's own answer is
|
|
68
|
+
* `user-content-` — so an author cannot mint an id that collides with
|
|
69
|
+
* the host's own DOM.
|
|
70
|
+
*/
|
|
71
|
+
slugPrefix?: string;
|
|
72
|
+
/**
|
|
73
|
+
* append a `#` link to each heading
|
|
74
|
+
* so a reader can copy a link to the section (default `false`).
|
|
75
|
+
* Requires `headingIds`; without ids there is nothing to link to.
|
|
76
|
+
*/
|
|
77
|
+
headingAnchors?: boolean;
|
|
78
|
+
/**
|
|
79
|
+
* the accessible name of the
|
|
80
|
+
* appended footnotes section (default `'Footnotes'`) — the one string
|
|
81
|
+
* this emitter writes that a reader can hear.
|
|
82
|
+
*/
|
|
83
|
+
footnotesLabel?: string;
|
|
84
|
+
/**
|
|
85
|
+
* give each top-level block a content-hash
|
|
86
|
+
* `key` (default `true`).
|
|
87
|
+
*
|
|
88
|
+
* Keys are what let the view patcher REORDER blocks instead of
|
|
89
|
+
* rebuilding them, so any caller whose output will be patched needs
|
|
90
|
+
* them — and computing one means hashing the block's whole subtree,
|
|
91
|
+
* which is around a quarter of this emitter's cost. A caller that
|
|
92
|
+
* renders once and throws the tree away (SSR, a string, a snapshot)
|
|
93
|
+
* pays that for nothing and should pass `false`.
|
|
94
|
+
*
|
|
95
|
+
* It is deliberately NOT inferred. A renderer cannot know whether its
|
|
96
|
+
* output will be patched, and guessing wrong silently turns O(1)
|
|
97
|
+
* reconciliation into a rebuild — a correctness-shaped failure with no
|
|
98
|
+
* error message. The default is the safe answer; opting out is a
|
|
99
|
+
* statement about the caller, which is why every caller in this
|
|
100
|
+
* repository that passes `false` says why.
|
|
101
|
+
*/
|
|
102
|
+
keyed?: boolean;
|
|
103
|
+
};
|
|
104
|
+
export type RenderCtx = {
|
|
105
|
+
tables: any;
|
|
106
|
+
html: 'skip' | 'text' | 'vnode';
|
|
107
|
+
options: MdVnodeOptions;
|
|
108
|
+
parseHtml: (html: string) => any;
|
|
109
|
+
sanitizeUrl: (url: string) => (string | null);
|
|
110
|
+
headingIds: boolean;
|
|
111
|
+
slugPrefix: string;
|
|
112
|
+
headingAnchors: boolean;
|
|
113
|
+
slugs: Map<string, number>;
|
|
114
|
+
footnotes: import('./footnotes.js').Footnotes | null;
|
|
115
|
+
footnotePrefix: string;
|
|
116
|
+
footnotesLabel: string;
|
|
117
|
+
hash: (str: string) => string;
|
|
118
|
+
counts: Map<string, number>;
|
|
119
|
+
};
|
|
120
|
+
/**
|
|
121
|
+
* Emit a whole document (or AST array) as one `article.md` vnode with
|
|
122
|
+
* content-hash-keyed block children.
|
|
123
|
+
*
|
|
124
|
+
* @example
|
|
125
|
+
* mdToVnode(parseMarkdown('# Hi'))
|
|
126
|
+
* // ['article', { class: 'md' }, [['h1', { key: '…' }, 'Hi']]]
|
|
127
|
+
*
|
|
128
|
+
* @param {any} docOrCompiled MdDocument, CompiledMd or MdNode[]
|
|
129
|
+
* @param {MdVnodeOptions} [options]
|
|
130
|
+
* @returns {any}
|
|
131
|
+
*/
|
|
132
|
+
export declare function mdToVnode(docOrCompiled: any, options?: MdVnodeOptions): any;
|
|
133
|
+
/**
|
|
134
|
+
* Create a renderer over `@jarenjs/view`'s DOM patcher that also runs
|
|
135
|
+
* plugin `hydrate` hooks after mount (PLUGINS.md §5). Returns a
|
|
136
|
+
* `render(docOrCompiled)` function.
|
|
137
|
+
*
|
|
138
|
+
* @param {{
|
|
139
|
+
* container: any,
|
|
140
|
+
* plugins?: any[],
|
|
141
|
+
* html?: 'skip'|'text',
|
|
142
|
+
* headingIds?: boolean,
|
|
143
|
+
* slugPrefix?: string,
|
|
144
|
+
* headingAnchors?: boolean,
|
|
145
|
+
* document?: any,
|
|
146
|
+
* onEvent?: (binding: any, event: any) => void,
|
|
147
|
+
* onHydrateError?: (err: any) => void,
|
|
148
|
+
* }} options
|
|
149
|
+
* @returns {(docOrCompiled: any) => void}
|
|
150
|
+
*/
|
|
151
|
+
export declare function createMdRenderer(options: {
|
|
152
|
+
container: any;
|
|
153
|
+
plugins?: any[];
|
|
154
|
+
html?: 'skip' | 'text';
|
|
155
|
+
headingIds?: boolean;
|
|
156
|
+
slugPrefix?: string;
|
|
157
|
+
headingAnchors?: boolean;
|
|
158
|
+
document?: any;
|
|
159
|
+
onEvent?: (binding: any, event: any) => void;
|
|
160
|
+
onHydrateError?: (err: any) => void;
|
|
161
|
+
}): (docOrCompiled: any) => void;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Small shared helpers for the md package.
|
|
3
|
+
*
|
|
4
|
+
* The content hash is the package's identity primitive — block vnode
|
|
5
|
+
* keys, the document `meta.hash`, and the mermaid SVG cache are all keyed
|
|
6
|
+
* by it — so md re-exports the suite's single `hashContent` from
|
|
7
|
+
* `@jarenjs/core` rather than carrying its own copy; equal content hits
|
|
8
|
+
* O(1) fast paths everywhere downstream. `fnv1a` is that same mixing
|
|
9
|
+
* step, exposed for the two callers that fold a hash incrementally (the
|
|
10
|
+
* streaming parser's chunks, the structural block-key walk) and so must
|
|
11
|
+
* seed it themselves from `FNV1A_OFFSET_BASIS`. Heading slugs come from
|
|
12
|
+
* the same place for the same reason: `slugify` is a pure text→fragment
|
|
13
|
+
* transform with no Markdown knowledge, so the suite keeps exactly one
|
|
14
|
+
* of it. The remaining helpers are md's own allocation-light scanner
|
|
15
|
+
* utilities.
|
|
16
|
+
*/
|
|
17
|
+
export { hashContent, fnv1a, FNV1A_OFFSET_BASIS, slugify } from '@jarenjs/core/string';
|
|
18
|
+
/**
|
|
19
|
+
* The `id` for one heading, unique within one emission.
|
|
20
|
+
*
|
|
21
|
+
* Both emitters mint ids, so the rule lives here once: slug the text,
|
|
22
|
+
* substitute `section` when nothing slug-worthy survives, number
|
|
23
|
+
* repeats the way GitHub numbers them (`setup`, `setup-1`, `setup-2`)
|
|
24
|
+
* and prefix the result. The COUNTER belongs to the caller — one map per
|
|
25
|
+
* emission, never shared with another numbering (a block key's hash and
|
|
26
|
+
* a slug share a namespace only by accident, and a collision there would
|
|
27
|
+
* shift an unrelated heading's number).
|
|
28
|
+
*
|
|
29
|
+
* @param {string} text the heading's plain text (`textOf`)
|
|
30
|
+
* @param {Map<string, number>} seen the emission's slug counter
|
|
31
|
+
* @param {string} prefix prepended to the result
|
|
32
|
+
* @returns {string}
|
|
33
|
+
*/
|
|
34
|
+
export declare function headingId(text: string, seen: Map<string, number>, prefix: string): string;
|
|
35
|
+
/**
|
|
36
|
+
* The accessible name for a heading's permalink affordance — `#` alone
|
|
37
|
+
* names nothing, so the link says which section it points at.
|
|
38
|
+
* @param {string} text the heading's plain text (`textOf`)
|
|
39
|
+
* @returns {string}
|
|
40
|
+
*/
|
|
41
|
+
export declare function permalinkLabel(text: string): string;
|
|
42
|
+
/**
|
|
43
|
+
* Count leading space characters (U+0020 only; the scanner expands no
|
|
44
|
+
* tabs here — callers pass detabbed text).
|
|
45
|
+
* @param {string} line
|
|
46
|
+
* @returns {number}
|
|
47
|
+
*/
|
|
48
|
+
export declare function countIndent(line: string): number;
|
|
49
|
+
/**
|
|
50
|
+
* Is the line blank (empty or whitespace-only)?
|
|
51
|
+
* @param {string} line
|
|
52
|
+
* @returns {boolean}
|
|
53
|
+
*/
|
|
54
|
+
export declare function isBlankLine(line: string): boolean;
|
|
55
|
+
/**
|
|
56
|
+
* Replace tabs with spaces to the next 4-column tab stop, counting
|
|
57
|
+
* columns from `startColumn`. Lines without tabs return the same
|
|
58
|
+
* string reference (the common case allocates nothing).
|
|
59
|
+
* @param {string} line
|
|
60
|
+
* @param {number} [startColumn]
|
|
61
|
+
* @returns {string}
|
|
62
|
+
*/
|
|
63
|
+
export declare function expandTabs(line: string, startColumn?: number): string;
|
package/docs/LOADER.md
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# The Jaren Markdown Loader
|
|
2
|
+
|
|
3
|
+
**Version 0.1 — Specification**
|
|
4
|
+
|
|
5
|
+
`loadMarkdown` turns any URL into a compiled Markdown document, with
|
|
6
|
+
caching, cancellation and streaming as first-class citizens.
|
|
7
|
+
|
|
8
|
+
## 1. API
|
|
9
|
+
|
|
10
|
+
```js
|
|
11
|
+
const compiled = await loadMarkdown(url, {
|
|
12
|
+
plugins: [], // compiled into the parser, §PLUGINS.md
|
|
13
|
+
base: undefined, // base URL for relative url arguments
|
|
14
|
+
signal: undefined, // AbortSignal — rejects with the abort reason
|
|
15
|
+
cache: defaultMdCache, // a cache instance, or false to bypass
|
|
16
|
+
fetch: globalThis.fetch,// injectable transport (tests, custom auth)
|
|
17
|
+
retainSource: true, // false drops the source string after parsing
|
|
18
|
+
toml: undefined, // injectable TOML frontmatter parser
|
|
19
|
+
});
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Accepted URL schemes are whatever the platform `fetch` accepts:
|
|
23
|
+
`http(s):`, `data:`, `blob:`, and relative paths resolved against
|
|
24
|
+
`options.base` (or as-is when already absolute). The resolved compiled
|
|
25
|
+
document is the same closure bundle `compileMarkdown` returns, with
|
|
26
|
+
`doc.meta.sourceUrl` set to the normalized URL.
|
|
27
|
+
|
|
28
|
+
## 2. Caching
|
|
29
|
+
|
|
30
|
+
The shared in-memory cache is keyed by **normalized URL**. Each entry
|
|
31
|
+
remembers the response's `ETag` and `Last-Modified`; a cache hit
|
|
32
|
+
resolves immediately with the cached compiled document, and a
|
|
33
|
+
revalidating fetch (`If-None-Match`/`If-Modified-Since`) only replaces
|
|
34
|
+
the entry when the origin answers with new content (`200` with a
|
|
35
|
+
different validator). Entries also key on the compile-relevant options
|
|
36
|
+
(plugin names), so the same URL compiled with different plugin sets
|
|
37
|
+
does not alias.
|
|
38
|
+
|
|
39
|
+
- `createMdCache(limit = 64)` — an LRU cache instance; the default
|
|
40
|
+
shared instance is exported as `defaultMdCache`.
|
|
41
|
+
- `cache: false` — bypass entirely.
|
|
42
|
+
- `cache.delete(url)` / `cache.clear()` — manual invalidation.
|
|
43
|
+
|
|
44
|
+
Concurrent `loadMarkdown` calls for the same key share one in-flight
|
|
45
|
+
fetch (the promise itself is cached), so a burst of loads costs one
|
|
46
|
+
request.
|
|
47
|
+
|
|
48
|
+
## 3. Cancellation
|
|
49
|
+
|
|
50
|
+
`options.signal` is passed through to `fetch` and checked between
|
|
51
|
+
parse steps; aborting rejects the promise with the signal's reason and
|
|
52
|
+
leaves the cache untouched (an aborted in-flight entry is evicted so
|
|
53
|
+
the next call retries).
|
|
54
|
+
|
|
55
|
+
## 4. Streaming
|
|
56
|
+
|
|
57
|
+
When the response body is a `ReadableStream`, the loader parses
|
|
58
|
+
**block-by-block as chunks arrive** instead of buffering the full body:
|
|
59
|
+
|
|
60
|
+
```js
|
|
61
|
+
for await (const block of streamMarkdown(url, options)) {
|
|
62
|
+
render(block); // top-level AST block nodes, in order
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
`streamMarkdown(urlOrStream, options)` accepts a URL, a `Response`, a
|
|
67
|
+
`ReadableStream`, or any async iterable of string/Uint8Array chunks,
|
|
68
|
+
and yields completed top-level block nodes as soon as their end is
|
|
69
|
+
certain (a construct boundary that no later chunk can reopen: a blank
|
|
70
|
+
line at container depth zero outside an open fence). The generator's
|
|
71
|
+
**return value** is the finished `MdDocument`; `loadMarkdown` itself is
|
|
72
|
+
implemented over the same incremental core.
|
|
73
|
+
|
|
74
|
+
The incremental core is exported for non-URL sources:
|
|
75
|
+
|
|
76
|
+
```js
|
|
77
|
+
const inc = createIncrementalParser(options);
|
|
78
|
+
inc.feed(chunk); // → MdNode[] — blocks completed by this chunk
|
|
79
|
+
inc.end(); // → MdDocument — flushes the tail
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Frontmatter is resolved as soon as its closing fence arrives, so
|
|
83
|
+
`inc.frontmatter` (and the first yielded value's document) is available
|
|
84
|
+
before the body finishes downloading.
|
|
85
|
+
|
|
86
|
+
## 5. In an app document (non-normative)
|
|
87
|
+
|
|
88
|
+
`loadMarkdown` fits `@jarenjs/app`'s effect registry as an ordinary
|
|
89
|
+
async effect: an action dispatches `{ effect: 'md.load', with: url }`,
|
|
90
|
+
the effect resolves the compiled document and dispatches a completion
|
|
91
|
+
action whose payload is the plain `MdDocument` — from there the app's
|
|
92
|
+
JSLT view stylesheet takes over (see the README's end-to-end example).
|