@tabnas/markdown 0.4.2 → 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/README.md +78 -12
- package/dist/ast.d.ts +83 -0
- package/dist/ast.js +248 -0
- package/dist/ast.js.map +1 -0
- package/dist/block.d.ts +10 -0
- package/dist/block.js +971 -0
- package/dist/block.js.map +1 -0
- package/dist/common.d.ts +49 -0
- package/dist/common.js +155 -0
- package/dist/common.js.map +1 -0
- package/dist/commonmark.d.ts +14 -0
- package/dist/commonmark.js +31 -0
- package/dist/commonmark.js.map +1 -0
- package/dist/entities.d.ts +1 -0
- package/dist/entities.js +14 -0
- package/dist/entities.js.map +1 -0
- package/dist/html.d.ts +10 -0
- package/dist/html.js +270 -0
- package/dist/html.js.map +1 -0
- package/dist/inline.d.ts +13 -0
- package/dist/inline.js +902 -0
- package/dist/inline.js.map +1 -0
- package/dist/markdown.d.ts +18 -84
- package/dist/markdown.js +80 -687
- package/dist/markdown.js.map +1 -1
- package/dist/node.d.ts +74 -0
- package/dist/node.js +167 -0
- package/dist/node.js.map +1 -0
- package/dist/options.d.ts +21 -0
- package/dist/options.js +16 -0
- package/dist/options.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -1
- package/src/ast.ts +352 -0
- package/src/block.ts +1094 -0
- package/src/common.ts +166 -0
- package/src/commonmark.ts +34 -0
- package/src/entities.ts +14 -0
- package/src/html.ts +321 -0
- package/src/inline.ts +1034 -0
- package/src/markdown.ts +126 -770
- package/src/node.ts +240 -0
- package/src/options.ts +39 -0
- package/src/tsconfig.json +2 -0
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
|
+
* `` 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
|
+
}
|