@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.
Files changed (49) hide show
  1. package/README.md +520 -0
  2. package/dist/types/ast.d.ts +181 -0
  3. package/dist/types/bake.d.ts +61 -0
  4. package/dist/types/compiler.d.ts +141 -0
  5. package/dist/types/component/index.d.ts +101 -0
  6. package/dist/types/directives.d.ts +126 -0
  7. package/dist/types/entities.d.ts +40 -0
  8. package/dist/types/footnotes.d.ts +83 -0
  9. package/dist/types/frontmatter.d.ts +67 -0
  10. package/dist/types/html.d.ts +72 -0
  11. package/dist/types/index.d.ts +30 -0
  12. package/dist/types/loader.d.ts +84 -0
  13. package/dist/types/mdx.d.ts +45 -0
  14. package/dist/types/parser.d.ts +116 -0
  15. package/dist/types/plugins/highlight.d.ts +64 -0
  16. package/dist/types/plugins/index.d.ts +64 -0
  17. package/dist/types/plugins/mermaid.d.ts +12 -0
  18. package/dist/types/scanner.d.ts +240 -0
  19. package/dist/types/to-html.d.ts +104 -0
  20. package/dist/types/to-md.d.ts +23 -0
  21. package/dist/types/to-vnode.d.ts +161 -0
  22. package/dist/types/utils.d.ts +63 -0
  23. package/docs/LOADER.md +92 -0
  24. package/docs/MD-FORMAT.md +502 -0
  25. package/docs/PLUGINS.md +277 -0
  26. package/package.json +80 -0
  27. package/schemas/jaren-md-ast.schema.json +296 -0
  28. package/src/ast.js +346 -0
  29. package/src/bake.js +104 -0
  30. package/src/compiler.js +167 -0
  31. package/src/component/index.js +191 -0
  32. package/src/directives.js +371 -0
  33. package/src/entities.js +107 -0
  34. package/src/footnotes.js +180 -0
  35. package/src/frontmatter.js +947 -0
  36. package/src/html.js +281 -0
  37. package/src/index.js +76 -0
  38. package/src/loader.js +0 -0
  39. package/src/mdx.js +219 -0
  40. package/src/parser.js +1685 -0
  41. package/src/plugins/highlight.js +325 -0
  42. package/src/plugins/index.js +75 -0
  43. package/src/plugins/mermaid.js +14 -0
  44. package/src/scanner.js +832 -0
  45. package/src/to-html.js +425 -0
  46. package/src/to-md.js +396 -0
  47. package/src/to-vnode.js +766 -0
  48. package/src/utils.js +107 -0
  49. 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>')); // '&lt;b&gt;x&lt;/b&gt;'
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
+ }