@jarenjs/md 0.34.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 +520 -0
- package/dist/types/ast.d.ts +181 -0
- package/dist/types/bake.d.ts +61 -0
- package/dist/types/compiler.d.ts +141 -0
- package/dist/types/component/index.d.ts +101 -0
- package/dist/types/directives.d.ts +126 -0
- package/dist/types/entities.d.ts +40 -0
- package/dist/types/footnotes.d.ts +83 -0
- package/dist/types/frontmatter.d.ts +67 -0
- package/dist/types/html.d.ts +72 -0
- package/dist/types/index.d.ts +30 -0
- package/dist/types/loader.d.ts +84 -0
- package/dist/types/mdx.d.ts +45 -0
- package/dist/types/parser.d.ts +116 -0
- package/dist/types/plugins/highlight.d.ts +64 -0
- package/dist/types/plugins/index.d.ts +64 -0
- package/dist/types/plugins/mermaid.d.ts +12 -0
- package/dist/types/scanner.d.ts +240 -0
- package/dist/types/to-html.d.ts +104 -0
- package/dist/types/to-md.d.ts +23 -0
- package/dist/types/to-vnode.d.ts +161 -0
- package/dist/types/utils.d.ts +63 -0
- package/docs/LOADER.md +92 -0
- package/docs/MD-FORMAT.md +502 -0
- package/docs/PLUGINS.md +277 -0
- package/package.json +80 -0
- package/schemas/jaren-md-ast.schema.json +296 -0
- package/src/ast.js +346 -0
- package/src/bake.js +104 -0
- package/src/compiler.js +167 -0
- package/src/component/index.js +191 -0
- package/src/directives.js +371 -0
- package/src/entities.js +107 -0
- package/src/footnotes.js +180 -0
- package/src/frontmatter.js +947 -0
- package/src/html.js +281 -0
- package/src/index.js +76 -0
- package/src/loader.js +0 -0
- package/src/mdx.js +219 -0
- package/src/parser.js +1685 -0
- package/src/plugins/highlight.js +325 -0
- package/src/plugins/index.js +75 -0
- package/src/plugins/mermaid.js +14 -0
- package/src/scanner.js +832 -0
- package/src/to-html.js +425 -0
- package/src/to-md.js +396 -0
- package/src/to-vnode.js +766 -0
- package/src/utils.js +107 -0
- package/styles/md.css +238 -0
package/src/scanner.js
ADDED
|
@@ -0,0 +1,832 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file Line-level scanners for the block parser.
|
|
4
|
+
*
|
|
5
|
+
* Pure, allocation-conscious functions that classify one (detabbed)
|
|
6
|
+
* line at a char-code level: does a construct start here, and where
|
|
7
|
+
* does its content begin? The parser in parser.js owns all state; this
|
|
8
|
+
* module owns none. Every regular expression is compiled once at
|
|
9
|
+
* module load — nothing in here builds a pattern per call.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { countIndent } from './utils.js';
|
|
13
|
+
import { decodeReferences } from './entities.js';
|
|
14
|
+
|
|
15
|
+
const CC_SPACE = 0x20;
|
|
16
|
+
const CC_HASH = 0x23;
|
|
17
|
+
const CC_STAR = 0x2A;
|
|
18
|
+
const CC_PLUS = 0x2B;
|
|
19
|
+
const CC_MINUS = 0x2D;
|
|
20
|
+
const CC_DOT = 0x2E;
|
|
21
|
+
const CC_RPAREN = 0x29;
|
|
22
|
+
const CC_LT = 0x3C;
|
|
23
|
+
const CC_GT = 0x3E;
|
|
24
|
+
const CC_EQ = 0x3D;
|
|
25
|
+
const CC_BACKTICK = 0x60;
|
|
26
|
+
const CC_TILDE = 0x7E;
|
|
27
|
+
const CC_UNDERSCORE = 0x5F;
|
|
28
|
+
const CC_PIPE = 0x7C;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Thematic break: three or more `*`, `-` or `_` (same character),
|
|
32
|
+
* interleaved with spaces, nothing else on the line.
|
|
33
|
+
* @param {string} line
|
|
34
|
+
* @param {number} start first non-space offset
|
|
35
|
+
* @returns {boolean}
|
|
36
|
+
*/
|
|
37
|
+
export function scanThematicBreak(line, start) {
|
|
38
|
+
const marker = line.charCodeAt(start);
|
|
39
|
+
if (marker !== CC_STAR && marker !== CC_MINUS && marker !== CC_UNDERSCORE) {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
let count = 0;
|
|
43
|
+
for (let i = start; i < line.length; i++) {
|
|
44
|
+
const c = line.charCodeAt(i);
|
|
45
|
+
if (c === marker) count++;
|
|
46
|
+
else if (c !== CC_SPACE && c !== 0x09) return false;
|
|
47
|
+
}
|
|
48
|
+
return count >= 3;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* ATX heading: `#{1,6}` followed by space or end of line. Returns the
|
|
53
|
+
* depth and the heading text (closing `#` run stripped), or null.
|
|
54
|
+
* @param {string} line
|
|
55
|
+
* @param {number} start first non-space offset
|
|
56
|
+
* @returns {{ depth: number, text: string } | null}
|
|
57
|
+
*/
|
|
58
|
+
export function scanAtxHeading(line, start) {
|
|
59
|
+
let depth = 0;
|
|
60
|
+
let i = start;
|
|
61
|
+
while (i < line.length && line.charCodeAt(i) === CC_HASH && depth < 7) {
|
|
62
|
+
depth++;
|
|
63
|
+
i++;
|
|
64
|
+
}
|
|
65
|
+
if (depth === 0 || depth > 6) return null;
|
|
66
|
+
if (i < line.length && line.charCodeAt(i) !== CC_SPACE && line.charCodeAt(i) !== 0x09) {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
let end = line.length;
|
|
70
|
+
while (end > i && isSpaceCode(line.charCodeAt(end - 1))) end--;
|
|
71
|
+
// A trailing `#` run preceded by a space (or the opener) is a closer.
|
|
72
|
+
let closer = end;
|
|
73
|
+
while (closer > i && line.charCodeAt(closer - 1) === CC_HASH) closer--;
|
|
74
|
+
if (closer < end && (closer === i || isSpaceCode(line.charCodeAt(closer - 1)))) {
|
|
75
|
+
end = closer;
|
|
76
|
+
while (end > i && isSpaceCode(line.charCodeAt(end - 1))) end--;
|
|
77
|
+
}
|
|
78
|
+
while (i < end && isSpaceCode(line.charCodeAt(i))) i++;
|
|
79
|
+
return { depth, text: line.slice(i, end) };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Code fence opener: three or more backticks or tildes. A backtick
|
|
84
|
+
* fence's info string may not contain a backtick.
|
|
85
|
+
* @param {string} line
|
|
86
|
+
* @param {number} start first non-space offset
|
|
87
|
+
* @returns {{ marker: number, length: number, info: string } | null}
|
|
88
|
+
*/
|
|
89
|
+
export function scanFenceOpen(line, start) {
|
|
90
|
+
const marker = line.charCodeAt(start);
|
|
91
|
+
if (marker !== CC_BACKTICK && marker !== CC_TILDE) return null;
|
|
92
|
+
let i = start;
|
|
93
|
+
while (i < line.length && line.charCodeAt(i) === marker) i++;
|
|
94
|
+
const length = i - start;
|
|
95
|
+
if (length < 3) return null;
|
|
96
|
+
const info = line.slice(i).trim();
|
|
97
|
+
if (marker === CC_BACKTICK && info.indexOf('`') !== -1) return null;
|
|
98
|
+
return { marker, length, info };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Does this line close a fence opened with `marker` × `length`?
|
|
103
|
+
* @param {string} line
|
|
104
|
+
* @param {number} marker
|
|
105
|
+
* @param {number} length
|
|
106
|
+
* @returns {boolean}
|
|
107
|
+
*/
|
|
108
|
+
export function scanFenceClose(line, marker, length) {
|
|
109
|
+
const start = countIndent(line);
|
|
110
|
+
if (start - 0 >= 4) return false;
|
|
111
|
+
let i = start;
|
|
112
|
+
while (i < line.length && line.charCodeAt(i) === marker) i++;
|
|
113
|
+
if (i - start < length) return false;
|
|
114
|
+
while (i < line.length) {
|
|
115
|
+
if (!isSpaceCode(line.charCodeAt(i))) return false;
|
|
116
|
+
i++;
|
|
117
|
+
}
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Split a fence info string into `lang` (first word) and `meta` (the
|
|
123
|
+
* rest), resolving backslash escapes and character references in both.
|
|
124
|
+
* @param {string} info
|
|
125
|
+
* @returns {{ lang: string|null, meta: string|null }}
|
|
126
|
+
*/
|
|
127
|
+
export function splitFenceInfo(info) {
|
|
128
|
+
if (info === '') return { lang: null, meta: null };
|
|
129
|
+
let i = 0;
|
|
130
|
+
while (i < info.length && !isSpaceCode(info.charCodeAt(i))) i++;
|
|
131
|
+
// an info string carries escapes and references like a destination does
|
|
132
|
+
const lang = decodeReferences(info.slice(0, i));
|
|
133
|
+
const meta = decodeReferences(info.slice(i).trim());
|
|
134
|
+
return { lang, meta: meta === '' ? null : meta };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Blockquote marker at `offset`: `>` with an optional following space.
|
|
139
|
+
* Returns the content offset, or -1.
|
|
140
|
+
* @param {string} line
|
|
141
|
+
* @param {number} offset first non-space offset
|
|
142
|
+
* @returns {number}
|
|
143
|
+
*/
|
|
144
|
+
export function scanBlockquote(line, offset) {
|
|
145
|
+
if (line.charCodeAt(offset) !== CC_GT) return -1;
|
|
146
|
+
const next = line.charCodeAt(offset + 1);
|
|
147
|
+
return next === CC_SPACE || next === 0x09 ? offset + 2 : offset + 1;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* List marker: `-`/`+`/`*` bullet or `1.`/`1)` ordered (start ≤ 9
|
|
152
|
+
* digits), followed by a space or line end. Returns the marker
|
|
153
|
+
* geometry the parser turns into a list container, or null.
|
|
154
|
+
* @param {string} line
|
|
155
|
+
* @param {number} start first non-space offset
|
|
156
|
+
* @returns {{ ordered: boolean, bullet: string, start: number,
|
|
157
|
+
* delimiter: string, contentOffset: number } | null}
|
|
158
|
+
*/
|
|
159
|
+
export function scanListMarker(line, start) {
|
|
160
|
+
const c = line.charCodeAt(start);
|
|
161
|
+
let markerEnd;
|
|
162
|
+
let ordered = false;
|
|
163
|
+
let ordinal = 1;
|
|
164
|
+
let bullet = '';
|
|
165
|
+
let delimiter = '';
|
|
166
|
+
if (c === CC_MINUS || c === CC_PLUS || c === CC_STAR) {
|
|
167
|
+
bullet = line[start];
|
|
168
|
+
markerEnd = start + 1;
|
|
169
|
+
}
|
|
170
|
+
else if (c >= 0x30 && c <= 0x39) {
|
|
171
|
+
let i = start;
|
|
172
|
+
while (i < line.length && line.charCodeAt(i) >= 0x30 && line.charCodeAt(i) <= 0x39) i++;
|
|
173
|
+
if (i - start > 9) return null;
|
|
174
|
+
const d = line.charCodeAt(i);
|
|
175
|
+
if (d !== CC_DOT && d !== CC_RPAREN) return null;
|
|
176
|
+
ordered = true;
|
|
177
|
+
ordinal = Number(line.slice(start, i));
|
|
178
|
+
delimiter = line[i];
|
|
179
|
+
markerEnd = i + 1;
|
|
180
|
+
}
|
|
181
|
+
else {
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
const after = line.charCodeAt(markerEnd);
|
|
185
|
+
if (!Number.isNaN(after) && after !== CC_SPACE && after !== 0x09) return null;
|
|
186
|
+
// Content begins after the marker and 1–4 following spaces; more
|
|
187
|
+
// than 4 (or a blank rest) means content at marker + 1 (indented
|
|
188
|
+
// code / empty item semantics).
|
|
189
|
+
let content = markerEnd;
|
|
190
|
+
while (content < line.length && line.charCodeAt(content) === CC_SPACE) content++;
|
|
191
|
+
const gap = content - markerEnd;
|
|
192
|
+
if (content >= line.length || gap > 4) content = markerEnd + 1;
|
|
193
|
+
return { ordered, bullet, start: ordinal, delimiter, contentOffset: content };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Setext underline under an open paragraph: `=` run (depth 1) or `-`
|
|
198
|
+
* run (depth 2), possibly space-padded. Returns 0 when neither.
|
|
199
|
+
* @param {string} line
|
|
200
|
+
* @param {number} start first non-space offset
|
|
201
|
+
* @returns {number}
|
|
202
|
+
*/
|
|
203
|
+
export function scanSetextUnderline(line, start) {
|
|
204
|
+
const marker = line.charCodeAt(start);
|
|
205
|
+
if (marker !== CC_EQ && marker !== CC_MINUS) return 0;
|
|
206
|
+
let i = start;
|
|
207
|
+
while (i < line.length && line.charCodeAt(i) === marker) i++;
|
|
208
|
+
while (i < line.length) {
|
|
209
|
+
if (!isSpaceCode(line.charCodeAt(i))) return 0;
|
|
210
|
+
i++;
|
|
211
|
+
}
|
|
212
|
+
return marker === CC_EQ ? 1 : 2;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* GFM table delimiter row: cells of `---`, `:--`, `--:`, `:-:` split
|
|
217
|
+
* by pipes. Returns the alignment array, or null.
|
|
218
|
+
* @param {string} line
|
|
219
|
+
* @returns {(string|null)[] | null}
|
|
220
|
+
*/
|
|
221
|
+
export function scanTableDelimiter(line) {
|
|
222
|
+
const cells = splitTableRow(line);
|
|
223
|
+
if (cells === null || cells.length === 0) return null;
|
|
224
|
+
/** @type {(string|null)[]} */
|
|
225
|
+
const align = [];
|
|
226
|
+
for (let i = 0; i < cells.length; i++) {
|
|
227
|
+
const cell = cells[i].trim();
|
|
228
|
+
if (cell.length === 0) return null;
|
|
229
|
+
const left = cell.charCodeAt(0) === 0x3A;
|
|
230
|
+
const right = cell.charCodeAt(cell.length - 1) === 0x3A;
|
|
231
|
+
const dashes = cell.slice(left ? 1 : 0, right ? cell.length - 1 : cell.length);
|
|
232
|
+
if (dashes.length === 0) return null;
|
|
233
|
+
for (let d = 0; d < dashes.length; d++) {
|
|
234
|
+
if (dashes.charCodeAt(d) !== CC_MINUS) return null;
|
|
235
|
+
}
|
|
236
|
+
align.push(left && right ? 'center' : right ? 'right' : left ? 'left' : null);
|
|
237
|
+
}
|
|
238
|
+
return align;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Split a table row into raw cell strings on unescaped `|`, honoring
|
|
243
|
+
* `\|` and pipes inside backtick code spans. Leading and trailing
|
|
244
|
+
* empty cells from outer pipes are dropped. Returns null when the line
|
|
245
|
+
* contains no pipe at all.
|
|
246
|
+
* @param {string} line
|
|
247
|
+
* @returns {string[] | null}
|
|
248
|
+
*/
|
|
249
|
+
export function splitTableRow(line) {
|
|
250
|
+
let text = line.trim();
|
|
251
|
+
if (text.indexOf('|') === -1) return null;
|
|
252
|
+
/** @type {string[]} */
|
|
253
|
+
const cells = [];
|
|
254
|
+
let cell = '';
|
|
255
|
+
let start = 0;
|
|
256
|
+
let i = 0;
|
|
257
|
+
while (i < text.length) {
|
|
258
|
+
const c = text.charCodeAt(i);
|
|
259
|
+
if (c === 0x5C /* \ */ && text.charCodeAt(i + 1) === CC_PIPE) {
|
|
260
|
+
cell += text.slice(start, i) + '|';
|
|
261
|
+
i += 2;
|
|
262
|
+
start = i;
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
if (c === CC_BACKTICK) {
|
|
266
|
+
// Skip the code span verbatim so pipes inside it do not split.
|
|
267
|
+
let run = i;
|
|
268
|
+
while (run < text.length && text.charCodeAt(run) === CC_BACKTICK) run++;
|
|
269
|
+
const fence = text.slice(i, run);
|
|
270
|
+
const close = text.indexOf(fence, run);
|
|
271
|
+
if (close !== -1) {
|
|
272
|
+
let closeEnd = close;
|
|
273
|
+
while (closeEnd < text.length && text.charCodeAt(closeEnd) === CC_BACKTICK) closeEnd++;
|
|
274
|
+
if (closeEnd - close === fence.length) {
|
|
275
|
+
i = closeEnd;
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
i = run;
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
if (c === CC_PIPE) {
|
|
283
|
+
cells.push(cell + text.slice(start, i));
|
|
284
|
+
cell = '';
|
|
285
|
+
i++;
|
|
286
|
+
start = i;
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
i++;
|
|
290
|
+
}
|
|
291
|
+
cells.push(cell + text.slice(start));
|
|
292
|
+
if (cells.length > 0 && cells[0].trim() === '' && text.charCodeAt(0) === CC_PIPE) {
|
|
293
|
+
cells.shift();
|
|
294
|
+
}
|
|
295
|
+
if (cells.length > 0 && cells[cells.length - 1].trim() === ''
|
|
296
|
+
&& text.charCodeAt(text.length - 1) === CC_PIPE) {
|
|
297
|
+
cells.pop();
|
|
298
|
+
}
|
|
299
|
+
return cells;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** HTML block openers, CommonMark types 1–7 (compiled once). */
|
|
303
|
+
const RE_HTML_TYPE1 = /^<(?:script|pre|style|textarea)(?:\s|>|$)/i;
|
|
304
|
+
const RE_HTML_TYPE6 = /^<\/?(?:address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h1|h2|h3|h4|h5|h6|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|nav|noframes|ol|optgroup|option|p|param|section|source|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul)(?:\s|\/?>|$)/i;
|
|
305
|
+
const RE_HTML_TYPE7 = /^<(?:[a-zA-Z][a-zA-Z0-9-]*(?:\s+[a-zA-Z_:][a-zA-Z0-9_.:-]*(?:\s*=\s*(?:[^\s"'=<>`]+|'[^']*'|"[^"]*"))?)*\s*\/?>|\/[a-zA-Z][a-zA-Z0-9-]*\s*>)\s*$/;
|
|
306
|
+
|
|
307
|
+
/** Closers for html block types 1–5 (type ↦ substring that ends it). */
|
|
308
|
+
const RE_HTML_END = [
|
|
309
|
+
/$^/, // unused index 0
|
|
310
|
+
/<\/(?:script|pre|style|textarea)>/i,
|
|
311
|
+
/-->/,
|
|
312
|
+
/\?>/,
|
|
313
|
+
/>/,
|
|
314
|
+
/\]\]>/,
|
|
315
|
+
];
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Classify an HTML block opener at `start` (CommonMark types 1–7);
|
|
319
|
+
* 0 means no HTML block starts here. Type 7 is only valid when no
|
|
320
|
+
* paragraph is open — the caller passes `paragraphOpen`.
|
|
321
|
+
* @param {string} line
|
|
322
|
+
* @param {number} start first non-space offset
|
|
323
|
+
* @param {boolean} paragraphOpen
|
|
324
|
+
* @returns {number}
|
|
325
|
+
*/
|
|
326
|
+
export function scanHtmlBlockStart(line, start, paragraphOpen) {
|
|
327
|
+
if (line.charCodeAt(start) !== CC_LT) return 0;
|
|
328
|
+
const rest = start === 0 ? line : line.slice(start);
|
|
329
|
+
if (RE_HTML_TYPE1.test(rest)) return 1;
|
|
330
|
+
if (rest.startsWith('<!--')) return 2;
|
|
331
|
+
if (rest.startsWith('<?')) return 3;
|
|
332
|
+
if (/^<![a-zA-Z]/.test(rest)) return 4;
|
|
333
|
+
if (rest.startsWith('<![CDATA[')) return 5;
|
|
334
|
+
if (RE_HTML_TYPE6.test(rest)) return 6;
|
|
335
|
+
if (!paragraphOpen && RE_HTML_TYPE7.test(rest)) return 7;
|
|
336
|
+
return 0;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Does this line end an HTML block of `kind`? Types 6/7 end on the
|
|
341
|
+
* following blank line (the parser checks that); types 1–5 end on a
|
|
342
|
+
* content condition, which may sit on the opening line itself.
|
|
343
|
+
* @param {number} kind
|
|
344
|
+
* @param {string} line
|
|
345
|
+
* @returns {boolean}
|
|
346
|
+
*/
|
|
347
|
+
export function scanHtmlBlockEnd(kind, line) {
|
|
348
|
+
if (kind >= 6) return false;
|
|
349
|
+
return RE_HTML_END[kind].test(line);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Is this char code a space or tab?
|
|
354
|
+
* @param {number} c
|
|
355
|
+
* @returns {boolean}
|
|
356
|
+
*/
|
|
357
|
+
export function isSpaceCode(c) {
|
|
358
|
+
return c === CC_SPACE || c === 0x09;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Link reference definition at the start of a closed paragraph's text:
|
|
363
|
+
* `[label]: destination "title"` (title optional, may be single-,
|
|
364
|
+
* double- or paren-quoted; destination may be `<>`-wrapped). Returns
|
|
365
|
+
* the definition and the offset after it, or null.
|
|
366
|
+
* @param {string} text the paragraph's raw text
|
|
367
|
+
* @param {number} pos
|
|
368
|
+
* @returns {{ label: string, url: string, title: string|null, end: number } | null}
|
|
369
|
+
*/
|
|
370
|
+
export function scanLinkDefinition(text, pos) {
|
|
371
|
+
if (text.charCodeAt(pos) !== 0x5B /* [ */) return null;
|
|
372
|
+
let i = pos + 1;
|
|
373
|
+
const labelStart = i;
|
|
374
|
+
while (i < text.length) {
|
|
375
|
+
const c = text.charCodeAt(i);
|
|
376
|
+
// A backslash escape delimits but does not RESOLVE here: labels
|
|
377
|
+
// match on the text as written, so `[foo\!]` and `[foo!]` are two
|
|
378
|
+
// different definitions (and the reference side reads it the same
|
|
379
|
+
// way).
|
|
380
|
+
if (c === 0x5C) { i += 2; continue; }
|
|
381
|
+
if (c === 0x5D /* ] */) break;
|
|
382
|
+
if (c === 0x5B) return null;
|
|
383
|
+
i++;
|
|
384
|
+
}
|
|
385
|
+
const label = text.slice(labelStart, Math.min(i, text.length));
|
|
386
|
+
if (i >= text.length || label.trim() === '' || label.length > 999) return null;
|
|
387
|
+
if (text.charCodeAt(i + 1) !== 0x3A /* : */) return null;
|
|
388
|
+
i += 2;
|
|
389
|
+
while (i < text.length && (isSpaceCode(text.charCodeAt(i)) || text.charCodeAt(i) === 0x0A)) i++;
|
|
390
|
+
const wrapped = text.charCodeAt(i) === 0x3C /* < */;
|
|
391
|
+
const dest = scanLinkDestination(text, i);
|
|
392
|
+
// `<>` names an empty destination on purpose; nothing at all does not.
|
|
393
|
+
if (dest === null || (dest.url === '' && !wrapped)) return null;
|
|
394
|
+
i = dest.end;
|
|
395
|
+
let j = i;
|
|
396
|
+
while (j < text.length && isSpaceCode(text.charCodeAt(j))) j++;
|
|
397
|
+
const sawNewline = text.charCodeAt(j) === 0x0A;
|
|
398
|
+
if (sawNewline) j++;
|
|
399
|
+
while (j < text.length && isSpaceCode(text.charCodeAt(j))) j++;
|
|
400
|
+
// The title must be separated from the destination by whitespace, so
|
|
401
|
+
// `[foo]: <bar>(baz)` is not a definition at all — it is a paragraph.
|
|
402
|
+
const title = j > i ? scanLinkTitle(text, j) : null;
|
|
403
|
+
if (title !== null) {
|
|
404
|
+
let k = title.end;
|
|
405
|
+
while (k < text.length && isSpaceCode(text.charCodeAt(k))) k++;
|
|
406
|
+
if (k >= text.length || text.charCodeAt(k) === 0x0A) {
|
|
407
|
+
return { label: normalizeLabel(label), url: dest.url, title: title.title, end: k + 1 };
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
// No (valid) title: the definition ends at its own line end.
|
|
411
|
+
while (i < text.length && isSpaceCode(text.charCodeAt(i))) i++;
|
|
412
|
+
if (i < text.length && text.charCodeAt(i) !== 0x0A) return null;
|
|
413
|
+
return { label: normalizeLabel(label), url: dest.url, title: null, end: i + 1 };
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Scan a link destination at `pos`: `<...>` wrapped or a run of
|
|
418
|
+
* non-space characters with balanced parens.
|
|
419
|
+
* @param {string} text
|
|
420
|
+
* @param {number} pos
|
|
421
|
+
* @returns {{ url: string, end: number } | null}
|
|
422
|
+
*/
|
|
423
|
+
export function scanLinkDestination(text, pos) {
|
|
424
|
+
// The raw range is decoded in ONE pass at the end (escapes and
|
|
425
|
+
// character references together): unescaping while scanning would let
|
|
426
|
+
// a backslash-escaped `&` start an entity in the next pass.
|
|
427
|
+
if (text.charCodeAt(pos) === CC_LT) {
|
|
428
|
+
let i = pos + 1;
|
|
429
|
+
while (i < text.length) {
|
|
430
|
+
const c = text.charCodeAt(i);
|
|
431
|
+
if (c === CC_GT) return { url: decodeReferences(text.slice(pos + 1, i)), end: i + 1 };
|
|
432
|
+
if (c === CC_LT || c === 0x0A) return null;
|
|
433
|
+
i += c === 0x5C && i + 1 < text.length ? 2 : 1;
|
|
434
|
+
}
|
|
435
|
+
return null;
|
|
436
|
+
}
|
|
437
|
+
let i = pos;
|
|
438
|
+
let depth = 0;
|
|
439
|
+
while (i < text.length) {
|
|
440
|
+
const c = text.charCodeAt(i);
|
|
441
|
+
if (c <= 0x20) break;
|
|
442
|
+
if (c === 0x5C && i + 1 < text.length) {
|
|
443
|
+
i += 2;
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
446
|
+
if (c === 0x28 /* ( */) depth++;
|
|
447
|
+
if (c === CC_RPAREN) {
|
|
448
|
+
if (depth === 0) break;
|
|
449
|
+
depth--;
|
|
450
|
+
}
|
|
451
|
+
i++;
|
|
452
|
+
}
|
|
453
|
+
if (depth !== 0) return null;
|
|
454
|
+
return i === pos ? null : { url: decodeReferences(text.slice(pos, i)), end: i };
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Scan a link title at `pos`: `"..."`, `'...'` or `(...)`.
|
|
459
|
+
* @param {string} text
|
|
460
|
+
* @param {number} pos
|
|
461
|
+
* @returns {{ title: string, end: number } | null}
|
|
462
|
+
*/
|
|
463
|
+
export function scanLinkTitle(text, pos) {
|
|
464
|
+
const open = text.charCodeAt(pos);
|
|
465
|
+
if (open !== 0x22 && open !== 0x27 && open !== 0x28) return null;
|
|
466
|
+
const close = open === 0x28 ? CC_RPAREN : open;
|
|
467
|
+
let i = pos + 1;
|
|
468
|
+
while (i < text.length) {
|
|
469
|
+
const c = text.charCodeAt(i);
|
|
470
|
+
if (c === close) return { title: decodeReferences(text.slice(pos + 1, i)), end: i + 1 };
|
|
471
|
+
if (open === 0x28 && c === 0x28) return null;
|
|
472
|
+
i += c === 0x5C && i + 1 < text.length ? 2 : 1;
|
|
473
|
+
}
|
|
474
|
+
return null;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// ------------------------------------------------------------------
|
|
478
|
+
// Character classes (the flanking rules and the autolink grammar)
|
|
479
|
+
// ------------------------------------------------------------------
|
|
480
|
+
|
|
481
|
+
/** ASCII punctuation membership (emphasis flanking, backslash escapes). */
|
|
482
|
+
export const ASCII_PUNCT = new Uint8Array(128);
|
|
483
|
+
for (const ch of '!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~') ASCII_PUNCT[ch.charCodeAt(0)] = 1;
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* The flanking rules — and the autolink grammar's notion of a host
|
|
487
|
+
* character — are defined over UNICODE classes, not ASCII: a
|
|
488
|
+
* "whitespace character" is Zs plus tab/LF/FF/CR (so a no-break space
|
|
489
|
+
* ends a delimiter run), and a "punctuation character" is anything in
|
|
490
|
+
* P* **or** S* (so `£` and `€` are punctuation, while a letter is not).
|
|
491
|
+
* Both are consulted only for code points outside ASCII, which the table
|
|
492
|
+
* above answers without allocating.
|
|
493
|
+
*/
|
|
494
|
+
const RE_UNICODE_WS = /[\p{Zs}\t\n\f\r]/u;
|
|
495
|
+
const RE_UNICODE_PUNCT = /[\p{P}\p{S}]/u;
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Is this code point a whitespace character in the spec's sense?
|
|
499
|
+
* @param {number} point
|
|
500
|
+
* @returns {boolean}
|
|
501
|
+
*/
|
|
502
|
+
export function isUnicodeWhitespace(point) {
|
|
503
|
+
if (point < 128) return point === 0x20 || point === 0x0A || point === 0x09;
|
|
504
|
+
return RE_UNICODE_WS.test(String.fromCodePoint(point));
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Is this code point a punctuation character in the spec's sense (P* or S*)?
|
|
509
|
+
* @param {number} point
|
|
510
|
+
* @returns {boolean}
|
|
511
|
+
*/
|
|
512
|
+
export function isUnicodePunctuation(point) {
|
|
513
|
+
if (point < 128) return ASCII_PUNCT[point] === 1;
|
|
514
|
+
return RE_UNICODE_PUNCT.test(String.fromCodePoint(point));
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* The whole code point ending at `pos`, so a run preceded by an astral
|
|
519
|
+
* symbol classifies on the symbol and not on a surrogate half.
|
|
520
|
+
* @param {string} src @param {number} pos
|
|
521
|
+
* @returns {number}
|
|
522
|
+
*/
|
|
523
|
+
export function codePointBefore(src, pos) {
|
|
524
|
+
const low = src.charCodeAt(pos - 1);
|
|
525
|
+
if (low >= 0xdc00 && low <= 0xdfff && pos >= 2) {
|
|
526
|
+
const high = src.charCodeAt(pos - 2);
|
|
527
|
+
if (high >= 0xd800 && high <= 0xdbff) return (high - 0xd800) * 0x400 + low - 0xdc00 + 0x10000;
|
|
528
|
+
}
|
|
529
|
+
return low;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Normalize a link label for matching: trim, collapse internal
|
|
534
|
+
* whitespace runs to one space, and case fold.
|
|
535
|
+
*
|
|
536
|
+
* The fold is lower→upper→lower, not `toLowerCase()`: the spec asks for
|
|
537
|
+
* Unicode case folding, under which `ẞ` matches `SS`, while lower-casing
|
|
538
|
+
* alone maps `ẞ` to `ß` and never meets `ss`. The round trip routes both
|
|
539
|
+
* spellings through the same expansion (`ẞ`→`ß`→`SS`→`ss`, and `fi`→`fi`),
|
|
540
|
+
* which is as close to the full fold as a zero-dependency package gets
|
|
541
|
+
* without shipping the table.
|
|
542
|
+
* @param {string} label
|
|
543
|
+
* @returns {string}
|
|
544
|
+
*/
|
|
545
|
+
export function normalizeLabel(label) {
|
|
546
|
+
return label.trim().replace(/[ \t\n]+/g, ' ').toLowerCase().toUpperCase().toLowerCase();
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// ------------------------------------------------------------------
|
|
550
|
+
// GFM footnotes
|
|
551
|
+
// ------------------------------------------------------------------
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* A footnote label: `[^` + one or more characters that are not `]`, `[`
|
|
555
|
+
* or whitespace. The no-whitespace rule is the reference
|
|
556
|
+
* implementation's and it applies to BOTH sides — a definition and a
|
|
557
|
+
* reference are recognized by the same grammar, so `[^my note]` is
|
|
558
|
+
* neither, rather than one without the other (MD-FORMAT.md §4.6).
|
|
559
|
+
* Returns the offset of the `]`, or -1.
|
|
560
|
+
* @param {string} text
|
|
561
|
+
* @param {number} start offset of the `[`
|
|
562
|
+
* @returns {number}
|
|
563
|
+
*/
|
|
564
|
+
function scanFootnoteLabel(text, start) {
|
|
565
|
+
if (text.charCodeAt(start) !== 0x5B /* [ */ || text.charCodeAt(start + 1) !== 0x5E /* ^ */) {
|
|
566
|
+
return -1;
|
|
567
|
+
}
|
|
568
|
+
let i = start + 2;
|
|
569
|
+
while (i < text.length) {
|
|
570
|
+
const c = text.charCodeAt(i);
|
|
571
|
+
if (c === 0x5D /* ] */) return i > start + 2 ? i : -1;
|
|
572
|
+
if (c === 0x5B /* [ */ || c === 0x20 || c === 0x09 || c === 0x0A) return -1;
|
|
573
|
+
i++;
|
|
574
|
+
}
|
|
575
|
+
return -1;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* Footnote definition opener: `[^label]:` and the spaces after it.
|
|
580
|
+
* @param {string} line
|
|
581
|
+
* @param {number} start first non-space offset
|
|
582
|
+
* @returns {{ label: string, contentOffset: number } | null}
|
|
583
|
+
*/
|
|
584
|
+
export function scanFootnoteDefinition(line, start) {
|
|
585
|
+
const close = scanFootnoteLabel(line, start);
|
|
586
|
+
if (close === -1 || line.charCodeAt(close + 1) !== 0x3A /* : */) return null;
|
|
587
|
+
let i = close + 2;
|
|
588
|
+
while (i < line.length && isSpaceCode(line.charCodeAt(i))) i++;
|
|
589
|
+
return { label: line.slice(start + 2, close), contentOffset: i };
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* Footnote reference: `[^label]` in inline text.
|
|
594
|
+
* @param {string} text
|
|
595
|
+
* @param {number} start offset of the `[`
|
|
596
|
+
* @returns {{ label: string, end: number } | null}
|
|
597
|
+
*/
|
|
598
|
+
export function scanFootnoteReference(text, start) {
|
|
599
|
+
const close = scanFootnoteLabel(text, start);
|
|
600
|
+
return close === -1 ? null : { label: text.slice(start + 2, close), end: close + 1 };
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// ------------------------------------------------------------------
|
|
604
|
+
// GFM autolink literals
|
|
605
|
+
// ------------------------------------------------------------------
|
|
606
|
+
|
|
607
|
+
/** Is this an ASCII letter or digit? */
|
|
608
|
+
function isAsciiAlnum(c) {
|
|
609
|
+
return (c >= 0x30 && c <= 0x39) || (c >= 0x41 && c <= 0x5A) || (c >= 0x61 && c <= 0x7A);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* A character that may carry a domain: alphanumerics, `-` and `_`, plus
|
|
614
|
+
* any non-ASCII character that is neither whitespace nor punctuation
|
|
615
|
+
* (so an internationalized domain autolinks and an em dash after one
|
|
616
|
+
* does not).
|
|
617
|
+
* @param {number} c
|
|
618
|
+
* @returns {boolean}
|
|
619
|
+
*/
|
|
620
|
+
function isDomainChar(c) {
|
|
621
|
+
if (c < 128) return isAsciiAlnum(c) || c === 0x2D /* - */ || c === 0x5F /* _ */;
|
|
622
|
+
return !isUnicodeWhitespace(c) && !isUnicodePunctuation(c);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/** An email local-part character: alphanumeric, `.`, `-`, `_` or `+`. */
|
|
626
|
+
function isEmailLocalChar(c) {
|
|
627
|
+
return isAsciiAlnum(c)
|
|
628
|
+
|| c === 0x2E /* . */ || c === 0x2D /* - */ || c === 0x5F /* _ */ || c === 0x2B /* + */;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* A literal autolink may only begin at the start of the text, after
|
|
633
|
+
* whitespace, or after one of `*`, `_`, `~`, `(` (GFM §Autolinks). The
|
|
634
|
+
* start of a text node counts: what precedes it is a sibling node, not a
|
|
635
|
+
* character, and a `www.` there is as unambiguous as one after a space.
|
|
636
|
+
* @param {string} text @param {number} pos
|
|
637
|
+
* @returns {boolean}
|
|
638
|
+
*/
|
|
639
|
+
function isAutolinkStart(text, pos) {
|
|
640
|
+
if (pos === 0) return true;
|
|
641
|
+
const c = text.charCodeAt(pos - 1);
|
|
642
|
+
return c === 0x20 || c === 0x09 || c === 0x0A
|
|
643
|
+
|| c === 0x2A /* * */ || c === 0x5F /* _ */ || c === 0x7E /* ~ */ || c === 0x28 /* ( */;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* A valid domain at `pos`: segments of domain characters separated by
|
|
648
|
+
* periods, at least one period, no underscore in the last two segments.
|
|
649
|
+
* A trailing period is not part of the domain. Returns the end offset,
|
|
650
|
+
* or -1.
|
|
651
|
+
*
|
|
652
|
+
* `underscores` relaxes the last-two-segments rule for the email
|
|
653
|
+
* grammar, which states only that the last character may not be `-` or
|
|
654
|
+
* `_` — the two grammars really do differ, and `foo@a_b.example` is a
|
|
655
|
+
* link while `www.a_b.example` is not.
|
|
656
|
+
* @param {string} text @param {number} pos @param {boolean} underscores
|
|
657
|
+
* @returns {number}
|
|
658
|
+
*/
|
|
659
|
+
function scanAutolinkDomain(text, pos, underscores) {
|
|
660
|
+
let i = pos;
|
|
661
|
+
while (i < text.length) {
|
|
662
|
+
const c = text.charCodeAt(i);
|
|
663
|
+
if (c !== 0x2E /* . */ && !isDomainChar(c)) break;
|
|
664
|
+
i++;
|
|
665
|
+
}
|
|
666
|
+
while (i > pos && text.charCodeAt(i - 1) === 0x2E) i--;
|
|
667
|
+
if (i === pos) return -1;
|
|
668
|
+
const segments = text.slice(pos, i).split('.');
|
|
669
|
+
if (segments.length < 2 || segments[segments.length - 1] === '') return -1;
|
|
670
|
+
if (!underscores) {
|
|
671
|
+
if (segments[segments.length - 1].indexOf('_') !== -1) return -1;
|
|
672
|
+
if (segments[segments.length - 2].indexOf('_') !== -1) return -1;
|
|
673
|
+
}
|
|
674
|
+
return i;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/**
|
|
678
|
+
* Extended autolink path validation (GFM §Autolinks): pull trailing
|
|
679
|
+
* punctuation back out of the link. `?!.,:*_~` always; a `)` only while
|
|
680
|
+
* the link holds more of them than `(`; a `;` only when it closes an
|
|
681
|
+
* entity-shaped tail (`©`), which is why the whole `&…;` goes and
|
|
682
|
+
* not just the semicolon.
|
|
683
|
+
* @param {string} text @param {number} start @param {number} end
|
|
684
|
+
* @returns {number}
|
|
685
|
+
*/
|
|
686
|
+
function trimAutolinkEnd(text, start, end) {
|
|
687
|
+
while (end > start) {
|
|
688
|
+
const c = text.charCodeAt(end - 1);
|
|
689
|
+
if (c === 0x3F || c === 0x21 || c === 0x2E || c === 0x2C
|
|
690
|
+
|| c === 0x3A || c === 0x2A || c === 0x5F || c === 0x7E) {
|
|
691
|
+
end--;
|
|
692
|
+
continue;
|
|
693
|
+
}
|
|
694
|
+
if (c === 0x3B /* ; */) {
|
|
695
|
+
let j = end - 2;
|
|
696
|
+
while (j > start && isAsciiAlnum(text.charCodeAt(j))) j--;
|
|
697
|
+
if (j < end - 2 && text.charCodeAt(j) === 0x26 /* & */) {
|
|
698
|
+
end = j;
|
|
699
|
+
continue;
|
|
700
|
+
}
|
|
701
|
+
break;
|
|
702
|
+
}
|
|
703
|
+
if (c === 0x29 /* ) */) {
|
|
704
|
+
let open = 0;
|
|
705
|
+
let close = 0;
|
|
706
|
+
for (let k = start; k < end; k++) {
|
|
707
|
+
const d = text.charCodeAt(k);
|
|
708
|
+
if (d === 0x28) open++;
|
|
709
|
+
else if (d === 0x29) close++;
|
|
710
|
+
}
|
|
711
|
+
if (close <= open) break;
|
|
712
|
+
end--;
|
|
713
|
+
continue;
|
|
714
|
+
}
|
|
715
|
+
break;
|
|
716
|
+
}
|
|
717
|
+
return end;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/** The URL tail after a domain: any run of non-space, non-`<` characters. */
|
|
721
|
+
function scanAutolinkTail(text, pos) {
|
|
722
|
+
let i = pos;
|
|
723
|
+
while (i < text.length) {
|
|
724
|
+
const c = text.charCodeAt(i);
|
|
725
|
+
if (c === 0x20 || c === 0x09 || c === 0x0A || c === 0x3C /* < */) break;
|
|
726
|
+
i++;
|
|
727
|
+
}
|
|
728
|
+
return i;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* The schemes that open an extended url autolink. Matching is
|
|
733
|
+
* case-SENSITIVE, here and for `www.`: the reference implementation
|
|
734
|
+
* compares bytes, so `WWW.EXAMPLE.COM` is not a link on GitHub either,
|
|
735
|
+
* and this package's promise is that a document renders the same in
|
|
736
|
+
* both places — not that it renders more.
|
|
737
|
+
*/
|
|
738
|
+
const AUTOLINK_SCHEMES = ['http://', 'https://', 'ftp://'];
|
|
739
|
+
|
|
740
|
+
/**
|
|
741
|
+
* Find every extended autolink in one text value (GFM §Autolinks): bare
|
|
742
|
+
* `www.…`, `http://…`, `https://…`, `ftp://…` and email addresses.
|
|
743
|
+
* Returns the matches in order, or `null` when there are none — the
|
|
744
|
+
* common answer, and the one that costs nothing.
|
|
745
|
+
*
|
|
746
|
+
* This works on a TEXT VALUE and not on the source, which is what makes
|
|
747
|
+
* the entity rule meaningful: `©` has already become `©` by the
|
|
748
|
+
* time we look, so the only `&…;` left to exclude is one that was never
|
|
749
|
+
* an entity in the first place.
|
|
750
|
+
* @param {string} value
|
|
751
|
+
* @returns {{ start: number, end: number, url: string }[] | null}
|
|
752
|
+
*/
|
|
753
|
+
export function scanAutolinkLiterals(value) {
|
|
754
|
+
/** @type {{ start: number, end: number, url: string }[] | null} */
|
|
755
|
+
let out = null;
|
|
756
|
+
let i = 0;
|
|
757
|
+
let floor = 0;
|
|
758
|
+
while (i < value.length) {
|
|
759
|
+
const c = value.charCodeAt(i);
|
|
760
|
+
/** @type {{ start: number, end: number, url: string } | null} */
|
|
761
|
+
let hit = null;
|
|
762
|
+
if (c === 0x40 /* @ */) {
|
|
763
|
+
hit = matchEmail(value, i, floor);
|
|
764
|
+
}
|
|
765
|
+
else if (c === 0x77 /* w */ && isAutolinkStart(value, i)) {
|
|
766
|
+
hit = matchWww(value, i);
|
|
767
|
+
}
|
|
768
|
+
else if ((c === 0x68 /* h */ || c === 0x66 /* f */) && isAutolinkStart(value, i)) {
|
|
769
|
+
hit = matchScheme(value, i);
|
|
770
|
+
}
|
|
771
|
+
if (hit === null) {
|
|
772
|
+
i++;
|
|
773
|
+
continue;
|
|
774
|
+
}
|
|
775
|
+
if (out === null) out = [];
|
|
776
|
+
out.push(hit);
|
|
777
|
+
i = hit.end;
|
|
778
|
+
floor = hit.end;
|
|
779
|
+
}
|
|
780
|
+
return out;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/**
|
|
784
|
+
* `www.` + a valid domain + a path tail; the scheme is inserted, so the
|
|
785
|
+
* AST holds the destination a browser would follow and no consumer has
|
|
786
|
+
* to re-derive it.
|
|
787
|
+
* @param {string} value @param {number} pos
|
|
788
|
+
*/
|
|
789
|
+
function matchWww(value, pos) {
|
|
790
|
+
if (!value.startsWith('www.', pos)) return null;
|
|
791
|
+
const domain = scanAutolinkDomain(value, pos + 4, false);
|
|
792
|
+
if (domain === -1) return null;
|
|
793
|
+
const end = trimAutolinkEnd(value, pos, scanAutolinkTail(value, domain));
|
|
794
|
+
if (end <= pos + 4) return null;
|
|
795
|
+
return { start: pos, end, url: 'http://' + value.slice(pos, end) };
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
/**
|
|
799
|
+
* `http://`, `https://` or `ftp://` + a valid domain + a path tail.
|
|
800
|
+
* @param {string} value @param {number} pos
|
|
801
|
+
*/
|
|
802
|
+
function matchScheme(value, pos) {
|
|
803
|
+
for (let s = 0; s < AUTOLINK_SCHEMES.length; s++) {
|
|
804
|
+
const scheme = AUTOLINK_SCHEMES[s];
|
|
805
|
+
if (!value.startsWith(scheme, pos)) continue;
|
|
806
|
+
const domain = scanAutolinkDomain(value, pos + scheme.length, false);
|
|
807
|
+
if (domain === -1) continue;
|
|
808
|
+
const end = trimAutolinkEnd(value, pos, scanAutolinkTail(value, domain));
|
|
809
|
+
if (end <= pos + scheme.length) continue;
|
|
810
|
+
return { start: pos, end, url: value.slice(pos, end) };
|
|
811
|
+
}
|
|
812
|
+
return null;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
/**
|
|
816
|
+
* An email address around the `@` at `at`. The local part is found by
|
|
817
|
+
* walking BACK — the address is the only autolink whose start is left of
|
|
818
|
+
* its trigger — and never back past `floor`, the end of the previous
|
|
819
|
+
* match. The path-validation trim does not apply: the grammar rejects an
|
|
820
|
+
* address ending in `-` or `_` outright rather than shortening it.
|
|
821
|
+
* @param {string} value @param {number} at @param {number} floor
|
|
822
|
+
*/
|
|
823
|
+
function matchEmail(value, at, floor) {
|
|
824
|
+
let start = at;
|
|
825
|
+
while (start > floor && isEmailLocalChar(value.charCodeAt(start - 1))) start--;
|
|
826
|
+
if (start === at || !isAutolinkStart(value, start)) return null;
|
|
827
|
+
const end = scanAutolinkDomain(value, at + 1, true);
|
|
828
|
+
if (end === -1) return null;
|
|
829
|
+
const last = value.charCodeAt(end - 1);
|
|
830
|
+
if (last === 0x2D /* - */ || last === 0x5F /* _ */) return null;
|
|
831
|
+
return { start, end, url: 'mailto:' + value.slice(start, end) };
|
|
832
|
+
}
|