@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/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 `&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
+
32
54
  /**
33
55
  * Render `doc` (a tree that has been through both parse phases) as HTML.
34
56
  *
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.
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(options)
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
- if (
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
- cr()
102
- tag('p')
103
- } else {
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(null === node.literal ? '' : node.literal)
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(null === node.literal ? '' : node.literal)
344
+ lit(rawHtml(node.literal))
220
345
  break
221
346
 
222
347
  case 'emph':