@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/inline.ts CHANGED
@@ -36,15 +36,26 @@ import {
36
36
  unescapeString,
37
37
  } from './common.ts'
38
38
 
39
+ const C_TAB = 9
39
40
  const C_NEWLINE = 10
41
+ const C_VTAB = 11
42
+ const C_FORMFEED = 12
43
+ const C_CARRIAGE_RETURN = 13
40
44
  const C_SPACE = 32
41
45
  const C_BANG = 33
42
46
  const C_AMPERSAND = 38
43
47
  const C_OPEN_PAREN = 40
44
48
  const C_CLOSE_PAREN = 41
45
49
  const C_ASTERISK = 42
50
+ const C_PLUS = 43
51
+ const C_COMMA = 44
52
+ const C_HYPHEN = 45
53
+ const C_PERIOD = 46
46
54
  const C_COLON = 58
55
+ const C_SEMICOLON = 59
47
56
  const C_LESSTHAN = 60
57
+ const C_QUESTION = 63
58
+ const C_AT = 64
48
59
  const C_OPEN_BRACKET = 91
49
60
  const C_BACKSLASH = 92
50
61
  const C_CLOSE_BRACKET = 93
@@ -1000,9 +1011,458 @@ class InlineParser {
1000
1011
  }
1001
1012
  }
1002
1013
 
