@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/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
|
|
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 &&
|
|
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
|
-
/**
|
|
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
|
|
9
|
+
* Enable the GFM extensions. Default true.
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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. */
|