@tabnas/markdown 0.4.1 → 0.5.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/src/html.ts ADDED
@@ -0,0 +1,321 @@
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 } 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
+ * Render `doc` (a tree that has been through both parse phases) as HTML.
34
+ *
35
+ * Only `options.breaks` affects the output: it turns soft line breaks into
36
+ * hard ones. `gfm` is a parse-time concern — by the time a `del` node exists
37
+ * the renderer just prints it.
38
+ */
39
+ export function renderHTML(doc: MdNode, options?: Partial<ParserOptions>): string {
40
+ const opts = resolveOptions(options)
41
+
42
+ // §6.9 soft line breaks: rendered as a newline, or as a hard break when the
43
+ // caller asks for `breaks`.
44
+ const softbreak = opts.breaks ? '<br />\n' : '\n'
45
+
46
+ let buf = ''
47
+ // Tracks whether the buffer is positioned at the start of a line, which is
48
+ // the only thing `cr()` needs to know. An empty buffer counts as at-line-
49
+ // start, so a leading `cr()` (every top-level block opens with one) writes
50
+ // nothing.
51
+ let atLineStart = true
52
+
53
+ function lit(s: string): void {
54
+ // An empty write must not claim the line is dirty, or the next `cr()`
55
+ // would insert a spurious newline.
56
+ if ('' === s) return
57
+ buf += s
58
+ atLineStart = '\n' === s.charAt(s.length - 1)
59
+ }
60
+
61
+ function cr(): void {
62
+ if (!atLineStart) lit('\n')
63
+ }
64
+
65
+ function tag(name: string, attrs?: Attr[], selfClosing?: boolean): void {
66
+ let s = '<' + name
67
+ if (undefined !== attrs) {
68
+ for (const attr of attrs) {
69
+ s += ' ' + attr[0] + '="' + attr[1] + '"'
70
+ }
71
+ }
72
+ if (true === selfClosing) s += ' /'
73
+ lit(s + '>')
74
+ }
75
+
76
+ const walker = doc.walker()
77
+ let event = walker.next()
78
+
79
+ while (null !== event) {
80
+ const node = event.node
81
+ const entering = event.entering
82
+
83
+ switch (node.type) {
84
+ case 'document':
85
+ break
86
+
87
+ case 'paragraph': {
88
+ // §5.3: "if a list is tight, we remove the <p> tags from the item
89
+ // contents". The paragraph's grandparent is the list — a paragraph
90
+ // whose grandparent is a list is necessarily an item's direct child.
91
+ const grandparent = null === node.parent ? null : node.parent.parent
92
+ if (
93
+ null !== grandparent &&
94
+ 'list' === grandparent.type &&
95
+ null !== grandparent.listData &&
96
+ grandparent.listData.tight
97
+ ) {
98
+ break
99
+ }
100
+ if (entering) {
101
+ cr()
102
+ tag('p')
103
+ } else {
104
+ tag('/p')
105
+ cr()
106
+ }
107
+ break
108
+ }
109
+
110
+ case 'heading': {
111
+ const name = 'h' + node.level
112
+ if (entering) {
113
+ cr()
114
+ tag(name)
115
+ } else {
116
+ tag('/' + name)
117
+ cr()
118
+ }
119
+ break
120
+ }
121
+
122
+ case 'thematic_break':
123
+ cr()
124
+ tag('hr', undefined, true)
125
+ cr()
126
+ break
127
+
128
+ case 'block_quote':
129
+ cr()
130
+ tag(entering ? 'blockquote' : '/blockquote')
131
+ cr()
132
+ break
133
+
134
+ case 'list': {
135
+ const data = node.listData
136
+ const ordered = null !== data && 'ordered' === data.type
137
+ const name = ordered ? 'ol' : 'ul'
138
+ if (entering) {
139
+ const attrs: Attr[] = []
140
+ // §5.3: the start number is only rendered when it is not 1 — and
141
+ // `start="0"` is meaningful, so this is an inequality, not a
142
+ // truthiness test.
143
+ if (ordered && null !== data && 1 !== data.start) {
144
+ attrs.push(['start', String(data.start)])
145
+ }
146
+ cr()
147
+ tag(name, attrs)
148
+ } else {
149
+ cr()
150
+ tag('/' + name)
151
+ }
152
+ cr()
153
+ break
154
+ }
155
+
156
+ case 'item':
157
+ // No `cr()` after `<li>`: a tight item renders `<li>text</li>`, and a
158
+ // loose one gets its newline from the leading `cr()` of the first
159
+ // child block.
160
+ if (entering) {
161
+ tag('li')
162
+ } else {
163
+ tag('/li')
164
+ cr()
165
+ }
166
+ break
167
+
168
+ case 'code_block': {
169
+ const attrs: Attr[] = []
170
+ // §4.5: "the first word of the info string is typically used to
171
+ // specify the language". The info string arrives from the block phase
172
+ // already backslash-unescaped and entity-decoded; only the escaping
173
+ // for attribute context is left to do here.
174
+ const info = null === node.info ? '' : node.info
175
+ const firstWord = '' === info ? '' : info.split(/\s+/)[0]
176
+ if ('' !== firstWord) {
177
+ attrs.push(['class', 'language-' + escapeXml(firstWord)])
178
+ }
179
+ cr()
180
+ tag('pre')
181
+ tag('code', attrs)
182
+ // The block phase guarantees the literal already ends with a newline
183
+ // (or is empty, for an empty fenced block, which renders as
184
+ // `<pre><code></code></pre>`).
185
+ lit(escapeXml(null === node.literal ? '' : node.literal))
186
+ tag('/code')
187
+ tag('/pre')
188
+ cr()
189
+ break
190
+ }
191
+
192
+ case 'html_block':
193
+ // §4.6: raw HTML passes through verbatim, unescaped.
194
+ cr()
195
+ lit(null === node.literal ? '' : node.literal)
196
+ cr()
197
+ break
198
+
199
+ case 'text':
200
+ lit(escapeXml(null === node.literal ? '' : node.literal))
201
+ break
202
+
203
+ case 'softbreak':
204
+ lit(softbreak)
205
+ break
206
+
207
+ case 'linebreak':
208
+ tag('br', undefined, true)
209
+ cr()
210
+ break
211
+
212
+ case 'code':
213
+ tag('code')
214
+ lit(escapeXml(null === node.literal ? '' : node.literal))
215
+ tag('/code')
216
+ break
217
+
218
+ case 'html_inline':
219
+ lit(null === node.literal ? '' : node.literal)
220
+ break
221
+
222
+ case 'emph':
223
+ tag(entering ? 'em' : '/em')
224
+ break
225
+
226
+ case 'strong':
227
+ tag(entering ? 'strong' : '/strong')
228
+ break
229
+
230
+ case 'del':
231
+ tag(entering ? 'del' : '/del')
232
+ break
233
+
234
+ case 'link':
235
+ if (entering) {
236
+ const attrs: Attr[] = [['href', escapeXml(normalizeURI(destinationOf(node)))]]
237
+ // An empty title is dropped: `[link](/url "")` renders as a bare
238
+ // `<a href="/url">`.
239
+ if (null !== node.title && '' !== node.title) {
240
+ attrs.push(['title', escapeXml(node.title)])
241
+ }
242
+ tag('a', attrs)
243
+ } else {
244
+ tag('/a')
245
+ }
246
+ break
247
+
248
+ case 'image': {
249
+ if (entering) {
250
+ const attrs: Attr[] = [
251
+ ['src', escapeXml(normalizeURI(destinationOf(node)))],
252
+ ['alt', escapeXml(altText(node))],
253
+ ]
254
+ if (null !== node.title && '' !== node.title) {
255
+ attrs.push(['title', escapeXml(node.title)])
256
+ }
257
+ tag('img', attrs, true)
258
+ // The children have been consumed into `alt`; jump the walk
259
+ // straight to this image's exit event so they are not also
260
+ // rendered as markup.
261
+ walker.resumeAt(node, false)
262
+ }
263
+ break
264
+ }
265
+ }
266
+
267
+ event = walker.next()
268
+ }
269
+
270
+ return buf
271
+ }
272
+
273
+ function destinationOf(node: MdNode): string {
274
+ return null === node.destination ? '' : node.destination
275
+ }
276
+
277
+ /**
278
+ * §6.4 images: "the image description gets represented as the `alt`
279
+ * attribute" — as plain text. Descend the subtree keeping the text and
280
+ * code-span literals and dropping the markup wrappers, so
281
+ * `![foo *bar*](/url)` yields `alt="foo bar"` and a nested link contributes
282
+ * only its text.
283
+ *
284
+ * Raw inline HTML and line breaks inside a description are not exercised by
285
+ * the spec suite and the two reference implementations disagree: cmark
286
+ * escapes the raw HTML into the attribute and turns breaks into spaces,
287
+ * commonmark.js emits the HTML verbatim (producing a malformed attribute)
288
+ * and turns breaks into newlines. We keep the literal — escaped, since the
289
+ * result is attribute-context text — and follow commonmark.js on breaks.
290
+ */
291
+ function altText(image: MdNode): string {
292
+ let alt = ''
293
+
294
+ const walker = image.walker()
295
+ walker.next() // discard the image's own entering event
296
+
297
+ let event = walker.next()
298
+ while (null !== event && event.node !== image) {
299
+ const node = event.node
300
+ if (event.entering) {
301
+ switch (node.type) {
302
+ case 'text':
303
+ case 'code':
304
+ case 'html_inline':
305
+ alt += null === node.literal ? '' : node.literal
306
+ break
307
+ case 'softbreak':
308
+ case 'linebreak':
309
+ alt += '\n'
310
+ break
311
+ default:
312
+ // Containers (emph, strong, link, a nested image) contribute
313
+ // nothing themselves; the walk still descends into their children.
314
+ break
315
+ }
316
+ }
317
+ event = walker.next()
318
+ }
319
+
320
+ return alt
321
+ }