1014
+ // --- GFM extended autolinks (autolink literals) -----------------------------
1015
+ //
1016
+ // GFM recognises `www.x.com`, `https://x.com` and `a@x.com` without the `<…>`
1017
+ // CommonMark requires. This runs as a post-pass over the finished inline tree
1018
+ // rather than as another branch of the scan above, which is how cmark-gfm does
1019
+ // it and is the only shape that keeps 652/652 safe: the scanner that decides
1020
+ // code spans, raw HTML, emphasis and links is not touched at all, and an
1021
+ // autolink can therefore never win against a construct CommonMark says comes
1022
+ // first.
1023
+ //
1024
+ // The pass rewrites `text` nodes into text/link/text runs. It does not descend
1025
+ // into `link` (links may not contain links) or `image` (its children are the
1026
+ // alt text), and `code`/`html_inline` are leaves it never looks inside.
1027
+
1028
+ /** RFC 1035's limit on a fully-qualified domain name. See `scanDomainEnd`. */
1029
+ const MAX_AUTOLINK_DOMAIN = 253
1030
+
1031
+ /** RFC 5321's limit on the local part of an address, before the `@`. */
1032
+ const MAX_EMAIL_LOCAL = 64
1033
+
1034
+ /** The three schemes the extension recognises without angle brackets. */
1035
+ const AUTOLINK_SCHEMES = ['https://', 'http://', 'ftp://']
1036
+
1037
+ function isAsciiAlnum(c: number): boolean {
1038
+ return (c >= 48 && c <= 57) || (c >= 65 && c <= 90) || (c >= 97 && c <= 122)
1039
+ }
1040
+
1041
+ function isAutolinkSpace(c: number): boolean {
1042
+ return (
1043
+ C_SPACE === c ||
1044
+ C_TAB === c ||
1045
+ C_NEWLINE === c ||
1046
+ C_VTAB === c ||
1047
+ C_FORMFEED === c ||
1048
+ C_CARRIAGE_RETURN === c
1049
+ )
1050
+ }
1051
+
1052
+ /** A domain segment holds alphanumerics, `_` and `-`; `.` separates them. */
1053
+ function isDomainChar(c: number): boolean {
1054
+ return isAsciiAlnum(c) || C_UNDERSCORE === c || C_HYPHEN === c || C_PERIOD === c
1055
+ }
1056
+
1057
+ /** Before the `@`: alphanumerics and `.`, `-`, `_`, `+`. */
1058
+ function isEmailLocalChar(c: number): boolean {
1059
+ return isAsciiAlnum(c) || C_PERIOD === c || C_HYPHEN === c || C_UNDERSCORE === c || C_PLUS === c
1060
+ }
1061
+
1062
+ /**
1063
+ * "All such recognized autolinks can only come at the beginning of a line,
1064
+ * after whitespace, or any of the delimiting characters `*`, `_`, `~`, `(`."
1065
+ *
1066
+ * Offset 0 has no preceding character in `s` at all, so the caller answers for
1067
+ * it through `atStart` — see `startsAfterDelimiter`.
1068
+ */
1069
+ function isAutolinkStart(s: string, i: number, atStart: boolean): boolean {
1070
+ if (0 === i) return atStart
1071
+ const c = s.charCodeAt(i - 1)
1072
+ return (
1073
+ isAutolinkSpace(c) ||
1074
+ C_ASTERISK === c ||
1075
+ C_UNDERSCORE === c ||
1076
+ C_TILDE === c ||
1077
+ C_OPEN_PAREN === c
1078
+ )
1079
+ }
1080
+
1081
+ /**
1082
+ * Whether offset 0 of a text node may begin an autolink, given the sibling in
1083
+ * front of it.
1084
+ *
1085
+ * The rule above is about the source character immediately before the match,
1086
+ * and by the time this pass runs the tree no longer carries source offsets —
1087
+ * but the previous sibling's *type* names that character exactly:
1088
+ *
1089
+ * * `emph` and `strong` end on the `*` or `_` that closed them, and `del` on
1090
+ * its second `~`. All three are delimiters the rule allows, so
1091
+ * `*a*www.b.com` links.
1092
+ * * `code` ends on a backtick, `link` and `image` on `)`, `]` or `>`, and
1093
+ * `html_inline` on `>`. None are delimiters, so `` `x`www.a.com ``,
1094
+ * `[l](/u)www.a.com` and `<b>www.a.com` do not link.
1095
+ * * a `softbreak` or `linebreak` puts offset 0 at the beginning of a line,
1096
+ * and so does having no previous sibling — that is the first inline of the
1097
+ * block, or of the emphasis span this pass descended into.
1098
+ *
1099
+ * A `text` node never appears here: `linkifyChildren` merges every run of
1100
+ * adjacent text siblings before handing the first of them over.
1101
+ */
1102
+ function startsAfterDelimiter(prev: MdNode | null): boolean {
1103
+ if (null === prev) return true
1104
+ const t = prev.type
1105
+ return 'softbreak' === t || 'linebreak' === t || 'emph' === t || 'strong' === t || 'del' === t
1106
+ }
1107
+
1108
+ /** ASCII case-insensitive comparison against an already-lowercase literal. */
1109
+ function matchesIgnoreCase(s: string, at: number, lower: string): boolean {
1110
+ if (at + lower.length > s.length) return false
1111
+ for (let k = 0; k < lower.length; k++) {
1112
+ let c = s.charCodeAt(at + k)
1113
+ if (c >= 65 && c <= 90) c += 32
1114
+ if (c !== lower.charCodeAt(k)) return false
1115
+ }
1116
+ return true
1117
+ }
1118
+
1119
+ /**
1120
+ * End of the run of domain characters at `from`, capped at
1121
+ * `MAX_AUTOLINK_DOMAIN`.
1122
+ *
1123
+ * The cap is what keeps this pass linear. `_` both continues a domain and
1124
+ * introduces a valid autolink start, so without a bound an input like
1125
+ * `'_www.a_b.c'.repeat(n)` gives every underscore a candidate whose domain run
1126
+ * reaches the end of the text — quadratic. Anything past the cap is still
1127
+ * scanned, just as the path rather than as the domain, and no real domain
1128
+ * comes near it.
1129
+ */
1130
+ function scanDomainEnd(s: string, from: number): number {
1131
+ const max = Math.min(s.length, from + MAX_AUTOLINK_DOMAIN)
1132
+ let i = from
1133
+ while (i < max && isDomainChar(s.charCodeAt(i))) i++
1134
+ return i
1135
+ }
1136
+
1137
+ /**
1138
+ * A valid domain: at least one period, and no underscore in either of the last
1139
+ * two segments. `from`..`to` must already be a run of domain characters.
1140
+ */
1141
+ function isValidDomain(s: string, from: number, to: number): boolean {
1142
+ if (to <= from) return false
1143
+ let periods = 0
1144
+ let underscoresInLast = 0
1145
+ let underscoresInPrev = 0
1146
+ for (let i = from; i < to; i++) {
1147
+ const c = s.charCodeAt(i)
1148
+ if (C_UNDERSCORE === c) {
1149
+ underscoresInLast++
1150
+ } else if (C_PERIOD === c) {
1151
+ underscoresInPrev = underscoresInLast
1152
+ underscoresInLast = 0
1153
+ periods++
1154
+ }
1155
+ }
1156
+ return 0 < periods && 0 === underscoresInLast && 0 === underscoresInPrev
1157
+ }
1158
+
1159
+ /**
1160
+ * "Extended autolink path validation" — pull the end of the match back off
1161
+ * trailing characters that read as sentence punctuation rather than as part of
1162
+ * a URL. One loop rather than three passes, because dropping a `)` can expose
1163
+ * a `.` and vice versa.
1164
+ *
1165
+ * * `?` `!` `.` `,` `:` `*` `_` `~` are dropped wherever they trail;
1166
+ * * a trailing `)` is dropped only while the match holds more `)` than `(`,
1167
+ * so `(www.a.com/x_(y))` keeps its inner pair and loses the outer one;
1168
+ * * a trailing `;` preceded by something shaped like an entity reference
1169
+ * takes the whole `&…;` with it.
1170
+ *
1171
+ * The parenthesis counts are taken once and then maintained, not recomputed
1172
+ * per drop: a deeply parenthesised URL is otherwise quadratic in its own
1173
+ * length.
1174
+ */
1175
+ function trimAutolinkEnd(s: string, start: number, end: number): number {
1176
+ let opening = -1
1177
+ let closing = -1
1178
+
1179
+ while (start < end) {
1180
+ const c = s.charCodeAt(end - 1)
1181
+
1182
+ if (
1183
+ C_QUESTION === c ||
1184
+ C_BANG === c ||
1185
+ C_PERIOD === c ||
1186
+ C_COMMA === c ||
1187
+ C_COLON === c ||
1188
+ C_ASTERISK === c ||
1189
+ C_UNDERSCORE === c ||
1190
+ C_TILDE === c
1191
+ ) {
1192
+ end--
1193
+ continue
1194
+ }
1195
+
1196
+ if (C_CLOSE_PAREN === c) {
1197
+ if (opening < 0) {
1198
+ opening = 0
1199
+ closing = 0
1200
+ for (let i = start; i < end; i++) {
1201
+ const d = s.charCodeAt(i)
1202
+ if (C_OPEN_PAREN === d) opening++
1203
+ else if (C_CLOSE_PAREN === d) closing++
1204
+ }
1205
+ }
1206
+ if (closing <= opening) return end
1207
+ closing--
1208
+ end--
1209
+ continue
1210
+ }
1211
+
1212
+ if (C_SEMICOLON === c) {
1213
+ // `&` followed by one or more alphanumerics, then this `;`.
1214
+ let j = end - 2
1215
+ while (j > start && isAsciiAlnum(s.charCodeAt(j))) j--
1216
+ if (j < end - 2 && C_AMPERSAND === s.charCodeAt(j)) {
1217
+ end = j
1218
+ continue
1219
+ }
1220
+ return end
1221
+ }
1222
+
1223
+ return end
1224
+ }
1225
+
1226
+ return end
1227
+ }
1228
+
1229
+ /** One recognised autolink literal: `s.slice(start, end)` linking to `href`. */
1230
+ type AutolinkMatch = { start: number; end: number; href: string }
1231
+
1232
+ /**
1233
+ * Every autolink literal in one text run, left to right and non-overlapping.
1234
+ * Returns null when there is nothing to do, which is the overwhelmingly common
1235
+ * case and saves the caller an array and a splice.
1236
+ */
1237
+ function scanAutolinks(s: string, atStart: boolean): AutolinkMatch[] | null {
1238
+ const len = s.length
1239
+ let out: AutolinkMatch[] | null = null
1240
+
1241
+ // The maximal run of non-space, non-`<` characters containing the current
1242
+ // position — "zero or more non-space non-`<` characters may follow" the
1243
+ // domain. Cached because candidates are tried left to right, so each run is
1244
+ // scanned once however many candidates start inside it.
1245
+ let runStart = -1
1246
+ let runEnd = -1
1247
+
1248
+ let i = 0
1249
+ let lastEnd = 0
1250
+
1251
+ while (i < len) {
1252
+ const c = s.charCodeAt(i)
1253
+
1254
+ // --- email: found by its `@`, then rewound to the start of the local part
1255
+ if (C_AT === c) {
1256
+ // The rewind is bounded to keep this pass linear, and RFC 5321 bounds a
1257
+ // local part in the same place anyway, so the cap costs no real address.
1258
+ const capped = i - MAX_EMAIL_LOCAL
1259
+ const floor = Math.max(lastEnd, capped)
1260
+ let k = i
1261
+ while (k > floor && isEmailLocalChar(s.charCodeAt(k - 1))) k--
1262
+
1263
+ // Stopping *on* the cap is not the same as finding a boundary. If the
1264
+ // character just outside it still belongs to the local part, the local
1265
+ // part is longer than the cap allows and there is no address here at
1266
+ // all: accepting the match would link the 64-character tail of a longer
1267
+ // run and leave the rest as text, inventing an address the source never
1268
+ // wrote. `k` can only reach `capped` when the cap, rather than `lastEnd`,
1269
+ // is what ended the loop; and `k` of 0 is the start of the string, where
1270
+ // there is no character outside the cap to disqualify anything.
1271
+ if (k === capped && 0 < k && isEmailLocalChar(s.charCodeAt(k - 1))) {
1272
+ i++
1273
+ continue
1274
+ }
1275
+
1276
+ if (k < i && isAutolinkStart(s, k, atStart)) {
1277
+ // After the `@`: alphanumerics, `.`, `-`, `_`; at least one period; a
1278
+ // trailing `.` is not part of the address, and a trailing `-` or `_`
1279
+ // invalidates the whole thing.
1280
+ let end = i + 1
1281
+ while (end < len && isDomainChar(s.charCodeAt(end))) end++
1282
+ while (end > i + 1 && C_PERIOD === s.charCodeAt(end - 1)) end--
1283
+
1284
+ const last = end > i + 1 ? s.charCodeAt(end - 1) : 0
1285
+ if (
1286
+ end > i + 1 &&
1287
+ C_HYPHEN !== last &&
1288
+ C_UNDERSCORE !== last &&
1289
+ hasPeriod(s, i + 1, end)
1290
+ ) {
1291
+ if (null === out) out = []
1292
+ out.push({ start: k, end, href: 'mailto:' + s.slice(k, end) })
1293
+ lastEnd = end
1294
+ i = end
1295
+ continue
1296
+ }
1297
+ }
1298
+ i++
1299
+ continue
1300
+ }
1301
+
1302
+ // --- www / scheme: both need a valid start and a valid domain
1303
+ if (!couldStartUrl(c) || !isAutolinkStart(s, i, atStart)) {
1304
+ i++
1305
+ continue
1306
+ }
1307
+
1308
+ let domainStart = -1
1309
+ let prefix = ''
1310
+ if (matchesIgnoreCase(s, i, 'www.')) {
1311
+ // The domain of a `www.` autolink includes the `www.` itself, so the
1312
+ // required period is already there and `www.foo_bar.com` is rejected on
1313
+ // the same footing as `http://foo_bar.com`.
1314
+ domainStart = i
1315
+ prefix = 'http://'
1316
+ } else {
1317
+ for (const scheme of AUTOLINK_SCHEMES) {
1318
+ if (matchesIgnoreCase(s, i, scheme)) {
1319
+ domainStart = i + scheme.length
1320
+ break
1321
+ }
1322
+ }
1323
+ }
1324
+ if (0 > domainStart) {
1325
+ i++
1326
+ continue
1327
+ }
1328
+
1329
+ const domainEnd = scanDomainEnd(s, domainStart)
1330
+ // Checked before the run scan below, so a candidate that cannot possibly
1331
+ // match costs only its own domain run.
1332
+ if (!isValidDomain(s, domainStart, domainEnd)) {
1333
+ i++
1334
+ continue
1335
+ }
1336
+
1337
+ if (i >= runEnd || i < runStart) {
1338
+ runStart = i
1339
+ runEnd = i
1340
+ while (runEnd < len) {
1341
+ const d = s.charCodeAt(runEnd)
1342
+ if (isAutolinkSpace(d) || C_LESSTHAN === d) break
1343
+ runEnd++
1344
+ }
1345
+ }
1346
+
1347
+ const end = trimAutolinkEnd(s, i, runEnd)
1348
+ // Trailing punctuation can eat back into the domain — `www.commonmark.org.`
1349
+ // loses its last period — so the domain is validated again over what
1350
+ // actually survived.
1351
+ if (end <= domainStart || !isValidDomain(s, domainStart, Math.min(domainEnd, end))) {
1352
+ i++
1353
+ continue
1354
+ }
1355
+
1356
+ if (null === out) out = []
1357
+ out.push({ start: i, end, href: prefix + s.slice(i, end) })
1358
+ lastEnd = end
1359
+ i = end
1360
+ }
1361
+
1362
+ return out
1363
+ }
1364
+
1365
+ /** `www.`, `http://`, `https://` and `ftp://` all start with one of these. */
1366
+ function couldStartUrl(c: number): boolean {
1367
+ return 119 === c || 87 === c || 104 === c || 72 === c || 102 === c || 70 === c
1368
+ }
1369
+
1370
+ function hasPeriod(s: string, from: number, to: number): boolean {
1371
+ for (let i = from; i < to; i++) {
1372
+ if (C_PERIOD === s.charCodeAt(i)) return true
1373
+ }
1374
+ return false
1375
+ }
1376
+
1377
+ /** Replace one text node with the text/link/text run its literal implies. */
1378
+ function linkifyTextNode(node: MdNode): void {
1379
+ const s = node.literal ?? ''
1380
+ const matches = scanAutolinks(s, startsAfterDelimiter(node.prev))
1381
+ if (null === matches) return
1382
+
1383
+ let at = 0
1384
+ for (const m of matches) {
1385
+ if (at < m.start) node.insertBefore(text(s.slice(at, m.start)))
1386
+ const link = new MdNode('link')
1387
+ // Not percent-encoded here, for the same reason as parseLinkDestination:
1388
+ // encoding belongs to the renderer, and the AST promises the decoded form.
1389
+ link.destination = m.href
1390
+ link.title = null
1391
+ link.appendChild(text(s.slice(m.start, m.end)))
1392
+ node.insertBefore(link)
1393
+ at = m.end
1394
+ }
1395
+ if (at < s.length) node.insertBefore(text(s.slice(at)))
1396
+
1397
+ node.unlink()
1398
+ }
1399
+
1400
+ /**
1401
+ * Autolink the text children of one container, and report the child containers
1402
+ * still to visit.
1403
+ */
1404
+ function linkifyChildren(parent: MdNode, pending: MdNode[]): void {
1405
+ let child: MdNode | null = parent.firstChild
1406
+
1407
+ while (null !== child) {
1408
+ if ('text' === child.type) {
1409
+ // Consolidate the run of adjacent text nodes first. The scan above emits
1410
+ // a node per delimiter run and per character it could not claim, so
1411
+ // `a.b-c_d@a.b` arrives as three siblings and the address is only
1412
+ // visible once they are one string. Merging changes nothing downstream:
1413
+ // the renderer writes text literals back to back, and the AST projection
1414
+ // already coalesces adjacent runs.
1415
+ let sibling: MdNode | null = child.next
1416
+ if (null !== sibling && 'text' === sibling.type) {
1417
+ let merged = child.literal ?? ''
1418
+ while (null !== sibling && 'text' === sibling.type) {
1419
+ merged += sibling.literal ?? ''
1420
+ const next: MdNode | null = sibling.next
1421
+ sibling.unlink()
1422
+ sibling = next
1423
+ }
1424
+ child.literal = merged
1425
+ }
1426
+
1427
+ const after: MdNode | null = child.next
1428
+ linkifyTextNode(child)
1429
+ child = after
1430
+ continue
1431
+ }
1432
+
1433
+ // No links inside links (§6.3), and an image's children are its alt text.
1434
+ if ('link' !== child.type && 'image' !== child.type && child.isContainer()) {
1435
+ pending.push(child)
1436
+ }
1437
+ child = child.next
1438
+ }
1439
+ }
1440
+
1441
+ /**
1442
+ * Recognise GFM autolink literals throughout one finished block's inlines.
1443
+ *
1444
+ * Iterative rather than recursive: emphasis nests as deeply as the input says.
1445
+ */
1446
+ export function linkifyAutolinks(block: MdNode): void {
1447
+ const pending: MdNode[] = [block]
1448
+ while (0 < pending.length) {
1449
+ linkifyChildren(pending.pop() as MdNode, pending)
1450
+ }
1451
+ }
1452
+
1453
+ /** Blocks whose raw content the block phase left for this phase to parse. */
1454
+ const INLINE_CONTENT_BLOCKS: Record<string, true> = {
1455
+ paragraph: true,
1456
+ heading: true,
1457
+ // GFM table cells hold inlines and nothing else — "block-level elements
1458
+ // cannot be inserted in a table" — so a cell is parsed exactly as a
1459
+ // paragraph is, autolink post-pass included.
1460
+ table_cell: true,
1461
+ }
1462
+
1003
1463
  /**
1004
1464
  * Phase 2 entry point: walk the block tree and parse the string content of
1005
- * every paragraph and heading into inline children.
1465
+ * every paragraph, heading and table cell into inline children.
1006
1466
  */
