@tabnas/markdown 0.5.0 → 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/README.md +65 -10
- package/dist/ast.d.ts +31 -1
- package/dist/ast.js +26 -1
- package/dist/ast.js.map +1 -1
- package/dist/block.js +448 -8
- package/dist/block.js.map +1 -1
- package/dist/html.d.ts +9 -3
- package/dist/html.js +137 -14
- package/dist/html.js.map +1 -1
- package/dist/inline.d.ts +7 -1
- package/dist/inline.js +431 -2
- package/dist/inline.js.map +1 -1
- package/dist/markdown.d.ts +5 -2
- package/dist/markdown.js.map +1 -1
- package/dist/node.d.ts +39 -1
- package/dist/node.js +36 -0
- package/dist/node.js.map +1 -1
- package/dist/options.d.ts +9 -4
- package/dist/options.js.map +1 -1
- package/package.json +2 -1
- package/src/ast.ts +56 -2
- package/src/block.ts +495 -10
- package/src/html.ts +140 -15
- package/src/inline.ts +463 -2
- package/src/markdown.ts +7 -1
- package/src/node.ts +53 -0
- package/src/options.ts +9 -4
package/src/html.ts
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
// The renderer reads only the finished tree — never `stringContent`, which
|
|
22
22
|
// the inline phase has already consumed.
|
|
23
23
|
|
|
24
|
-
import type { MdNode } from './node.ts'
|
|
24
|
+
import type { MdNode, TableAlign } from './node.ts'
|
|
25
25
|
import { escapeXml, normalizeURI } from './common.ts'
|
|
26
26
|
import { resolveOptions } from './options.ts'
|
|
27
27
|
import type { ParserOptions } from './options.ts'
|
|
@@ -29,15 +29,52 @@ import type { ParserOptions } from './options.ts'
|
|
|
29
29
|
/** An HTML attribute as [name, already-escaped value]. */
|
|
30
30
|
type Attr = [string, string]
|
|
31
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 `<` — 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) => '<' + m.slice(1))
|
|
52
|
+
}
|
|
53
|
+
|
|
32
54
|
/**
|
|
33
55
|
* Render `doc` (a tree that has been through both parse phases) as HTML.
|
|
34
56
|
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
* the
|
|
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.
|
|
38
66
|
*/
|
|
39
67
|
export function renderHTML(doc: MdNode, options?: Partial<ParserOptions>): string {
|
|
40
|
-
const opts = resolveOptions(
|
|
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
|
+
}
|
|
41
78
|
|
|
42
79
|
// §6.9 soft line breaks: rendered as a newline, or as a hard break when the
|
|
43
80
|
// caller asks for `breaks`.
|
|
@@ -73,6 +110,14 @@ export function renderHTML(doc: MdNode, options?: Partial<ParserOptions>): strin
|
|
|
73
110
|
lit(s + '>')
|
|
74
111
|
}
|
|
75
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
|
+
|
|
76
121
|
const walker = doc.walker()
|
|
77
122
|
let event = walker.next()
|
|
78
123
|
|
|
@@ -89,18 +134,38 @@ export function renderHTML(doc: MdNode, options?: Partial<ParserOptions>): strin
|
|
|
89
134
|
// contents". The paragraph's grandparent is the list — a paragraph
|
|
90
135
|
// whose grandparent is a list is necessarily an item's direct child.
|
|
91
136
|
const grandparent = null === node.parent ? null : node.parent.parent
|
|
92
|
-
|
|
137
|
+
const tight =
|
|
93
138
|
null !== grandparent &&
|
|
94
139
|
'list' === grandparent.type &&
|
|
95
140
|
null !== grandparent.listData &&
|
|
96
141
|
grandparent.listData.tight
|
|
97
|
-
|
|
98
|
-
break
|
|
99
|
-
}
|
|
142
|
+
|
|
100
143
|
if (entering) {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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) {
|
|
104
169
|
tag('/p')
|
|
105
170
|
cr()
|
|
106
171
|
}
|
|
@@ -190,12 +255,72 @@ export function renderHTML(doc: MdNode, options?: Partial<ParserOptions>): strin
|
|
|
190
255
|
}
|
|
191
256
|
|
|
192
257
|
case 'html_block':
|
|
193
|
-
// §4.6: raw HTML passes through verbatim, unescaped
|
|
258
|
+
// §4.6: raw HTML passes through verbatim, unescaped — except for the
|
|
259
|
+
// nine tags GFM's tagfilter neutralises.
|
|
194
260
|
cr()
|
|
195
|
-
lit(
|
|
261
|
+
lit(rawHtml(node.literal))
|
|
196
262
|
cr()
|
|
197
263
|
break
|
|
198
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
|
+
|
|
199
324
|
case 'text':
|
|
200
325
|
lit(escapeXml(null === node.literal ? '' : node.literal))
|
|
201
326
|
break
|
|
@@ -216,7 +341,7 @@ export function renderHTML(doc: MdNode, options?: Partial<ParserOptions>): strin
|
|
|
216
341
|
break
|
|
217
342
|
|
|
218
343
|
case 'html_inline':
|
|
219
|
-
lit(
|
|
344
|
+
lit(rawHtml(node.literal))
|
|
220
345
|
break
|
|
221
346
|
|
|
222
347
|
case 'emph':
|