@tabnas/markdown 0.4.2 → 0.5.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/src/html.ts ADDED
@@ -0,0 +1,446 @@
1
+ /* Copyright (c) 2021-2026 Richard Rodger, MIT License */
2
+
3
+ // HTML renderer for the finished CommonMark tree.
4
+ //
5
+ // The conformance suite compares this output to the spec's expected HTML
6
+ // byte for byte, so newline placement is a correctness contract here, not a
7
+ // formatting preference. The discipline that makes it come out right is the
8
+ // reference renderer's:
9
+ //
10
+ // * every block writes `cr()` before its opening tag and after its closing
11
+ // tag, and `cr()` is a no-op when the buffer is already at the start of a
12
+ // line;
13
+ // * no block ever emits a newline on behalf of a neighbour or a child.
14
+ //
15
+ // Because a block only ever asks for "be at line start", nested blocks
16
+ // compose without doubling up: `<li>` deliberately does *not* end a line, and
17
+ // the newline in `<li>\n<p>…` is the paragraph's own leading `cr()`. A tight
18
+ // item, whose paragraph is skipped entirely, therefore renders as
19
+ // `<li>text</li>` with no interior newlines at all.
20
+ //
21
+ // The renderer reads only the finished tree — never `stringContent`, which
22
+ // the inline phase has already consumed.
23
+
24
+ import type { MdNode, TableAlign } from './node.ts'
25
+ import { escapeXml, normalizeURI } from './common.ts'
26
+ import { resolveOptions } from './options.ts'
27
+ import type { ParserOptions } from './options.ts'
28
+
29
+ /** An HTML attribute as [name, already-escaped value]. */
30
+ type Attr = [string, string]
31
+
32
+ /**
33
+ * GFM's `tagfilter` extension. These nine tags change how the *rest* of a
34
+ * document is interpreted (everything after `<xmp>` or `<plaintext>` stops
35
+ * being markup at all), so GFM neutralises them by rewriting the leading `<`
36
+ * as `&lt;` — opening and closing, in any case. Every other tag still passes
37
+ * through verbatim, as CommonMark requires.
38
+ *
39
+ * This is purely a rendering concern: nothing rewrites the node, so the
40
+ * literal in the tree and the `value` in the AST stay exactly as written.
41
+ *
42
+ * The tag name must be followed by whitespace, `/`, `>` or the end of the
43
+ * text, so `<scriptlet>` and `<titles>` are untouched.
44
+ */
45
+ const RE_DISALLOWED_TAG =
46
+ /<\/?(?:title|textarea|style|xmp|iframe|noembed|noframes|script|plaintext)(?=[\t\n\f\r />]|$)/gi
47
+
48
+ function filterDisallowedTags(html: string): string {
49
+ // Cheap reject: the vast majority of raw HTML holds none of these.
50
+ if (-1 === html.indexOf('<')) return html
51
+ return html.replace(RE_DISALLOWED_TAG, (m) => '&lt;' + m.slice(1))
52
+ }
53
+
54
+ /**
55
+ * Render `doc` (a tree that has been through both parse phases) as HTML.
56
+ *
57
+ * Two options reach the renderer. `breaks` turns soft line breaks into hard
58
+ * ones. `gfm` selects one thing only — the disallowed-raw-HTML filter, which
59
+ * the extension defines at render time rather than at parse time. Everything
60
+ * else GFM adds is settled by then: by the time a `del` node or an item's
61
+ * `checked` state exists, the renderer just prints it.
62
+ *
63
+ * `gfm` defaults to whatever the *document* was parsed with rather than to the
64
+ * package default, so `renderHTML(tree)` on a `gfm:false` parse renders plain
65
+ * CommonMark. An explicit `options.gfm` still wins.
66
+ */
67
+ export function renderHTML(doc: MdNode, options?: Partial<ParserOptions>): string {
68
+ const opts = resolveOptions({
69
+ gfm: options?.gfm ?? doc.gfm,
70
+ breaks: options?.breaks,
71
+ })
72
+
73
+ /** Raw HTML as written, minus the tags GFM's tagfilter neutralises. */
74
+ const rawHtml = (literal: string | null): string => {
75
+ const s = null === literal ? '' : literal
76
+ return opts.gfm ? filterDisallowedTags(s) : s
77
+ }
78
+
79
+ // §6.9 soft line breaks: rendered as a newline, or as a hard break when the
80
+ // caller asks for `breaks`.
81
+ const softbreak = opts.breaks ? '<br />\n' : '\n'
82
+
83
+ let buf = ''
84
+ // Tracks whether the buffer is positioned at the start of a line, which is
85
+ // the only thing `cr()` needs to know. An empty buffer counts as at-line-
86
+ // start, so a leading `cr()` (every top-level block opens with one) writes
87
+ // nothing.
88
+ let atLineStart = true
89
+
90
+ function lit(s: string): void {
91
+ // An empty write must not claim the line is dirty, or the next `cr()`
92
+ // would insert a spurious newline.
93
+ if ('' === s) return
94
+ buf += s
95
+ atLineStart = '\n' === s.charAt(s.length - 1)
96
+ }
97
+
98
+ function cr(): void {
99
+ if (!atLineStart) lit('\n')
100
+ }
101
+
102
+ function tag(name: string, attrs?: Attr[], selfClosing?: boolean): void {
103
+ let s = '<' + name
104
+ if (undefined !== attrs) {
105
+ for (const attr of attrs) {
106
+ s += ' ' + attr[0] + '="' + attr[1] + '"'
107
+ }
108
+ }
109
+ if (true === selfClosing) s += ' /'
110
+ lit(s + '>')
111
+ }
112
+
113
+ // GFM tables carry their alignment once, on the table, but it is rendered
114
+ // per cell. Tracking the current table's array and the current row's column
115
+ // as the walk goes is what keeps that an O(1) lookup instead of counting a
116
+ // cell's previous siblings — and a table can never contain another table
117
+ // (its cells hold inlines only), so one of each is enough.
118
+ let tableAlign: TableAlign[] = []
119
+ let cellIndex = 0
120
+
121
+ const walker = doc.walker()
122
+ let event = walker.next()
123
+
124
+ while (null !== event) {
125
+ const node = event.node
126
+ const entering = event.entering
127
+
128
+ switch (node.type) {
129
+ case 'document':
130
+ break
131
+
132
+ case 'paragraph': {
133
+ // §5.3: "if a list is tight, we remove the <p> tags from the item
134
+ // contents". The paragraph's grandparent is the list — a paragraph
135
+ // whose grandparent is a list is necessarily an item's direct child.
136
+ const grandparent = null === node.parent ? null : node.parent.parent
137
+ const tight =
138
+ null !== grandparent &&
139
+ 'list' === grandparent.type &&
140
+ null !== grandparent.listData &&
141
+ grandparent.listData.tight
142
+
143
+ if (entering) {
144
+ if (!tight) {
145
+ cr()
146
+ tag('p')
147
+ }
148
+ // GFM task list item. The block phase consumed the `[x]` marker and
149
+ // left the state on the item, so the checkbox is written here, at
150
+ // the head of the item's first paragraph: directly after `<li>` when
151
+ // the list is tight, and inside the `<p>` when it is loose. The
152
+ // trailing space is the separator the extension's own output shows
153
+ // between the checkbox and the item text.
154
+ const item = node.parent
155
+ if (
156
+ null !== item &&
157
+ 'item' === item.type &&
158
+ item.firstChild === node &&
159
+ null !== item.checked
160
+ ) {
161
+ const attrs: Attr[] = []
162
+ if (item.checked) attrs.push(['checked', ''])
163
+ attrs.push(['disabled', ''])
164
+ attrs.push(['type', 'checkbox'])
165
+ tag('input', attrs)
166
+ lit(' ')
167
+ }
168
+ } else if (!tight) {
169
+ tag('/p')
170
+ cr()
171
+ }
172
+ break
173
+ }
174
+
175
+ case 'heading': {
176
+ const name = 'h' + node.level
177
+ if (entering) {
178
+ cr()
179
+ tag(name)
180
+ } else {
181
+ tag('/' + name)
182
+ cr()
183
+ }
184
+ break
185
+ }
186
+
187
+ case 'thematic_break':
188
+ cr()
189
+ tag('hr', undefined, true)
190
+ cr()
191
+ break
192
+
193
+ case 'block_quote':
194
+ cr()
195
+ tag(entering ? 'blockquote' : '/blockquote')
196
+ cr()
197
+ break
198
+
199
+ case 'list': {
200
+ const data = node.listData
201
+ const ordered = null !== data && 'ordered' === data.type
202
+ const name = ordered ? 'ol' : 'ul'
203
+ if (entering) {
204
+ const attrs: Attr[] = []
205
+ // §5.3: the start number is only rendered when it is not 1 — and
206
+ // `start="0"` is meaningful, so this is an inequality, not a
207
+ // truthiness test.
208
+ if (ordered && null !== data && 1 !== data.start) {
209
+ attrs.push(['start', String(data.start)])
210
+ }
211
+ cr()
212
+ tag(name, attrs)
213
+ } else {
214
+ cr()
215
+ tag('/' + name)
216
+ }
217
+ cr()
218
+ break
219
+ }
220
+
221
+ case 'item':
222
+ // No `cr()` after `<li>`: a tight item renders `<li>text</li>`, and a
223
+ // loose one gets its newline from the leading `cr()` of the first
224
+ // child block.
225
+ if (entering) {
226
+ tag('li')
227
+ } else {
228
+ tag('/li')
229
+ cr()
230
+ }
231
+ break
232
+
233
+ case 'code_block': {
234
+ const attrs: Attr[] = []
235
+ // §4.5: "the first word of the info string is typically used to
236
+ // specify the language". The info string arrives from the block phase
237
+ // already backslash-unescaped and entity-decoded; only the escaping
238
+ // for attribute context is left to do here.
239
+ const info = null === node.info ? '' : node.info
240
+ const firstWord = '' === info ? '' : info.split(/\s+/)[0]
241
+ if ('' !== firstWord) {
242
+ attrs.push(['class', 'language-' + escapeXml(firstWord)])
243
+ }
244
+ cr()
245
+ tag('pre')
246
+ tag('code', attrs)
247
+ // The block phase guarantees the literal already ends with a newline
248
+ // (or is empty, for an empty fenced block, which renders as
249
+ // `<pre><code></code></pre>`).
250
+ lit(escapeXml(null === node.literal ? '' : node.literal))
251
+ tag('/code')
252
+ tag('/pre')
253
+ cr()
254
+ break
255
+ }
256
+
257
+ case 'html_block':
258
+ // §4.6: raw HTML passes through verbatim, unescaped — except for the
259
+ // nine tags GFM's tagfilter neutralises.
260
+ cr()
261
+ lit(rawHtml(node.literal))
262
+ cr()
263
+ break
264
+
265
+ // --- GFM tables ---
266
+ //
267
+ // `<thead>` and `<tbody>` have no node of their own: the header row is
268
+ // the one row flagged as such, and the body is every row after it. That
269
+ // is also why `<tbody>` is written by the *first* body row rather than
270
+ // unconditionally — a table with no body rows must have no `<tbody>` at
271
+ // all.
272
+
273
+ case 'table':
274
+ if (entering) {
275
+ tableAlign = node.tableAlign ?? []
276
+ cr()
277
+ tag('table')
278
+ } else {
279
+ cr()
280
+ tag('/table')
281
+ }
282
+ cr()
283
+ break
284
+
285
+ case 'table_row': {
286
+ const header = node.isHeaderRow
287
+ if (entering) {
288
+ if (header) {
289
+ cr()
290
+ tag('thead')
291
+ } else if (null === node.prev || node.prev.isHeaderRow) {
292
+ cr()
293
+ tag('tbody')
294
+ }
295
+ cr()
296
+ tag('tr')
297
+ cellIndex = 0
298
+ } else {
299
+ cr()
300
+ tag('/tr')
301
+ cr()
302
+ if (header) tag('/thead')
303
+ else if (null === node.next) tag('/tbody')
304
+ }
305
+ cr()
306
+ break
307
+ }
308
+
309
+ case 'table_cell': {
310
+ const name = null !== node.parent && node.parent.isHeaderRow ? 'th' : 'td'
311
+ if (entering) {
312
+ const attrs: Attr[] = []
313
+ const align = tableAlign[cellIndex]
314
+ if (undefined !== align && null !== align) attrs.push(['align', align])
315
+ cellIndex += 1
316
+ tag(name, attrs)
317
+ } else {
318
+ tag('/' + name)
319
+ cr()
320
+ }
321
+ break
322
+ }
323
+
324
+ case 'text':
325
+ lit(escapeXml(null === node.literal ? '' : node.literal))
326
+ break
327
+
328
+ case 'softbreak':
329
+ lit(softbreak)
330
+ break
331
+
332
+ case 'linebreak':
333
+ tag('br', undefined, true)
334
+ cr()
335
+ break
336
+
337
+ case 'code':
338
+ tag('code')
339
+ lit(escapeXml(null === node.literal ? '' : node.literal))
340
+ tag('/code')
341
+ break
342
+
343
+ case 'html_inline':
344
+ lit(rawHtml(node.literal))
345
+ break
346
+
347
+ case 'emph':
348
+ tag(entering ? 'em' : '/em')
349
+ break
350
+
351
+ case 'strong':
352
+ tag(entering ? 'strong' : '/strong')
353
+ break
354
+
355
+ case 'del':
356
+ tag(entering ? 'del' : '/del')
357
+ break
358
+
359
+ case 'link':
360
+ if (entering) {
361
+ const attrs: Attr[] = [['href', escapeXml(normalizeURI(destinationOf(node)))]]
362
+ // An empty title is dropped: `[link](/url "")` renders as a bare
363
+ // `<a href="/url">`.
364
+ if (null !== node.title && '' !== node.title) {
365
+ attrs.push(['title', escapeXml(node.title)])
366
+ }
367
+ tag('a', attrs)
368
+ } else {
369
+ tag('/a')
370
+ }
371
+ break
372
+
373
+ case 'image': {
374
+ if (entering) {
375
+ const attrs: Attr[] = [
376
+ ['src', escapeXml(normalizeURI(destinationOf(node)))],
377
+ ['alt', escapeXml(altText(node))],
378
+ ]
379
+ if (null !== node.title && '' !== node.title) {
380
+ attrs.push(['title', escapeXml(node.title)])
381
+ }
382
+ tag('img', attrs, true)
383
+ // The children have been consumed into `alt`; jump the walk
384
+ // straight to this image's exit event so they are not also
385
+ // rendered as markup.
386
+ walker.resumeAt(node, false)
387
+ }
388
+ break
389
+ }
390
+ }
391
+
392
+ event = walker.next()
393
+ }
394
+
395
+ return buf
396
+ }
397
+
398
+ function destinationOf(node: MdNode): string {
399
+ return null === node.destination ? '' : node.destination
400
+ }
401
+
402
+ /**
403
+ * §6.4 images: "the image description gets represented as the `alt`
404
+ * attribute" — as plain text. Descend the subtree keeping the text and
405
+ * code-span literals and dropping the markup wrappers, so
406
+ * `![foo *bar*](/url)` yields `alt="foo bar"` and a nested link contributes
407
+ * only its text.
408
+ *
409
+ * Raw inline HTML and line breaks inside a description are not exercised by
410
+ * the spec suite and the two reference implementations disagree: cmark
411
+ * escapes the raw HTML into the attribute and turns breaks into spaces,
412
+ * commonmark.js emits the HTML verbatim (producing a malformed attribute)
413
+ * and turns breaks into newlines. We keep the literal — escaped, since the
414
+ * result is attribute-context text — and follow commonmark.js on breaks.
415
+ */
416
+ function altText(image: MdNode): string {
417
+ let alt = ''
418
+
419
+ const walker = image.walker()
420
+ walker.next() // discard the image's own entering event
421
+
422
+ let event = walker.next()
423
+ while (null !== event && event.node !== image) {
424
+ const node = event.node
425
+ if (event.entering) {
426
+ switch (node.type) {
427
+ case 'text':
428
+ case 'code':
429
+ case 'html_inline':
430
+ alt += null === node.literal ? '' : node.literal
431
+ break
432
+ case 'softbreak':
433
+ case 'linebreak':
434
+ alt += '\n'
435
+ break
436
+ default:
437
+ // Containers (emph, strong, link, a nested image) contribute
438
+ // nothing themselves; the walk still descends into their children.
439
+ break
440
+ }
441
+ }
442
+ event = walker.next()
443
+ }
444
+
445
+ return alt
446
+ }