@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
package/src/to-html.js
ADDED
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file AST → HTML string, directly.
|
|
4
|
+
*
|
|
5
|
+
* The package's second emitter, and deliberately not a wrapper around
|
|
6
|
+
* the first (ARCHITECTURE.md "Two emitters"). `mdToVnode` builds a
|
|
7
|
+
* PATCHABLE TREE — keyed, memoized, reference-stable — whose safety is
|
|
8
|
+
* structural: a vnode has no slot for unescaped author markup, which is
|
|
9
|
+
* why a lone `</div>` cannot survive it. `toHtml` builds BYTES, and a
|
|
10
|
+
* string can hold half an element, so the raw-HTML corner of CommonMark
|
|
11
|
+
* is reachable here and only here.
|
|
12
|
+
*
|
|
13
|
+
* What the two share is factored, not copied: the escapers are
|
|
14
|
+
* `@jarenjs/view`'s (the same ones its SSR renderer uses, which is what
|
|
15
|
+
* makes the two outputs byte-identical for markup a vnode can express),
|
|
16
|
+
* the URL policy is `@jarenjs/view/helpers`, heading slugs come from
|
|
17
|
+
* `@jarenjs/core/string` through `utils.js`, and plugin dispatch is the
|
|
18
|
+
* parser's own table.
|
|
19
|
+
*
|
|
20
|
+
* Escaping is the default and `html: 'raw'` is per-call: the default
|
|
21
|
+
* cannot emit unescaped author content, and no jaren surface passes
|
|
22
|
+
* `'raw'`.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { escapeText, escapeAttribute } from '@jarenjs/view';
|
|
26
|
+
import { sanitizeUrl as defaultSanitizeUrl, encodeUrlAttribute } from '@jarenjs/view/helpers';
|
|
27
|
+
|
|
28
|
+
import { headingId, permalinkLabel } from './utils.js';
|
|
29
|
+
import { textOf } from './ast.js';
|
|
30
|
+
import { buildPluginTables } from './parser.js';
|
|
31
|
+
import {
|
|
32
|
+
collectFootnotes, footnoteId, footnoteRefId, backrefLabel,
|
|
33
|
+
FOOTNOTE_PREFIX, BACKREF_MARK,
|
|
34
|
+
} from './footnotes.js';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @typedef {import('./ast.js').MdNode} MdNode
|
|
38
|
+
*/
|
|
39
|
+
/**
|
|
40
|
+
* @typedef {object} MdHtmlOptions
|
|
41
|
+
* @property {'escape'|'skip'|'raw'} [html] raw-HTML policy (default
|
|
42
|
+
* `'escape'`): show the markup as literal text, drop it (the vnode
|
|
43
|
+
* path's default), or pass it through verbatim. **`'raw'` emits author
|
|
44
|
+
* content as live markup and is for trusted input only** — a document
|
|
45
|
+
* from a user, a fetch or a model must never be rendered with it.
|
|
46
|
+
* @property {(url: string) => (string|null)} [sanitizeUrl] link/image URL
|
|
47
|
+
* filter, replacing the default deny-list; return the URL to emit or
|
|
48
|
+
* `null` to drop the attribute. It applies in EVERY `html` mode,
|
|
49
|
+
* `'raw'` included: `'raw'` is a statement about HTML blocks, not a
|
|
50
|
+
* blanket trust, so a markdown `[x](javascript:…)` is still filtered.
|
|
51
|
+
* @property {any[]} [plugins] plugin set (must match the parse set for
|
|
52
|
+
* claimed nodes); a plugin contributes `toHtml(node, ctx)` here the
|
|
53
|
+
* way it contributes `render` to the vnode path.
|
|
54
|
+
* @property {boolean} [headingIds] GitHub-compatible `id` per heading
|
|
55
|
+
* (default `false`; see MD-FORMAT.md §4.5 for why it is opt-in).
|
|
56
|
+
* @property {string} [slugPrefix] prepended to every heading id and
|
|
57
|
+
* anchor href (default `''`); set it for markdown you did not author.
|
|
58
|
+
* @property {boolean} [headingAnchors] append a `#` permalink to each
|
|
59
|
+
* heading (default `false`). Requires `headingIds`.
|
|
60
|
+
* @property {string} [footnotesLabel] the accessible name of the
|
|
61
|
+
* appended footnotes section (default `'Footnotes'`); a localized page
|
|
62
|
+
* sets it, since it is the one string the emitter writes that a reader
|
|
63
|
+
* can hear.
|
|
64
|
+
* @property {string} [wrap] wrapping element, written as it appears in
|
|
65
|
+
* the start tag (`'article class="md"'`). Default `null`: bare
|
|
66
|
+
* fragment HTML, which is what a consumer concatenating into a
|
|
67
|
+
* template wants.
|
|
68
|
+
*/
|
|
69
|
+
/**
|
|
70
|
+
* The emission context, built once per call and threaded through.
|
|
71
|
+
* @typedef {{ tables: any, html: 'escape'|'skip'|'raw',
|
|
72
|
+
* sanitizeUrl: (url: string) => (string|null),
|
|
73
|
+
* headingIds: boolean, slugPrefix: string, headingAnchors: boolean,
|
|
74
|
+
* slugs: Map<string, number>, options: MdHtmlOptions,
|
|
75
|
+
* footnotes: import('./footnotes.js').Footnotes|null,
|
|
76
|
+
* footnotePrefix: string, footnotesLabel: string }} HtmlCtx
|
|
77
|
+
*/
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* One raw-HTML node under the policy in force. `'escape'` shows the
|
|
81
|
+
* markup instead of dropping it: a string can display what a vnode tree
|
|
82
|
+
* cannot hold, and losing the content silently would be the worse
|
|
83
|
+
* default.
|
|
84
|
+
* @param {MdNode} node @param {HtmlCtx} ctx
|
|
85
|
+
* @returns {string}
|
|
86
|
+
*/
|
|
87
|
+
function rawHtml(node, ctx) {
|
|
88
|
+
if (ctx.html === 'skip') return '';
|
|
89
|
+
return ctx.html === 'raw' ? node.value : escapeText(node.value);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* One attribute, or nothing when the value is absent. Attribute values
|
|
94
|
+
* are escaped exactly as `@jarenjs/view`'s SSR renderer escapes them.
|
|
95
|
+
* @param {string} name @param {string|null|undefined} value
|
|
96
|
+
* @returns {string}
|
|
97
|
+
*/
|
|
98
|
+
function attr(name, value) {
|
|
99
|
+
return value === null || value === undefined
|
|
100
|
+
? ''
|
|
101
|
+
: ' ' + name + '="' + escapeAttribute(String(value)) + '"';
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ------------------------------------------------------------------
|
|
105
|
+
// Inline emission
|
|
106
|
+
// ------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
/** @type {Record<string, (node: MdNode, ctx: HtmlCtx) => string>} */
|
|
109
|
+
const INLINE_HTML = {
|
|
110
|
+
text: (node) => escapeText(node.value),
|
|
111
|
+
emphasis: (node, ctx) => '<em>' + inlineChildren(node.children, ctx) + '</em>',
|
|
112
|
+
strong: (node, ctx) => '<strong>' + inlineChildren(node.children, ctx) + '</strong>',
|
|
113
|
+
strikethrough: (node, ctx) => '<del>' + inlineChildren(node.children, ctx) + '</del>',
|
|
114
|
+
inlineCode: (node) => '<code>' + escapeText(node.value) + '</code>',
|
|
115
|
+
|
|
116
|
+
link: (node, ctx) => {
|
|
117
|
+
// A rejected destination drops the attribute and keeps the element,
|
|
118
|
+
// so the link text stays readable; what survives is percent-encoded
|
|
119
|
+
// for the attribute, as CommonMark's rendering rule requires.
|
|
120
|
+
const href = ctx.sanitizeUrl(node.url);
|
|
121
|
+
return '<a' + (href === null ? '' : attr('href', encodeUrlAttribute(href)))
|
|
122
|
+
+ attr('title', node.title) + '>' + inlineChildren(node.children, ctx) + '</a>';
|
|
123
|
+
},
|
|
124
|
+
|
|
125
|
+
image: (node, ctx) => {
|
|
126
|
+
const src = ctx.sanitizeUrl(node.url);
|
|
127
|
+
return '<img' + (src === null ? '' : attr('src', encodeUrlAttribute(src)))
|
|
128
|
+
+ attr('alt', node.alt) + attr('title', node.title) + '>';
|
|
129
|
+
},
|
|
130
|
+
|
|
131
|
+
break: () => '<br>',
|
|
132
|
+
softBreak: () => '\n',
|
|
133
|
+
html: (node, ctx) => rawHtml(node, ctx),
|
|
134
|
+
|
|
135
|
+
footnoteReference: (node, ctx) => {
|
|
136
|
+
const cite = ctx.footnotes?.refs.get(node);
|
|
137
|
+
// A reference with nothing to point at is the literal text it was
|
|
138
|
+
// written as, not a link to a missing anchor. The parser only makes
|
|
139
|
+
// this node when a definition exists, so this is the transformed-AST
|
|
140
|
+
// path — but a broken `href` is the one outcome worth ruling out.
|
|
141
|
+
if (cite === undefined) return escapeText('[^' + (node.label ?? node.identifier) + ']');
|
|
142
|
+
const prefix = ctx.footnotePrefix;
|
|
143
|
+
return '<sup>'
|
|
144
|
+
+ '<a' + attr('href', '#' + footnoteId(prefix, cite.number))
|
|
145
|
+
+ attr('id', footnoteRefId(prefix, cite.number, cite.occurrence))
|
|
146
|
+
+ '>' + cite.number + '</a></sup>';
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Emit a run of inline nodes.
|
|
152
|
+
* @param {MdNode[]} nodes @param {HtmlCtx} ctx
|
|
153
|
+
* @returns {string}
|
|
154
|
+
*/
|
|
155
|
+
function inlineChildren(nodes, ctx) {
|
|
156
|
+
let out = '';
|
|
157
|
+
for (let i = 0; i < nodes.length; i++) out += inlineHtml(nodes[i], ctx);
|
|
158
|
+
return out;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Emit one inline node (a plugin's `toHtml` shadows the core table).
|
|
163
|
+
* @param {MdNode} node @param {HtmlCtx} ctx
|
|
164
|
+
* @returns {string}
|
|
165
|
+
*/
|
|
166
|
+
function inlineHtml(node, ctx) {
|
|
167
|
+
const plugin = ctx.tables.htmls.get(node.type);
|
|
168
|
+
if (plugin !== undefined) return String(plugin.toHtml(node, ctx) ?? '');
|
|
169
|
+
const renderer = INLINE_HTML[node.type];
|
|
170
|
+
if (renderer !== undefined) return renderer(node, ctx);
|
|
171
|
+
if (ctx.tables.renders.has(node.type)) return unsupportedNode(node.type);
|
|
172
|
+
return fallbackHtml(node, ctx, true);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ------------------------------------------------------------------
|
|
176
|
+
// Block emission
|
|
177
|
+
// ------------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
/** @type {Record<string, (node: MdNode, ctx: HtmlCtx) => string>} */
|
|
180
|
+
const BLOCK_HTML = {
|
|
181
|
+
paragraph: (node, ctx) => '<p>' + inlineChildren(node.children, ctx) + '</p>',
|
|
182
|
+
|
|
183
|
+
heading: (node, ctx) => {
|
|
184
|
+
const tag = 'h' + node.depth;
|
|
185
|
+
if (ctx.headingIds !== true) {
|
|
186
|
+
return '<' + tag + '>' + inlineChildren(node.children, ctx) + '</' + tag + '>';
|
|
187
|
+
}
|
|
188
|
+
const id = headingId(textOf(node), ctx.slugs, ctx.slugPrefix);
|
|
189
|
+
let out = '<' + tag + attr('id', id) + '>' + inlineChildren(node.children, ctx);
|
|
190
|
+
if (ctx.headingAnchors === true) {
|
|
191
|
+
out += '<a class="md-anchor"' + attr('href', '#' + id)
|
|
192
|
+
+ attr('aria-label', permalinkLabel(textOf(node))) + '>#</a>';
|
|
193
|
+
}
|
|
194
|
+
return out + '</' + tag + '>';
|
|
195
|
+
},
|
|
196
|
+
|
|
197
|
+
thematicBreak: () => '<hr>',
|
|
198
|
+
|
|
199
|
+
blockquote: (node, ctx) => '<blockquote>' + blockChildren(node.children, ctx) + '</blockquote>',
|
|
200
|
+
|
|
201
|
+
list: (node, ctx) => {
|
|
202
|
+
const tag = node.ordered ? 'ol' : 'ul';
|
|
203
|
+
let out = '<' + tag
|
|
204
|
+
+ (node.ordered && node.start !== null && node.start !== 1 ? attr('start', node.start) : '')
|
|
205
|
+
+ '>';
|
|
206
|
+
for (let i = 0; i < node.children.length; i++) {
|
|
207
|
+
out += listItemHtml(node.children[i], ctx, node.tight);
|
|
208
|
+
}
|
|
209
|
+
return out + '</' + tag + '>';
|
|
210
|
+
},
|
|
211
|
+
|
|
212
|
+
code: (node) => '<pre><code'
|
|
213
|
+
+ (node.lang === null ? '' : attr('class', 'language-' + node.lang))
|
|
214
|
+
+ '>' + escapeText(node.value) + '</code></pre>',
|
|
215
|
+
|
|
216
|
+
html: (node, ctx) => rawHtml(node, ctx),
|
|
217
|
+
|
|
218
|
+
table: (node, ctx) => {
|
|
219
|
+
const rows = node.children;
|
|
220
|
+
if (rows.length === 0) return '<table></table>';
|
|
221
|
+
const align = node.align;
|
|
222
|
+
let out = '<table><thead><tr>' + cellsHtml(rows[0].children, align, 'th', ctx) + '</tr></thead>';
|
|
223
|
+
if (rows.length > 1) {
|
|
224
|
+
out += '<tbody>';
|
|
225
|
+
for (let r = 1; r < rows.length; r++) {
|
|
226
|
+
out += '<tr>' + cellsHtml(rows[r].children, align, 'td', ctx) + '</tr>';
|
|
227
|
+
}
|
|
228
|
+
out += '</tbody>';
|
|
229
|
+
}
|
|
230
|
+
return out + '</table>';
|
|
231
|
+
},
|
|
232
|
+
|
|
233
|
+
// Collected, not rendered in place (MD-FORMAT.md §4.6): the section
|
|
234
|
+
// at the end of the document is where a footnote's content appears,
|
|
235
|
+
// and an uncited definition appears nowhere at all.
|
|
236
|
+
footnoteDefinition: () => '',
|
|
237
|
+
|
|
238
|
+
custom: (node, ctx) => fallbackHtml(node, ctx, false),
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* The `<section class="footnotes">` appended after the last block: one
|
|
243
|
+
* `<li>` per cited definition, in first-citation order, each ending in a
|
|
244
|
+
* back-reference per citation.
|
|
245
|
+
* @param {HtmlCtx} ctx
|
|
246
|
+
* @returns {string}
|
|
247
|
+
*/
|
|
248
|
+
function footnotesHtml(ctx) {
|
|
249
|
+
const notes = ctx.footnotes;
|
|
250
|
+
if (notes === null || notes.defs.length === 0) return '';
|
|
251
|
+
const prefix = ctx.footnotePrefix;
|
|
252
|
+
let out = '<section class="footnotes"' + attr('aria-label', ctx.footnotesLabel) + '><ol>';
|
|
253
|
+
for (let i = 0; i < notes.defs.length; i++) {
|
|
254
|
+
const def = notes.defs[i];
|
|
255
|
+
const number = /** @type {number} */ (notes.numbers.get(def.identifier));
|
|
256
|
+
let back = '';
|
|
257
|
+
const times = notes.counts.get(def.identifier) ?? 1;
|
|
258
|
+
for (let k = 1; k <= times; k++) {
|
|
259
|
+
back += ' <a class="footnote-backref"'
|
|
260
|
+
+ attr('href', '#' + footnoteRefId(prefix, number, k))
|
|
261
|
+
+ attr('aria-label', backrefLabel(number, k))
|
|
262
|
+
+ '>' + BACKREF_MARK + (k > 1 ? '<sup>' + k + '</sup>' : '') + '</a>';
|
|
263
|
+
}
|
|
264
|
+
out += '<li' + attr('id', footnoteId(prefix, number)) + '>';
|
|
265
|
+
const blocks = def.children;
|
|
266
|
+
const last = blocks.length - 1;
|
|
267
|
+
for (let b = 0; b < blocks.length; b++) {
|
|
268
|
+
// The back-references ride along inside the closing paragraph, so
|
|
269
|
+
// they read as part of the note rather than as a block of their own.
|
|
270
|
+
out += b === last && blocks[b].type === 'paragraph'
|
|
271
|
+
? '<p>' + inlineChildren(blocks[b].children, ctx) + back + '</p>'
|
|
272
|
+
: blockHtml(blocks[b], ctx);
|
|
273
|
+
}
|
|
274
|
+
if (blocks.length === 0 || blocks[last].type !== 'paragraph') out += '<p>' + back + '</p>';
|
|
275
|
+
out += '</li>';
|
|
276
|
+
}
|
|
277
|
+
return out + '</ol></section>';
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* @param {MdNode[]} cells @param {(string|null)[]} align
|
|
282
|
+
* @param {string} tag @param {HtmlCtx} ctx
|
|
283
|
+
* @returns {string}
|
|
284
|
+
*/
|
|
285
|
+
function cellsHtml(cells, align, tag, ctx) {
|
|
286
|
+
let out = '';
|
|
287
|
+
for (let c = 0; c < cells.length; c++) {
|
|
288
|
+
const a = align[c] ?? null;
|
|
289
|
+
out += '<' + tag + (a === null ? '' : attr('style', 'text-align:' + a)) + '>'
|
|
290
|
+
+ inlineChildren(cells[c].children, ctx) + '</' + tag + '>';
|
|
291
|
+
}
|
|
292
|
+
return out;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* @param {MdNode} item @param {HtmlCtx} ctx
|
|
297
|
+
* @param {boolean} tight tight lists unwrap their paragraphs
|
|
298
|
+
* @returns {string}
|
|
299
|
+
*/
|
|
300
|
+
function listItemHtml(item, ctx, tight) {
|
|
301
|
+
let out = '<li>';
|
|
302
|
+
if (item.checked !== null && item.checked !== undefined) {
|
|
303
|
+
out += '<input type="checkbox"' + (item.checked ? ' checked' : '') + ' disabled> ';
|
|
304
|
+
}
|
|
305
|
+
const children = item.children;
|
|
306
|
+
for (let i = 0; i < children.length; i++) {
|
|
307
|
+
const child = children[i];
|
|
308
|
+
// see the vnode emitter: a tight item's unwrapped blocks need the
|
|
309
|
+
// newline that their absent element boundary would have provided
|
|
310
|
+
if (i > 0) out += '\n';
|
|
311
|
+
out += tight && child.type === 'paragraph'
|
|
312
|
+
? inlineChildren(child.children, ctx)
|
|
313
|
+
: blockHtml(child, ctx);
|
|
314
|
+
}
|
|
315
|
+
return out + '</li>';
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Unknown node types degrade honestly, exactly as the vnode emitter
|
|
320
|
+
* degrades them: a literal node shows its value as preformatted text, a
|
|
321
|
+
* container shows its children.
|
|
322
|
+
* @param {MdNode} node @param {HtmlCtx} ctx @param {boolean} inline
|
|
323
|
+
* @returns {string}
|
|
324
|
+
*/
|
|
325
|
+
function fallbackHtml(node, ctx, inline) {
|
|
326
|
+
if (typeof node.value === 'string') {
|
|
327
|
+
const tag = inline ? 'code' : 'pre';
|
|
328
|
+
return '<' + tag + attr('class', 'md-' + node.type) + '>'
|
|
329
|
+
+ escapeText(node.value) + '</' + tag + '>';
|
|
330
|
+
}
|
|
331
|
+
if (Array.isArray(node.children)) {
|
|
332
|
+
const tag = inline ? 'span' : 'div';
|
|
333
|
+
return '<' + tag + attr('class', 'md-' + node.type) + '>'
|
|
334
|
+
+ (inline ? inlineChildren(node.children, ctx) : blockChildren(node.children, ctx))
|
|
335
|
+
+ '</' + tag + '>';
|
|
336
|
+
}
|
|
337
|
+
return '';
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* A plugin that renders to vnodes but not to a string: the gap is
|
|
342
|
+
* SHOWN rather than silently dropped, so a consumer can see what it is
|
|
343
|
+
* missing. The type is reduced to identifier characters, because a
|
|
344
|
+
* comment must not be able to close itself.
|
|
345
|
+
* @param {string} type
|
|
346
|
+
* @returns {string}
|
|
347
|
+
*/
|
|
348
|
+
function unsupportedNode(type) {
|
|
349
|
+
const name = String(type).replace(/[^A-Za-z0-9_-]/g, '').replace(/-{2,}/g, '-');
|
|
350
|
+
return '<!-- unsupported plugin node: ' + name + ' -->';
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Emit a sequence of block nodes. Blocks are NOT separated: the output
|
|
355
|
+
* is markup, not a pretty-printed document, and the separator a reader
|
|
356
|
+
* expects is the block element itself.
|
|
357
|
+
* @param {MdNode[]} blocks @param {HtmlCtx} ctx
|
|
358
|
+
* @returns {string}
|
|
359
|
+
*/
|
|
360
|
+
function blockChildren(blocks, ctx) {
|
|
361
|
+
let out = '';
|
|
362
|
+
for (let i = 0; i < blocks.length; i++) out += blockHtml(blocks[i], ctx);
|
|
363
|
+
return out;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Emit one block node. A plugin's `toHtml` shadows the core table; a
|
|
368
|
+
* plugin that only renders vnodes falls back to the CORE emitter when
|
|
369
|
+
* the node type has one (the highlight plugin claims `code`, so a code
|
|
370
|
+
* block still prints, just unhighlighted) and is reported as
|
|
371
|
+
* unsupported only when nothing else can print it.
|
|
372
|
+
* @param {MdNode} node @param {HtmlCtx} ctx
|
|
373
|
+
* @returns {string}
|
|
374
|
+
*/
|
|
375
|
+
function blockHtml(node, ctx) {
|
|
376
|
+
const plugin = ctx.tables.htmls.get(node.type);
|
|
377
|
+
if (plugin !== undefined) return String(plugin.toHtml(node, ctx) ?? '');
|
|
378
|
+
const renderer = BLOCK_HTML[node.type];
|
|
379
|
+
if (renderer !== undefined) return renderer(node, ctx);
|
|
380
|
+
if (ctx.tables.renders.has(node.type)) return unsupportedNode(node.type);
|
|
381
|
+
return fallbackHtml(node, ctx, false);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Render an MdDocument (or a CompiledMd, an AST array, a single node)
|
|
386
|
+
* to an HTML string.
|
|
387
|
+
*
|
|
388
|
+
* @example
|
|
389
|
+
* toHtml(parseMarkdown('# Hi')); // '<h1>Hi</h1>'
|
|
390
|
+
* toHtml(parseMarkdown('<b>x</b>')); // '<b>x</b>'
|
|
391
|
+
* toHtml(doc, { html: 'raw' }); // trusted input only
|
|
392
|
+
* toHtml(doc, { wrap: 'article class="md"' }); // wrapped
|
|
393
|
+
*
|
|
394
|
+
* @param {any} docOrAst MdDocument, CompiledMd, MdNode[] or MdNode
|
|
395
|
+
* @param {MdHtmlOptions} [options]
|
|
396
|
+
* @returns {string}
|
|
397
|
+
*/
|
|
398
|
+
export function toHtml(docOrAst, options = {}) {
|
|
399
|
+
const ast = Array.isArray(docOrAst)
|
|
400
|
+
? docOrAst
|
|
401
|
+
: (docOrAst !== null && typeof docOrAst === 'object' && Array.isArray(docOrAst.ast))
|
|
402
|
+
? docOrAst.ast
|
|
403
|
+
: [docOrAst];
|
|
404
|
+
/** @type {HtmlCtx} */
|
|
405
|
+
const ctx = {
|
|
406
|
+
tables: docOrAst?.tables ?? buildPluginTables(options.plugins),
|
|
407
|
+
html: options.html === 'raw' || options.html === 'skip' ? options.html : 'escape',
|
|
408
|
+
sanitizeUrl: typeof options.sanitizeUrl === 'function'
|
|
409
|
+
? options.sanitizeUrl
|
|
410
|
+
: defaultSanitizeUrl,
|
|
411
|
+
headingIds: options.headingIds === true,
|
|
412
|
+
slugPrefix: typeof options.slugPrefix === 'string' ? options.slugPrefix : '',
|
|
413
|
+
headingAnchors: options.headingIds === true && options.headingAnchors === true,
|
|
414
|
+
slugs: new Map(),
|
|
415
|
+
options,
|
|
416
|
+
footnotes: collectFootnotes(ast),
|
|
417
|
+
footnotePrefix: typeof options.slugPrefix === 'string' ? options.slugPrefix : FOOTNOTE_PREFIX,
|
|
418
|
+
footnotesLabel: typeof options.footnotesLabel === 'string' ? options.footnotesLabel : 'Footnotes',
|
|
419
|
+
};
|
|
420
|
+
const body = blockChildren(ast, ctx) + footnotesHtml(ctx);
|
|
421
|
+
const wrap = options.wrap;
|
|
422
|
+
if (typeof wrap !== 'string' || wrap === '') return body;
|
|
423
|
+
const space = wrap.indexOf(' ');
|
|
424
|
+
return '<' + wrap + '>' + body + '</' + (space === -1 ? wrap : wrap.slice(0, space)) + '>';
|
|
425
|
+
}
|