1007
1467
  export function parseInlines(doc: MdNode, refmap: RefMap, options: ParserOptions): void {
1008
1468
  const parser = new InlineParser(refmap, options)
@@ -1011,8 +1471,9 @@ export function parseInlines(doc: MdNode, refmap: RefMap, options: ParserOptions
1011
1471
  let ev = walker.next()
1012
1472
  while (null !== ev) {
1013
1473
  const node = ev.node
1014
- if (!ev.entering && ('paragraph' === node.type || 'heading' === node.type)) {
1474
+ if (!ev.entering && true === INLINE_CONTENT_BLOCKS[node.type]) {
1015
1475
  parser.parse(node)
1476
+ if (options.gfm) linkifyAutolinks(node)
1016
1477
  }
1017
1478
  ev = walker.next()
1018
1479
  }
package/src/markdown.ts CHANGED
@@ -30,7 +30,10 @@ import { MdNode } from './node.ts'
30
30
  // Public options
31
31
 
32
32
  export type MarkdownOptions = {
33
- /** Enable GFM extensions (strikethrough). Default true. */
33
+ /**
34
+ * Enable the GFM extensions — tables, strikethrough, task list items,
35
+ * autolink literals and the disallowed-raw-HTML filter. Default true.
36
+ */
34
37
  gfm?: boolean
35
38
  /** When true, a single soft break becomes a hard break. Default false. */
36
39
  breaks?: boolean
@@ -174,6 +177,9 @@ export type {
174
177
  CodeNode,
175
178
  HtmlNode,
176
179
  ThematicBreakNode,
180
+ TableNode,
181
+ TableRowNode,
182
+ TableCellNode,
177
183
  Inline,
178
184
  TextNode,
179
185
  EmphasisNode,
package/src/node.ts CHANGED
@@ -27,6 +27,13 @@ export type NodeType =
27
27
  | 'thematic_break'
28
28
  | 'code_block'
29
29
  | 'html_block'
30
+ // GFM tables (extension): a leaf block as far as the spec's algorithm is
31
+ // concerned — no block-level element can be inserted in one — but a
32
+ // container as far as the tree is concerned, since its rows and cells are
33
+ // real nodes and its cells hold inlines.
34
+ | 'table'
35
+ | 'table_row'
36
+ | 'table_cell'
30
37
  // inlines
31
38
  | 'text'
32
39
  | 'softbreak'
@@ -46,6 +53,9 @@ const CONTAINER_TYPES: Record<string, true> = {
46
53
  item: true,
47
54
  paragraph: true,
48
55
  heading: true,
56
+ table: true,
57
+ table_row: true,
58
+ table_cell: true,
49
59
  emph: true,
50
60
  strong: true,
51
61
  link: true,
@@ -53,6 +63,12 @@ const CONTAINER_TYPES: Record<string, true> = {
53
63
  del: true,
54
64
  }
55
65
 
66
+ /**
67
+ * Per-column alignment of a GFM table, from the colons in its delimiter row:
68
+ * `:--` left, `--:` right, `:-:` center, `---` none.
69
+ */
70
+ export type TableAlign = 'left' | 'right' | 'center' | null
71
+
56
72
  export type ListType = 'bullet' | 'ordered'
57
73
 
58
74
  export type ListData = {
@@ -106,6 +122,43 @@ export class MdNode {
106
122
 
107
123
  listData: ListData | null = null
108
124
 
125
+ /**
126
+ * GFM tables: one entry per column, in order, set on the **table** node by
127
+ * the block phase from the delimiter row. Every row of the table — header
128
+ * and body alike — has exactly this many cells, because short rows are
129
+ * padded and long ones are truncated. Null on every other node type.
130
+ */
131
+ tableAlign: TableAlign[] | null = null
132
+
133
+ /**
134
+ * GFM tables: true on the one `table_row` that is the header row, which is
135
+ * always the table's first child. mdast has no header flag and relies on
136
+ * that convention, but the renderer needs to choose `<th>` over `<td>`
137
+ * without walking back up to the table.
138
+ */
139
+ isHeaderRow = false
140
+
141
+ /**
142
+ * GFM task list items (`- [x] foo`): `true`/`false` for a checked/unchecked
143
+ * item, `null` for an ordinary one. Set on `item` nodes by the block phase
144
+ * when `gfm` is on, once the marker has been consumed from the item's first
145
+ * paragraph. mdast's field and mdast's semantics — `ast.ts` projects it
146
+ * straight through to `listItem.checked`.
147
+ */
148
+ checked: boolean | null = null
149
+
150
+ /**
151
+ * The `gfm` parse option, recorded on the **document** node by the block
152
+ * phase and meaningless anywhere else.
153
+ *
154
+ * The renderer needs it because GFM's disallowed-raw-HTML filter is a
155
+ * render-time concern, and `renderHTML(tree)` may be called with no options
156
+ * at all. Without this the default (`gfm: true`) would apply the filter to a
157
+ * tree that was parsed as plain CommonMark, which is exactly the case the
158
+ * conformance runner exercises.
159
+ */
160
+ gfm = true
161
+
109
162
  // --- block-phase bookkeeping (not part of the rendered tree) ---
110
163
 
111
164
  /** Open blocks accept further lines; closed ones are finalized. */
package/src/options.ts CHANGED
@@ -6,11 +6,16 @@
6
6
 
7
7
  export type ParserOptions = {
8
8
  /**
9
- * Enable GFM strikethrough (`~~x~~`). Default true.
9
+ * Enable the GFM extensions. Default true.
10
10
  *
11
- * Strikethrough is the only GFM extension implemented; tables, task list
12
- * items, autolink literals, footnotes and raw-HTML filtering are not, and
13
- * this flag does not gate them.
11
+ * Five are implemented, and this one flag gates all of them together:
12
+ * tables, strikethrough (`~~x~~`), task list items (`- [x] foo`), autolink
13
+ * literals (bare `www.` / `http://` / `https://` / `ftp://` / `a@b.co`) and
14
+ * the disallowed-raw-HTML filter. Footnotes are not implemented.
15
+ *
16
+ * The first four are parse-time; the raw-HTML filter is applied by the
17
+ * renderer, which is why `html.ts` reads this option too. With `gfm:false`
18
+ * the output is plain CommonMark, byte for byte.
14
19
  */
15
20
  gfm: boolean
16
21
  /** Render soft line breaks as hard breaks. */