@vesk/runtime 0.1.1 → 0.2.8
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/dist/form.js +1 -1
- package/dist/headless.d.ts +30 -0
- package/dist/headless.d.ts.map +1 -0
- package/dist/headless.js +39 -0
- package/dist/hmr-client.js +5 -2
- package/dist/index-client.d.ts +5 -0
- package/dist/index-client.d.ts.map +1 -1
- package/dist/index-client.js +3 -0
- package/dist/index-router.d.ts +16 -0
- package/dist/index-router.d.ts.map +1 -0
- package/dist/index-router.js +15 -0
- package/dist/index-server.d.ts +3 -0
- package/dist/index-server.d.ts.map +1 -1
- package/dist/index-server.js +2 -0
- package/dist/loading-indicator.d.ts +127 -0
- package/dist/loading-indicator.d.ts.map +1 -0
- package/dist/loading-indicator.js +342 -0
- package/dist/md.d.ts +112 -3
- package/dist/md.d.ts.map +1 -1
- package/dist/md.js +1977 -198
- package/dist/network.d.ts +28 -0
- package/dist/network.d.ts.map +1 -0
- package/dist/network.js +62 -0
- package/dist/request.d.ts.map +1 -1
- package/dist/request.js +22 -13
- package/dist/resource.d.ts +3 -1
- package/dist/resource.d.ts.map +1 -1
- package/dist/resource.js +36 -1
- package/dist/ripple-runtime.d.ts +11 -6
- package/dist/ripple-runtime.d.ts.map +1 -1
- package/dist/ripple-runtime.js +12 -0
- package/dist/router-components.d.ts +29 -3
- package/dist/router-components.d.ts.map +1 -1
- package/dist/router-components.js +73 -0
- package/dist/router-match.d.ts +2 -0
- package/dist/router-match.d.ts.map +1 -1
- package/dist/router-match.js +6 -3
- package/dist/router.d.ts +27 -0
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +770 -254
- package/package.json +6 -2
- package/dist/package.json +0 -23
package/dist/md.js
CHANGED
|
@@ -1,10 +1,35 @@
|
|
|
1
|
+
import { effect } from '@vesk/runtime/src/ripple-blocks';
|
|
1
2
|
// =============================================================
|
|
2
3
|
// Markdown rendering for <Md content={...} />.
|
|
3
4
|
//
|
|
4
5
|
// Tokenizer-based (no regex) — mirrors the compiler's no-regex
|
|
5
6
|
// rule for source-text handling. Raw HTML is never passed through;
|
|
6
7
|
// everything is escaped so content is safe to render as-is.
|
|
8
|
+
//
|
|
9
|
+
// renderMarkdown(md) keeps its legacy output shape byte-compatible.
|
|
10
|
+
// <Md> opts into the advanced pipeline (syntax highlighting, code
|
|
11
|
+
// chrome, heading anchors, autolinks, hard breaks) and can be tuned
|
|
12
|
+
// via props (css / lineNumbers / copy / highlight / ids / autolink).
|
|
13
|
+
//
|
|
14
|
+
// `content` accepts a plain string or a tracked cell — cells are
|
|
15
|
+
// unwrapped for rendering and, on the client, subscribed so the
|
|
16
|
+
// rendered markdown updates reactively.
|
|
7
17
|
// =============================================================
|
|
18
|
+
/** Unwraps tracked cells passed as props (compiler emits the cell itself). */
|
|
19
|
+
function unwrapMaybeCell(v) {
|
|
20
|
+
if (v !== null && typeof v === 'object' && typeof v.get === 'function') {
|
|
21
|
+
try {
|
|
22
|
+
return v.get();
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return v;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return v;
|
|
29
|
+
}
|
|
30
|
+
function isCell(v) {
|
|
31
|
+
return v !== null && typeof v === 'object' && typeof v.get === 'function';
|
|
32
|
+
}
|
|
8
33
|
export function escapeHtml(s) {
|
|
9
34
|
let out = '';
|
|
10
35
|
for (let i = 0; i < s.length; i++) {
|
|
@@ -75,7 +100,86 @@ function fenceInfo(line) {
|
|
|
75
100
|
n++;
|
|
76
101
|
if (n < 3)
|
|
77
102
|
return null;
|
|
78
|
-
return { char: ch, len: n,
|
|
103
|
+
return { char: ch, len: n, info: t.slice(n).trim() };
|
|
104
|
+
}
|
|
105
|
+
function fenceLang(info) {
|
|
106
|
+
// Info string: first whitespace-delimited token is the language.
|
|
107
|
+
let i = 0;
|
|
108
|
+
while (i < info.length && info[i] !== ' ' && info[i] !== '\t')
|
|
109
|
+
i++;
|
|
110
|
+
return info.slice(0, i).toLowerCase();
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Parses a fenced info string: the first token is the language, remaining
|
|
114
|
+
* whitespace-separated tokens are key=value params. Values may contain
|
|
115
|
+
* parentheses (rgb()/rgba()) without breaking the split. Unknown keys are
|
|
116
|
+
* preserved so callers can extend.
|
|
117
|
+
*/
|
|
118
|
+
function parseFenceMeta(info) {
|
|
119
|
+
const tokens = [];
|
|
120
|
+
let cur = '';
|
|
121
|
+
let depth = 0;
|
|
122
|
+
for (let i = 0; i < info.length; i++) {
|
|
123
|
+
const ch = info[i];
|
|
124
|
+
if (ch === '(')
|
|
125
|
+
depth++;
|
|
126
|
+
if (ch === ')')
|
|
127
|
+
depth = Math.max(0, depth - 1);
|
|
128
|
+
if ((ch === ' ' || ch === '\t') && depth === 0) {
|
|
129
|
+
if (cur) {
|
|
130
|
+
tokens.push(cur);
|
|
131
|
+
cur = '';
|
|
132
|
+
}
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
cur += ch;
|
|
136
|
+
}
|
|
137
|
+
if (cur)
|
|
138
|
+
tokens.push(cur);
|
|
139
|
+
const lang = tokens.length > 0 ? tokens[0].toLowerCase() : '';
|
|
140
|
+
const params = {};
|
|
141
|
+
for (let k = 1; k < tokens.length; k++) {
|
|
142
|
+
const eq = tokens[k].indexOf('=');
|
|
143
|
+
if (eq === -1)
|
|
144
|
+
continue;
|
|
145
|
+
const key = tokens[k].slice(0, eq).toLowerCase();
|
|
146
|
+
let val = tokens[k].slice(eq + 1);
|
|
147
|
+
// tolerate shell-ish quoting from pasted examples
|
|
148
|
+
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
|
|
149
|
+
val = val.slice(1, -1);
|
|
150
|
+
}
|
|
151
|
+
if (key)
|
|
152
|
+
params[key] = val;
|
|
153
|
+
}
|
|
154
|
+
return { lang, params };
|
|
155
|
+
}
|
|
156
|
+
/** Color functions permitted inside bg=/fg= values; anything else with
|
|
157
|
+
* parentheses (url(), var(), expression tricks) is rejected. */
|
|
158
|
+
const COLOR_FUNCS = new Set(['rgb', 'rgba', 'hsl', 'hsla', 'hwb', 'lab', 'lch', 'oklab', 'oklch', 'color', 'color-mix']);
|
|
159
|
+
/** Allows only safe CSS color-ish characters into inline style output. */
|
|
160
|
+
function safeColorValue(v) {
|
|
161
|
+
if (v === '' || v.length > 64)
|
|
162
|
+
return null;
|
|
163
|
+
const out = [];
|
|
164
|
+
let word = '';
|
|
165
|
+
for (const ch of v) {
|
|
166
|
+
const ok = (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') ||
|
|
167
|
+
ch === '#' || ch === '%' || ch === '(' || ch === ')' || ch === ',' || ch === '.' ||
|
|
168
|
+
ch === '-' || ch === ' ';
|
|
169
|
+
if (!ok)
|
|
170
|
+
return null;
|
|
171
|
+
if (isIdentStart(ch) || isDigit(ch))
|
|
172
|
+
word += ch.toLowerCase();
|
|
173
|
+
if (ch === '(') {
|
|
174
|
+
const fnName = word.slice(0, -1);
|
|
175
|
+
if (!COLOR_FUNCS.has(fnName))
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
if (!isIdentPart(ch))
|
|
179
|
+
word = '';
|
|
180
|
+
out.push(ch);
|
|
181
|
+
}
|
|
182
|
+
return out.join('').trim() || null;
|
|
79
183
|
}
|
|
80
184
|
function isFenceClose(line, char, len) {
|
|
81
185
|
const t = line.trim();
|
|
@@ -115,6 +219,113 @@ function parseListMarker(line) {
|
|
|
115
219
|
}
|
|
116
220
|
return null;
|
|
117
221
|
}
|
|
222
|
+
// ── Tables ───────────────────────────────────────────────────
|
|
223
|
+
function splitTableRow(line) {
|
|
224
|
+
const t = line.trim();
|
|
225
|
+
if (t.length === 0)
|
|
226
|
+
return null;
|
|
227
|
+
let start = 0;
|
|
228
|
+
let end = t.length;
|
|
229
|
+
if (t[0] === '|')
|
|
230
|
+
start = 1;
|
|
231
|
+
if (t[end - 1] === '|' && end - 1 > 0 && t[end - 2] !== '\\')
|
|
232
|
+
end--;
|
|
233
|
+
const cells = [];
|
|
234
|
+
let cell = '';
|
|
235
|
+
let i = start;
|
|
236
|
+
while (i < end) {
|
|
237
|
+
const ch = t[i];
|
|
238
|
+
if (ch === '\\' && i + 1 < end && (t[i + 1] === '|' || t[i + 1] === '\\')) {
|
|
239
|
+
cell += t[i + 1];
|
|
240
|
+
i += 2;
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
if (ch === '|') {
|
|
244
|
+
cells.push(cell.trim());
|
|
245
|
+
cell = '';
|
|
246
|
+
i++;
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
cell += ch;
|
|
250
|
+
i++;
|
|
251
|
+
}
|
|
252
|
+
cells.push(cell.trim());
|
|
253
|
+
return cells;
|
|
254
|
+
}
|
|
255
|
+
function isTableSepRow(line) {
|
|
256
|
+
const t = line.trim();
|
|
257
|
+
if (t.length < 3)
|
|
258
|
+
return false;
|
|
259
|
+
let sawDash = false;
|
|
260
|
+
for (let i = 0; i < t.length; i++) {
|
|
261
|
+
const ch = t[i];
|
|
262
|
+
if (ch === '-') {
|
|
263
|
+
sawDash = true;
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
if (ch === ':' || ch === '|' || ch === ' ' || ch === '\t')
|
|
267
|
+
continue;
|
|
268
|
+
return false;
|
|
269
|
+
}
|
|
270
|
+
return sawDash;
|
|
271
|
+
}
|
|
272
|
+
function parseAlignRow(cells) {
|
|
273
|
+
const out = [];
|
|
274
|
+
for (const c of cells) {
|
|
275
|
+
const left = c.startsWith(':');
|
|
276
|
+
const right = c.endsWith(':') && c.length > (left ? 1 : 0);
|
|
277
|
+
if (left && right)
|
|
278
|
+
out.push('center');
|
|
279
|
+
else if (right)
|
|
280
|
+
out.push('right');
|
|
281
|
+
else if (left)
|
|
282
|
+
out.push('left');
|
|
283
|
+
else
|
|
284
|
+
out.push(null);
|
|
285
|
+
}
|
|
286
|
+
return out;
|
|
287
|
+
}
|
|
288
|
+
/** A table row needs a non-empty delimiter row on the next line. */
|
|
289
|
+
function looksLikeTable(lines, i) {
|
|
290
|
+
const row = lines[i].trim();
|
|
291
|
+
if (!row.includes('|'))
|
|
292
|
+
return false;
|
|
293
|
+
if (row[0] !== '|' && !row.includes(' | ') && !(row.length > 1 && row[row.length - 1] === '|')) {
|
|
294
|
+
// single column without pipes is not a table
|
|
295
|
+
if (!row.startsWith('|'))
|
|
296
|
+
return false;
|
|
297
|
+
}
|
|
298
|
+
const sep = lines[i + 1];
|
|
299
|
+
if (sep === undefined)
|
|
300
|
+
return false;
|
|
301
|
+
if (!isTableSepRow(sep))
|
|
302
|
+
return false;
|
|
303
|
+
if (!sep.includes('|') && row.includes('|')) {
|
|
304
|
+
// allow single-column tables where sep has no pipe
|
|
305
|
+
}
|
|
306
|
+
return true;
|
|
307
|
+
}
|
|
308
|
+
/** Setext underline: a row of '=' (h1) or '-' (h2) directly under a paragraph. */
|
|
309
|
+
function setextLevel(line) {
|
|
310
|
+
const t = line.trim();
|
|
311
|
+
if (t.length === 0)
|
|
312
|
+
return 0;
|
|
313
|
+
let allEq = true;
|
|
314
|
+
let allDash = true;
|
|
315
|
+
for (let i = 0; i < t.length; i++) {
|
|
316
|
+
if (t[i] !== '=')
|
|
317
|
+
allEq = false;
|
|
318
|
+
if (t[i] !== '-')
|
|
319
|
+
allDash = false;
|
|
320
|
+
if (!allEq && !allDash)
|
|
321
|
+
return 0;
|
|
322
|
+
}
|
|
323
|
+
if (allEq)
|
|
324
|
+
return 1;
|
|
325
|
+
if (allDash)
|
|
326
|
+
return 2;
|
|
327
|
+
return 0;
|
|
328
|
+
}
|
|
118
329
|
function parseBlocks(lines) {
|
|
119
330
|
const blocks = [];
|
|
120
331
|
let i = 0;
|
|
@@ -139,7 +350,7 @@ function parseBlocks(lines) {
|
|
|
139
350
|
codeLines.push(l);
|
|
140
351
|
i++;
|
|
141
352
|
}
|
|
142
|
-
blocks.push({ type: 'code', code: codeLines.join('\n'), lang: fence.
|
|
353
|
+
blocks.push({ type: 'code', code: codeLines.join('\n'), lang: fenceLang(fence.info), info: fence.info });
|
|
143
354
|
continue;
|
|
144
355
|
}
|
|
145
356
|
if (indent >= 4) {
|
|
@@ -159,7 +370,7 @@ function parseBlocks(lines) {
|
|
|
159
370
|
}
|
|
160
371
|
while (codeLines.length > 0 && codeLines[codeLines.length - 1] === '')
|
|
161
372
|
codeLines.pop();
|
|
162
|
-
blocks.push({ type: 'code', code: codeLines.join('\n'), lang: '' });
|
|
373
|
+
blocks.push({ type: 'code', code: codeLines.join('\n'), lang: '', info: '' });
|
|
163
374
|
continue;
|
|
164
375
|
}
|
|
165
376
|
const level = headingLevel(line);
|
|
@@ -173,6 +384,33 @@ function parseBlocks(lines) {
|
|
|
173
384
|
i++;
|
|
174
385
|
continue;
|
|
175
386
|
}
|
|
387
|
+
if (looksLikeTable(lines, i)) {
|
|
388
|
+
const head = splitTableRow(lines[i]) || [];
|
|
389
|
+
const sepCells = splitTableRow(lines[i + 1]) || [];
|
|
390
|
+
if (sepCells.length !== head.length) {
|
|
391
|
+
// delimiter count must match header count (GFM)
|
|
392
|
+
blocks.push({ type: 'paragraph', text: trimmed });
|
|
393
|
+
i++;
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
const align = parseAlignRow(sepCells);
|
|
397
|
+
i += 2;
|
|
398
|
+
const rows = [];
|
|
399
|
+
while (i < lines.length) {
|
|
400
|
+
const l = lines[i];
|
|
401
|
+
if (isBlank(l))
|
|
402
|
+
break;
|
|
403
|
+
if (!l.includes('|'))
|
|
404
|
+
break;
|
|
405
|
+
const cells = splitTableRow(l);
|
|
406
|
+
if (!cells)
|
|
407
|
+
break;
|
|
408
|
+
rows.push(cells);
|
|
409
|
+
i++;
|
|
410
|
+
}
|
|
411
|
+
blocks.push({ type: 'table', head, align, rows });
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
176
414
|
if (trimmed[0] === '>') {
|
|
177
415
|
const quoteLines = [];
|
|
178
416
|
while (i < lines.length) {
|
|
@@ -196,6 +434,7 @@ function parseBlocks(lines) {
|
|
|
196
434
|
const list = {
|
|
197
435
|
type: 'list',
|
|
198
436
|
ordered: marker.ordered,
|
|
437
|
+
start: marker.number,
|
|
199
438
|
items: [],
|
|
200
439
|
};
|
|
201
440
|
while (i < lines.length) {
|
|
@@ -206,7 +445,18 @@ function parseBlocks(lines) {
|
|
|
206
445
|
if (m.ordered !== list.ordered)
|
|
207
446
|
break;
|
|
208
447
|
const contentIndent = m.contentStart;
|
|
209
|
-
|
|
448
|
+
let first = l.slice(m.contentStart);
|
|
449
|
+
let task = null;
|
|
450
|
+
// GFM task item: [ ] / [x] / [X] directly after the marker.
|
|
451
|
+
if (first.startsWith('[ ] ')) {
|
|
452
|
+
task = false;
|
|
453
|
+
first = first.slice(4);
|
|
454
|
+
}
|
|
455
|
+
else if (first.startsWith('[x] ') || first.startsWith('[X] ')) {
|
|
456
|
+
task = true;
|
|
457
|
+
first = first.slice(4);
|
|
458
|
+
}
|
|
459
|
+
const itemLines = [first];
|
|
210
460
|
i++;
|
|
211
461
|
while (i < lines.length) {
|
|
212
462
|
const c = lines[i];
|
|
@@ -225,18 +475,20 @@ function parseBlocks(lines) {
|
|
|
225
475
|
}
|
|
226
476
|
break;
|
|
227
477
|
}
|
|
228
|
-
list.items.push({ blocks: parseBlocks(itemLines) });
|
|
478
|
+
list.items.push({ blocks: parseBlocks(itemLines), task });
|
|
229
479
|
}
|
|
230
480
|
blocks.push(list);
|
|
231
481
|
continue;
|
|
232
482
|
}
|
|
233
|
-
const paraLines = [
|
|
483
|
+
const paraLines = [line.slice(leadingSpaces(line))];
|
|
234
484
|
i++;
|
|
235
485
|
while (i < lines.length) {
|
|
236
486
|
const l = lines[i];
|
|
237
487
|
if (isBlank(l))
|
|
238
488
|
break;
|
|
239
489
|
const t = l.trim();
|
|
490
|
+
if (setextLevel(t) > 0)
|
|
491
|
+
break;
|
|
240
492
|
if (headingLevel(l) > 0)
|
|
241
493
|
break;
|
|
242
494
|
if (isHr(l))
|
|
@@ -249,238 +501,1750 @@ function parseBlocks(lines) {
|
|
|
249
501
|
break;
|
|
250
502
|
if (leadingSpaces(l) >= 4)
|
|
251
503
|
break;
|
|
252
|
-
paraLines.push(
|
|
504
|
+
paraLines.push(l.slice(leadingSpaces(l)));
|
|
253
505
|
i++;
|
|
254
506
|
}
|
|
255
|
-
|
|
507
|
+
const joined = paraLines.join(' ');
|
|
508
|
+
// Setext heading: the underline directly terminates the paragraph.
|
|
509
|
+
if (i < lines.length && paraLines.length > 0) {
|
|
510
|
+
const lv = setextLevel(lines[i].trim());
|
|
511
|
+
if (lv > 0) {
|
|
512
|
+
blocks.push({ type: 'heading', level: lv, text: joined });
|
|
513
|
+
i++;
|
|
514
|
+
continue;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
blocks.push({ type: 'paragraph', text: joined, lines: paraLines });
|
|
256
518
|
}
|
|
257
519
|
return blocks;
|
|
258
520
|
}
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
521
|
+
// ── URL safety ───────────────────────────────────────────────
|
|
522
|
+
const SAFE_SCHEMES = ['http:', 'https:', 'mailto:', 'tel:'];
|
|
523
|
+
/** Allows http/https/mailto/tel plus relative URLs; anything else → '#'. */
|
|
524
|
+
export function sanitizeUrl(url) {
|
|
525
|
+
const u = url.trim();
|
|
526
|
+
if (u === '')
|
|
527
|
+
return '#';
|
|
528
|
+
// find scheme delimiter before any path-ish char
|
|
529
|
+
for (let i = 0; i < u.length; i++) {
|
|
530
|
+
const ch = u[i];
|
|
531
|
+
if (ch === ':') {
|
|
532
|
+
const scheme = u.slice(0, i + 1).toLowerCase();
|
|
533
|
+
for (const s of SAFE_SCHEMES) {
|
|
534
|
+
if (scheme === s)
|
|
535
|
+
return u;
|
|
536
|
+
}
|
|
537
|
+
return '#';
|
|
272
538
|
}
|
|
539
|
+
if (ch === '/' || ch === '?' || ch === '#' || ch === '&')
|
|
540
|
+
break;
|
|
273
541
|
}
|
|
274
|
-
return
|
|
275
|
-
}
|
|
276
|
-
function renderBlock(b) {
|
|
277
|
-
if (b.type === 'paragraph') {
|
|
278
|
-
return `<p>${renderInline(b.text)}</p>`;
|
|
279
|
-
}
|
|
280
|
-
if (b.type === 'heading') {
|
|
281
|
-
return `<h${b.level}>${renderInline(b.text)}</h${b.level}>`;
|
|
282
|
-
}
|
|
283
|
-
if (b.type === 'hr') {
|
|
284
|
-
return '<hr />';
|
|
285
|
-
}
|
|
286
|
-
if (b.type === 'code') {
|
|
287
|
-
const lang = b.lang ? ` class="language-${escapeHtml(b.lang)}"` : '';
|
|
288
|
-
return `<pre><code${lang}>${escapeHtml(b.code)}</code></pre>`;
|
|
289
|
-
}
|
|
290
|
-
if (b.type === 'blockquote') {
|
|
291
|
-
return `<blockquote>${renderBlocks(b.blocks)}</blockquote>`;
|
|
292
|
-
}
|
|
293
|
-
return renderList(b);
|
|
294
|
-
}
|
|
295
|
-
function renderList(b) {
|
|
296
|
-
const tag = b.ordered ? 'ol' : 'ul';
|
|
297
|
-
const items = b.items.map(renderListItem).join('\n');
|
|
298
|
-
return `<${tag}>\n${items}\n</${tag}>`;
|
|
542
|
+
return u;
|
|
299
543
|
}
|
|
300
|
-
function
|
|
301
|
-
|
|
302
|
-
for (const b of blocks) {
|
|
303
|
-
out.push(renderBlock(b));
|
|
304
|
-
}
|
|
305
|
-
return out.join('\n');
|
|
544
|
+
function normalizeRefLabel(label) {
|
|
545
|
+
return label.trim().toLowerCase();
|
|
306
546
|
}
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
if (text[i] !== '[')
|
|
547
|
+
/**
|
|
548
|
+
* Parses one `[label]: destination "title"` definition line (single-line form).
|
|
549
|
+
* Returns null when the line is not a definition.
|
|
550
|
+
*/
|
|
551
|
+
function parseRefDefinitionLine(rawLine) {
|
|
552
|
+
const line = rawLine.trim();
|
|
553
|
+
if (line.length < 4 || line[0] !== '[')
|
|
315
554
|
return null;
|
|
316
|
-
i++;
|
|
317
555
|
let depth = 1;
|
|
318
|
-
let
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
if (
|
|
556
|
+
let j = 1;
|
|
557
|
+
while (j < line.length) {
|
|
558
|
+
const c = line[j];
|
|
559
|
+
if (c === '\\') {
|
|
560
|
+
j += 2;
|
|
561
|
+
continue;
|
|
562
|
+
}
|
|
563
|
+
if (c === '[')
|
|
322
564
|
depth++;
|
|
323
|
-
else if (
|
|
565
|
+
else if (c === ']') {
|
|
324
566
|
depth--;
|
|
325
|
-
if (depth === 0)
|
|
326
|
-
labelEnd = j;
|
|
567
|
+
if (depth === 0)
|
|
327
568
|
break;
|
|
328
|
-
}
|
|
329
569
|
}
|
|
330
570
|
j++;
|
|
331
571
|
}
|
|
332
|
-
if (
|
|
572
|
+
if (j >= line.length || line[j] !== ']')
|
|
333
573
|
return null;
|
|
334
|
-
const label =
|
|
335
|
-
|
|
336
|
-
if (text[j] !== '(')
|
|
574
|
+
const label = line.slice(1, j);
|
|
575
|
+
if (label.trim() === '')
|
|
337
576
|
return null;
|
|
338
|
-
j
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
577
|
+
if (line[j + 1] !== ':')
|
|
578
|
+
return null;
|
|
579
|
+
let rest = line.slice(j + 2);
|
|
580
|
+
// optional whitespace incl. up to one newline equivalent (we are single-line)
|
|
581
|
+
while (rest.length > 0 && (rest[0] === ' ' || rest[0] === '\t'))
|
|
582
|
+
rest = rest.slice(1);
|
|
583
|
+
if (rest === '')
|
|
584
|
+
return null;
|
|
585
|
+
let dest;
|
|
586
|
+
if (rest[0] === '<') {
|
|
587
|
+
const close = rest.indexOf('>');
|
|
588
|
+
if (close === -1)
|
|
589
|
+
return null;
|
|
590
|
+
dest = rest.slice(1, close);
|
|
591
|
+
rest = rest.slice(close + 1);
|
|
592
|
+
if (dest.includes('<'))
|
|
593
|
+
return null;
|
|
594
|
+
}
|
|
595
|
+
else {
|
|
596
|
+
let e = 0;
|
|
597
|
+
while (e < rest.length && rest[e] !== ' ' && rest[e] !== '\t')
|
|
598
|
+
e++;
|
|
599
|
+
dest = rest.slice(0, e);
|
|
600
|
+
rest = rest.slice(e);
|
|
601
|
+
}
|
|
602
|
+
if (dest === '')
|
|
603
|
+
return null;
|
|
604
|
+
let title = '';
|
|
605
|
+
const trimmedRest = rest.trim();
|
|
606
|
+
if (trimmedRest !== '') {
|
|
607
|
+
// Definition tail is a bare title: "…", '…' or (…) occupying the rest.
|
|
608
|
+
const open = trimmedRest[0];
|
|
609
|
+
if (open !== '"' && open !== '\'' && open !== '(')
|
|
610
|
+
return null;
|
|
611
|
+
const close = open === '(' ? ')' : open;
|
|
612
|
+
let m = 1;
|
|
613
|
+
const titleChars = [];
|
|
614
|
+
let closed = false;
|
|
615
|
+
while (m < trimmedRest.length) {
|
|
616
|
+
const c = trimmedRest[m];
|
|
617
|
+
if (c === '\\' && m + 1 < trimmedRest.length) {
|
|
618
|
+
titleChars.push(trimmedRest[m + 1]);
|
|
619
|
+
m += 2;
|
|
620
|
+
continue;
|
|
621
|
+
}
|
|
622
|
+
if (c === close) {
|
|
623
|
+
closed = true;
|
|
624
|
+
m++;
|
|
351
625
|
break;
|
|
352
626
|
}
|
|
627
|
+
titleChars.push(c);
|
|
628
|
+
m++;
|
|
353
629
|
}
|
|
354
|
-
|
|
630
|
+
if (!closed)
|
|
631
|
+
return null;
|
|
632
|
+
while (m < trimmedRest.length && (trimmedRest[m] === ' ' || trimmedRest[m] === '\t'))
|
|
633
|
+
m++;
|
|
634
|
+
if (m < trimmedRest.length)
|
|
635
|
+
return null;
|
|
636
|
+
title = titleChars.join('');
|
|
355
637
|
}
|
|
356
|
-
|
|
357
|
-
return null;
|
|
358
|
-
const url = text.slice(urlStart, urlEnd).trim();
|
|
359
|
-
if (url === '')
|
|
360
|
-
return null;
|
|
361
|
-
return { text: label, url, alt: label, image: isImage, end: urlEnd + 1 };
|
|
362
|
-
}
|
|
363
|
-
function findDelimiter(text, from, delim) {
|
|
364
|
-
return text.indexOf(delim, from);
|
|
638
|
+
return { label, def: { url: dest, title } };
|
|
365
639
|
}
|
|
366
|
-
|
|
367
|
-
|
|
640
|
+
/**
|
|
641
|
+
* Collects top-level reference definitions and returns the remaining lines
|
|
642
|
+
* with those definitions removed. Fenced code contents are never treated as
|
|
643
|
+
* definitions.
|
|
644
|
+
*/
|
|
645
|
+
function extractRefDefinitions(lines) {
|
|
646
|
+
const defs = new Map();
|
|
647
|
+
const out = [];
|
|
368
648
|
let i = 0;
|
|
369
|
-
while (i <
|
|
370
|
-
const
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
continue;
|
|
375
|
-
}
|
|
376
|
-
if (ch === '`') {
|
|
377
|
-
let n = 0;
|
|
378
|
-
while (i + n < text.length && text[i + n] === '`')
|
|
379
|
-
n++;
|
|
380
|
-
const delim = '`'.repeat(n);
|
|
381
|
-
const close = findDelimiter(text, i + n, delim);
|
|
382
|
-
if (close !== -1) {
|
|
383
|
-
let code = text.slice(i + n, close);
|
|
384
|
-
code = code.split('\n').join(' ');
|
|
385
|
-
out += `<code>${escapeHtml(code)}</code>`;
|
|
386
|
-
i = close + n;
|
|
387
|
-
continue;
|
|
388
|
-
}
|
|
389
|
-
out += '`';
|
|
649
|
+
while (i < lines.length) {
|
|
650
|
+
const line = lines[i];
|
|
651
|
+
const fence = fenceInfo(line.trim());
|
|
652
|
+
if (fence) {
|
|
653
|
+
out.push(line);
|
|
390
654
|
i++;
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
i = close + 2;
|
|
398
|
-
continue;
|
|
655
|
+
while (i < lines.length) {
|
|
656
|
+
out.push(lines[i]);
|
|
657
|
+
const isClose = isFenceClose(lines[i], fence.char, fence.len);
|
|
658
|
+
i++;
|
|
659
|
+
if (isClose)
|
|
660
|
+
break;
|
|
399
661
|
}
|
|
400
|
-
out += '~';
|
|
401
|
-
i++;
|
|
402
662
|
continue;
|
|
403
663
|
}
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
const inner = tryEmphasis(text, i);
|
|
408
|
-
if (inner) {
|
|
409
|
-
out += inner.html;
|
|
410
|
-
i = inner.end;
|
|
411
|
-
continue;
|
|
412
|
-
}
|
|
413
|
-
}
|
|
414
|
-
out += ch;
|
|
664
|
+
const parsed = parseRefDefinitionLine(line);
|
|
665
|
+
if (parsed && !defs.has(normalizeRefLabel(parsed.label))) {
|
|
666
|
+
defs.set(normalizeRefLabel(parsed.label), parsed.def);
|
|
415
667
|
i++;
|
|
416
668
|
continue;
|
|
417
669
|
}
|
|
418
|
-
|
|
419
|
-
const link = parseLink(text, i);
|
|
420
|
-
if (link) {
|
|
421
|
-
if (link.image) {
|
|
422
|
-
out += `<img src="${escapeHtml(link.url)}" alt="${escapeHtml(link.alt)}" />`;
|
|
423
|
-
}
|
|
424
|
-
else {
|
|
425
|
-
out += `<a href="${escapeHtml(link.url)}">${renderInline(link.text)}</a>`;
|
|
426
|
-
}
|
|
427
|
-
i = link.end;
|
|
428
|
-
continue;
|
|
429
|
-
}
|
|
430
|
-
}
|
|
431
|
-
out += escapeHtml(ch);
|
|
670
|
+
out.push(line);
|
|
432
671
|
i++;
|
|
433
672
|
}
|
|
434
|
-
return out;
|
|
673
|
+
return { lines: out, defs };
|
|
435
674
|
}
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
675
|
+
const KW_JS = new Set([
|
|
676
|
+
'abstract', 'as', 'async', 'await', 'break', 'case', 'catch', 'class', 'const', 'continue',
|
|
677
|
+
'debugger', 'declare', 'default', 'delete', 'do', 'else', 'enum', 'export', 'extends', 'finally',
|
|
678
|
+
'for', 'from', 'function', 'get', 'if', 'implements', 'import', 'in', 'infer', 'instanceof',
|
|
679
|
+
'interface', 'is', 'keyof', 'let', 'namespace', 'new', 'of', 'private', 'protected', 'public',
|
|
680
|
+
'readonly', 'return', 'satisfies', 'set', 'static', 'super', 'switch', 'this', 'throw', 'try',
|
|
681
|
+
'type', 'typeof', 'var', 'void', 'while', 'with', 'yield',
|
|
682
|
+
'string', 'number', 'boolean', 'any', 'unknown', 'never', 'object', 'symbol', 'bigint',
|
|
683
|
+
]);
|
|
684
|
+
const KW_VSK = new Set([...KW_JS, 'component']);
|
|
685
|
+
const LIT_JS = new Set(['true', 'false', 'null', 'undefined', 'NaN', 'Infinity']);
|
|
686
|
+
const KW_PY = new Set([
|
|
687
|
+
'and', 'as', 'assert', 'async', 'await', 'break', 'class', 'continue', 'def', 'del', 'elif',
|
|
688
|
+
'else', 'except', 'finally', 'for', 'from', 'global', 'if', 'import', 'in', 'is', 'lambda',
|
|
689
|
+
'nonlocal', 'not', 'or', 'pass', 'raise', 'return', 'try', 'while', 'with', 'yield', 'match', 'case',
|
|
690
|
+
]);
|
|
691
|
+
const LIT_PY = new Set(['True', 'False', 'None', 'self', 'cls']);
|
|
692
|
+
const KW_GO = new Set([
|
|
693
|
+
'break', 'case', 'chan', 'const', 'continue', 'default', 'defer', 'else', 'fallthrough', 'for',
|
|
694
|
+
'func', 'go', 'goto', 'if', 'import', 'interface', 'map', 'package', 'range', 'return', 'select',
|
|
695
|
+
'struct', 'switch', 'type', 'var',
|
|
696
|
+
]);
|
|
697
|
+
const LIT_GO = new Set(['true', 'false', 'nil', 'iota']);
|
|
698
|
+
const KW_RUST = new Set([
|
|
699
|
+
'as', 'async', 'await', 'break', 'const', 'continue', 'crate', 'dyn', 'else', 'enum', 'extern',
|
|
700
|
+
'fn', 'for', 'if', 'impl', 'in', 'let', 'loop', 'match', 'mod', 'move', 'mut', 'pub', 'ref',
|
|
701
|
+
'return', 'self', 'Self', 'static', 'struct', 'super', 'trait', 'type', 'unsafe', 'use', 'where', 'while',
|
|
702
|
+
]);
|
|
703
|
+
const LIT_RUST = new Set(['true', 'false', 'None', 'Some', 'Ok', 'Err']);
|
|
704
|
+
const KW_SQL = new Set([
|
|
705
|
+
'select', 'from', 'where', 'insert', 'into', 'values', 'update', 'set', 'delete', 'create', 'table',
|
|
706
|
+
'drop', 'alter', 'add', 'column', 'primary', 'key', 'foreign', 'references', 'join', 'inner', 'left',
|
|
707
|
+
'right', 'outer', 'full', 'on', 'group', 'by', 'order', 'having', 'limit', 'offset', 'union', 'all',
|
|
708
|
+
'distinct', 'as', 'and', 'or', 'not', 'null', 'is', 'in', 'between', 'like', 'exists', 'case', 'when',
|
|
709
|
+
'then', 'else', 'end', 'index', 'view', 'with', 'returning',
|
|
710
|
+
]);
|
|
711
|
+
const KW_BASH = new Set([
|
|
712
|
+
'if', 'then', 'else', 'elif', 'fi', 'for', 'while', 'until', 'do', 'done', 'case', 'esac', 'in',
|
|
713
|
+
'function', 'return', 'local', 'export', 'source', 'alias', 'set', 'unset', 'shift', 'read', 'echo',
|
|
714
|
+
'cd', 'exit', 'trap',
|
|
715
|
+
]);
|
|
716
|
+
function kwSet(items) {
|
|
717
|
+
return new Set(items);
|
|
718
|
+
}
|
|
719
|
+
function profileFor(lang) {
|
|
720
|
+
if (lang === '')
|
|
721
|
+
return null;
|
|
722
|
+
if (lang === 'vsk' || lang === 'vesk') {
|
|
723
|
+
return { keywords: KW_VSK, literals: LIT_JS, lineComments: ['//'], blockComments: [['/*', '*/']], strings: ['"', "'", '`'] };
|
|
450
724
|
}
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
if (j > start + 1 && (text[j - 1] === ' ' || text[j - 1] === '\t')) {
|
|
455
|
-
j++;
|
|
456
|
-
continue;
|
|
457
|
-
}
|
|
458
|
-
return { html: `<em>${renderInline(text.slice(start + 1, j))}</em>`, end: j + 1 };
|
|
459
|
-
}
|
|
460
|
-
j++;
|
|
725
|
+
if (lang === 'js' || lang === 'javascript' || lang === 'mjs' || lang === 'cjs'
|
|
726
|
+
|| lang === 'ts' || lang === 'typescript' || lang === 'tsx' || lang === 'jsx') {
|
|
727
|
+
return { keywords: KW_JS, literals: LIT_JS, lineComments: ['//'], blockComments: [['/*', '*/']], strings: ['"', "'", '`'] };
|
|
461
728
|
}
|
|
462
|
-
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
729
|
+
if (lang === 'json' || lang === 'jsonc' || lang === 'json5') {
|
|
730
|
+
return { keywords: new Set(), literals: LIT_JS, lineComments: lang === 'json' ? [] : ['//'], blockComments: lang === 'json' ? [] : [['/*', '*/']], strings: ['"'] };
|
|
731
|
+
}
|
|
732
|
+
if (lang === 'py' || lang === 'python') {
|
|
733
|
+
return { keywords: KW_PY, literals: LIT_PY, lineComments: ['#'], strings: ['"', "'"] };
|
|
734
|
+
}
|
|
735
|
+
if (lang === 'go') {
|
|
736
|
+
return { keywords: KW_GO, literals: LIT_GO, lineComments: ['//'], blockComments: [['/*', '*/']], strings: ['"', "'", '`'] };
|
|
737
|
+
}
|
|
738
|
+
if (lang === 'rs' || lang === 'rust') {
|
|
739
|
+
return { keywords: KW_RUST, literals: LIT_RUST, lineComments: ['//'], blockComments: [['/*', '*/']], strings: ['"'] };
|
|
740
|
+
}
|
|
741
|
+
if (lang === 'sql' || lang === 'postgres' || lang === 'mysql' || lang === 'sqlite') {
|
|
742
|
+
return { keywords: KW_SQL, lineComments: ['--'], blockComments: [['/*', '*/']], strings: ["'"] };
|
|
743
|
+
}
|
|
744
|
+
if (lang === 'sh' || lang === 'bash' || lang === 'shell' || lang === 'zsh') {
|
|
745
|
+
return { keywords: KW_BASH, literals: new Set(['true', 'false']), lineComments: ['#'], strings: ['"', "'"] };
|
|
746
|
+
}
|
|
747
|
+
if (lang === 'yaml' || lang === 'yml' || lang === 'toml') {
|
|
748
|
+
return { keywords: new Set(), literals: LIT_PY, lineComments: ['#'], strings: ['"', "'"], highlightHashComment: true };
|
|
749
|
+
}
|
|
750
|
+
if (lang === 'java' || lang === 'kt' || lang === 'kotlin' || lang === 'swift' || lang === 'c' || lang === 'cpp' || lang === 'cs' || lang === 'csharp' || lang === 'php' || lang === 'dart' || lang === 'scala' || lang === 'zig') {
|
|
751
|
+
return { keywords: kwSet(['abstract', 'boolean', 'break', 'byte', 'case', 'catch', 'char', 'class', 'const', 'continue', 'default', 'do', 'double', 'else', 'enum', 'extends', 'final', 'finally', 'float', 'for', 'fun', 'func', 'if', 'implements', 'import', 'in', 'instanceof', 'int', 'interface', 'is', 'let', 'long', 'namespace', 'new', 'null', 'override', 'package', 'private', 'protected', 'public', 'return', 'short', 'signed', 'static', 'struct', 'super', 'switch', 'synchronized', 'template', 'this', 'throw', 'throws', 'trait', 'try', 'type', 'typedef', 'typeof', 'union', 'unsigned', 'val', 'var', 'void', 'volatile', 'when', 'where', 'while', 'using', 'public', 'ref', 'fn', 'impl']), literals: LIT_JS, lineComments: ['//'], blockComments: [['/*', '*/']], strings: ['"', "'"] };
|
|
752
|
+
}
|
|
753
|
+
return null;
|
|
754
|
+
}
|
|
755
|
+
function isIdentStart(ch) {
|
|
756
|
+
return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || ch === '_' || ch === '$';
|
|
757
|
+
}
|
|
758
|
+
function isIdentPart(ch) {
|
|
759
|
+
return isIdentStart(ch) || (ch >= '0' && ch <= '9');
|
|
760
|
+
}
|
|
761
|
+
function isDigit(ch) {
|
|
762
|
+
return ch >= '0' && ch <= '9';
|
|
763
|
+
}
|
|
764
|
+
function isHexDigit(ch) {
|
|
765
|
+
return isDigit(ch) || (ch >= 'a' && ch <= 'f') || (ch >= 'A' && ch <= 'F');
|
|
766
|
+
}
|
|
767
|
+
/**
|
|
768
|
+
* Highlights `code` as `lang` into class-tokenized HTML. Everything is
|
|
769
|
+
* escaped; unknown languages fall back to escaped plain text. Supported:
|
|
770
|
+
* js/ts/jsx/tsx/vsk/vesk family, json, python, go, rust, sql, bash/shell, yaml/toml,
|
|
771
|
+
* css/scss, html/xml/svg/vue/svelte, diff/patch.
|
|
772
|
+
*/
|
|
773
|
+
export function highlightCode(code, lang) {
|
|
774
|
+
if (lang === 'diff' || lang === 'patch')
|
|
775
|
+
return highlightDiff(code);
|
|
776
|
+
if (lang === 'css' || lang === 'scss' || lang === 'less' || lang === 'postcss')
|
|
777
|
+
return highlightCss(code);
|
|
778
|
+
if (lang === 'html' || lang === 'xml' || lang === 'svg' || lang === 'vue' || lang === 'svelte')
|
|
779
|
+
return highlightHtml(code);
|
|
780
|
+
const p = profileFor(lang);
|
|
781
|
+
if (!p)
|
|
782
|
+
return escapeHtml(code);
|
|
783
|
+
let out = '';
|
|
784
|
+
let i = 0;
|
|
785
|
+
const n = code.length;
|
|
786
|
+
let prevKw = null;
|
|
787
|
+
const emit = (cls, text) => {
|
|
788
|
+
out += `<span class="tok-${cls}">${escapeHtml(text)}</span>`;
|
|
789
|
+
};
|
|
790
|
+
while (i < n) {
|
|
791
|
+
const ch = code[i];
|
|
792
|
+
// line comments
|
|
793
|
+
let matchedLine = false;
|
|
794
|
+
if (p.lineComments) {
|
|
795
|
+
for (const lc of p.lineComments) {
|
|
796
|
+
if (code.startsWith(lc, i)) {
|
|
797
|
+
let j = i;
|
|
798
|
+
while (j < n && code[j] !== '\n')
|
|
799
|
+
j++;
|
|
800
|
+
emit('com', code.slice(i, j));
|
|
801
|
+
i = j;
|
|
802
|
+
matchedLine = true;
|
|
803
|
+
break;
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
if (matchedLine)
|
|
808
|
+
continue;
|
|
809
|
+
// block comments
|
|
810
|
+
let matchedBlock = false;
|
|
811
|
+
if (p.blockComments) {
|
|
812
|
+
for (const [open, close] of p.blockComments) {
|
|
813
|
+
if (code.startsWith(open, i)) {
|
|
814
|
+
const endIdx = code.indexOf(close, i + open.length);
|
|
815
|
+
const j = endIdx === -1 ? n : endIdx + close.length;
|
|
816
|
+
emit('com', code.slice(i, j));
|
|
817
|
+
i = j;
|
|
818
|
+
matchedBlock = true;
|
|
819
|
+
break;
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
if (matchedBlock)
|
|
824
|
+
continue;
|
|
825
|
+
// strings
|
|
826
|
+
let matchedStr = false;
|
|
827
|
+
if (p.strings) {
|
|
828
|
+
for (const q of p.strings) {
|
|
829
|
+
if (ch === q) {
|
|
830
|
+
let j = i + 1;
|
|
831
|
+
while (j < n) {
|
|
832
|
+
if (code[j] === '\\') {
|
|
833
|
+
j += 2;
|
|
834
|
+
continue;
|
|
835
|
+
}
|
|
836
|
+
if (code[j] === q) {
|
|
837
|
+
j++;
|
|
838
|
+
break;
|
|
839
|
+
}
|
|
840
|
+
if (code[j] === '\n' && q !== '`') {
|
|
841
|
+
break;
|
|
842
|
+
}
|
|
843
|
+
j++;
|
|
844
|
+
}
|
|
845
|
+
emit('str', code.slice(i, j));
|
|
846
|
+
i = j;
|
|
847
|
+
matchedStr = true;
|
|
848
|
+
break;
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
if (matchedStr)
|
|
853
|
+
continue;
|
|
854
|
+
// numbers
|
|
855
|
+
if (isDigit(ch) || (ch === '.' && i + 1 < n && isDigit(code[i + 1]))) {
|
|
856
|
+
let j = i;
|
|
857
|
+
while (j < n && (isDigit(code[j]) || code[j] === '.' || code[j] === '_'
|
|
858
|
+
|| ((code[j] === 'x' || code[j] === 'b' || code[j] === 'o') && (j === i + 1 && (code[i] === '0')))
|
|
859
|
+
|| ((code[j] === 'e' || code[j] === 'E') && j > i && (isDigit(code[j - 1]) || code[j - 1] === '.'))
|
|
860
|
+
|| ((code[j] === '+' || code[j] === '-') && j > i && (code[j - 1] === 'e' || code[j - 1] === 'E'))))
|
|
861
|
+
j++;
|
|
862
|
+
emit('num', code.slice(i, j));
|
|
863
|
+
i = j;
|
|
864
|
+
continue;
|
|
865
|
+
}
|
|
866
|
+
// identifiers / keywords / calls
|
|
867
|
+
if (isIdentStart(ch)) {
|
|
868
|
+
let j = i;
|
|
869
|
+
while (j < n && isIdentPart(code[j]))
|
|
870
|
+
j++;
|
|
871
|
+
const word = code.slice(i, j);
|
|
872
|
+
let k = j;
|
|
873
|
+
while (k < n && (code[k] === ' ' || code[k] === '\t'))
|
|
874
|
+
k++;
|
|
875
|
+
const isProp = code[k] === ':' || (code[k] === '?' && code[k + 1] === ':');
|
|
876
|
+
const isTypeName = prevKw === 'type' || prevKw === 'interface';
|
|
877
|
+
if (p.keywords.has(word)) {
|
|
878
|
+
emit('kw', word);
|
|
879
|
+
prevKw = (word === 'type' || word === 'interface') ? word : null;
|
|
880
|
+
}
|
|
881
|
+
else if (p.literals && p.literals.has(word)) {
|
|
882
|
+
emit('lit', word);
|
|
883
|
+
prevKw = null;
|
|
884
|
+
}
|
|
885
|
+
else if (code[k] === '(') {
|
|
886
|
+
emit('fn', word);
|
|
887
|
+
prevKw = null;
|
|
888
|
+
}
|
|
889
|
+
else if (isTypeName) {
|
|
890
|
+
emit('fn', word);
|
|
891
|
+
prevKw = null;
|
|
892
|
+
}
|
|
893
|
+
else if (isProp) {
|
|
894
|
+
emit('prop', word);
|
|
895
|
+
prevKw = null;
|
|
896
|
+
}
|
|
897
|
+
else {
|
|
898
|
+
emit('txt', word);
|
|
899
|
+
prevKw = null;
|
|
900
|
+
}
|
|
901
|
+
i = j;
|
|
902
|
+
continue;
|
|
903
|
+
}
|
|
904
|
+
else {
|
|
905
|
+
// reset prevKw on non-identifier that isn't whitespace
|
|
906
|
+
if (ch !== ' ' && ch !== '\t' && ch !== '\n')
|
|
907
|
+
prevKw = null;
|
|
908
|
+
}
|
|
909
|
+
if (ch === '\n') {
|
|
910
|
+
out += '\n';
|
|
911
|
+
i++;
|
|
912
|
+
continue;
|
|
913
|
+
}
|
|
914
|
+
if (ch === ' ' || ch === '\t') {
|
|
915
|
+
out += ch;
|
|
916
|
+
i++;
|
|
917
|
+
continue;
|
|
918
|
+
}
|
|
919
|
+
out += escapeHtml(ch);
|
|
920
|
+
i++;
|
|
921
|
+
}
|
|
922
|
+
return out;
|
|
923
|
+
}
|
|
924
|
+
function highlightDiff(code) {
|
|
925
|
+
const lines = code.split('\n');
|
|
926
|
+
let out = '';
|
|
927
|
+
for (const line of lines) {
|
|
928
|
+
let cls = 'txt';
|
|
929
|
+
if (line.startsWith('+') && !line.startsWith('+++'))
|
|
930
|
+
cls = 'add';
|
|
931
|
+
else if (line.startsWith('-') && !line.startsWith('---'))
|
|
932
|
+
cls = 'del';
|
|
933
|
+
else if (line.startsWith('@@'))
|
|
934
|
+
cls = 'kw';
|
|
935
|
+
else if (line.startsWith('diff ') || line.startsWith('index '))
|
|
936
|
+
cls = 'com';
|
|
937
|
+
out += `<span class="tok-${cls}">${escapeHtml(line)}</span>\n`;
|
|
938
|
+
}
|
|
939
|
+
return out;
|
|
940
|
+
}
|
|
941
|
+
function highlightCss(code) {
|
|
942
|
+
let out = '';
|
|
943
|
+
let i = 0;
|
|
944
|
+
const n = code.length;
|
|
945
|
+
const emit = (cls, text) => { out += `<span class="tok-${cls}">${escapeHtml(text)}</span>`; };
|
|
946
|
+
let inProp = false;
|
|
947
|
+
while (i < n) {
|
|
948
|
+
const ch = code[i];
|
|
949
|
+
if (code.startsWith('/*', i)) {
|
|
950
|
+
const end = code.indexOf('*/', i + 2);
|
|
951
|
+
const j = end === -1 ? n : end + 2;
|
|
952
|
+
emit('com', code.slice(i, j));
|
|
953
|
+
i = j;
|
|
954
|
+
continue;
|
|
955
|
+
}
|
|
956
|
+
if (ch === '"' || ch === "'") {
|
|
957
|
+
let j = i + 1;
|
|
958
|
+
while (j < n) {
|
|
959
|
+
if (code[j] === '\\') {
|
|
960
|
+
j += 2;
|
|
961
|
+
continue;
|
|
962
|
+
}
|
|
963
|
+
if (code[j] === ch) {
|
|
964
|
+
j++;
|
|
965
|
+
break;
|
|
966
|
+
}
|
|
967
|
+
j++;
|
|
968
|
+
}
|
|
969
|
+
emit('str', code.slice(i, j));
|
|
970
|
+
i = j;
|
|
971
|
+
continue;
|
|
972
|
+
}
|
|
973
|
+
if (ch === '#' && isHexDigit(code[i + 1] || '')) {
|
|
974
|
+
let j = i + 1;
|
|
975
|
+
while (j < n && isHexDigit(code[j]))
|
|
976
|
+
j++;
|
|
977
|
+
emit('num', code.slice(i, j));
|
|
978
|
+
i = j;
|
|
979
|
+
continue;
|
|
980
|
+
}
|
|
981
|
+
if (ch === '@' && isIdentStart(code[i + 1] || '')) {
|
|
982
|
+
let j = i + 1;
|
|
983
|
+
while (j < n && (isIdentPart(code[j]) || code[j] === '-'))
|
|
984
|
+
j++;
|
|
985
|
+
emit('kw', code.slice(i, j));
|
|
986
|
+
i = j;
|
|
987
|
+
continue;
|
|
988
|
+
}
|
|
989
|
+
if (isDigit(ch) || (ch === '.' && isDigit(code[i + 1] || ''))) {
|
|
990
|
+
let j = i;
|
|
991
|
+
while (j < n && (isDigit(code[j]) || code[j] === '.' || code[j] === '%' || isIdentPart(code[j])))
|
|
992
|
+
j++;
|
|
993
|
+
emit('num', code.slice(i, j));
|
|
994
|
+
inProp = false;
|
|
995
|
+
i = j;
|
|
996
|
+
continue;
|
|
997
|
+
}
|
|
998
|
+
if (isIdentStart(ch)) {
|
|
999
|
+
let j = i;
|
|
1000
|
+
while (j < n && (isIdentPart(code[j]) || code[j] === '-'))
|
|
1001
|
+
j++;
|
|
1002
|
+
const word = code.slice(i, j);
|
|
1003
|
+
let k = j;
|
|
1004
|
+
while (k < n && code[k] === ' ')
|
|
1005
|
+
k++;
|
|
1006
|
+
if (code[k] === ':' && code[k + 1] !== ':') {
|
|
1007
|
+
emit('prop', word);
|
|
1008
|
+
inProp = true;
|
|
1009
|
+
}
|
|
1010
|
+
else if (inProp)
|
|
1011
|
+
emit('txt', word);
|
|
1012
|
+
else if (word.startsWith('--'))
|
|
1013
|
+
emit('prop', word);
|
|
1014
|
+
else
|
|
1015
|
+
emit('sel', word);
|
|
1016
|
+
i = j;
|
|
1017
|
+
continue;
|
|
1018
|
+
}
|
|
1019
|
+
if (ch === '{' || ch === '}') {
|
|
1020
|
+
inProp = false;
|
|
1021
|
+
out += escapeHtml(ch);
|
|
1022
|
+
i++;
|
|
1023
|
+
continue;
|
|
1024
|
+
}
|
|
1025
|
+
if (ch === ':') {
|
|
1026
|
+
inProp = true;
|
|
1027
|
+
out += escapeHtml(ch);
|
|
1028
|
+
i++;
|
|
1029
|
+
continue;
|
|
1030
|
+
}
|
|
1031
|
+
if (ch === ';') {
|
|
1032
|
+
inProp = false;
|
|
1033
|
+
out += escapeHtml(ch);
|
|
1034
|
+
i++;
|
|
1035
|
+
continue;
|
|
1036
|
+
}
|
|
1037
|
+
out += escapeHtml(ch);
|
|
1038
|
+
i++;
|
|
1039
|
+
}
|
|
1040
|
+
return out;
|
|
1041
|
+
}
|
|
1042
|
+
function highlightHtml(code) {
|
|
1043
|
+
let out = '';
|
|
1044
|
+
let i = 0;
|
|
1045
|
+
const n = code.length;
|
|
1046
|
+
const emit = (cls, text) => { out += `<span class="tok-${cls}">${escapeHtml(text)}</span>`; };
|
|
1047
|
+
while (i < n) {
|
|
1048
|
+
if (code.startsWith('<!--', i)) {
|
|
1049
|
+
const end = code.indexOf('-->', i + 4);
|
|
1050
|
+
const j = end === -1 ? n : end + 3;
|
|
1051
|
+
emit('com', code.slice(i, j));
|
|
1052
|
+
i = j;
|
|
1053
|
+
continue;
|
|
1054
|
+
}
|
|
1055
|
+
if (code[i] === '<') {
|
|
1056
|
+
// tag
|
|
1057
|
+
let j = i + 1;
|
|
1058
|
+
if (code[j] === '/')
|
|
1059
|
+
j++;
|
|
1060
|
+
const nameStart = j;
|
|
1061
|
+
while (j < n && (isIdentPart(code[j]) || code[j] === '-'))
|
|
1062
|
+
j++;
|
|
1063
|
+
emit('kw', code.slice(i, j));
|
|
1064
|
+
i = j;
|
|
1065
|
+
// attributes until '>' or '/>'
|
|
1066
|
+
while (i < n && code[i] !== '>') {
|
|
1067
|
+
if (code.startsWith('/>', i))
|
|
1068
|
+
break;
|
|
1069
|
+
const ch = code[i];
|
|
1070
|
+
if (ch === '"' || ch === "'") {
|
|
1071
|
+
let k = i + 1;
|
|
1072
|
+
while (k < n && code[k] !== ch)
|
|
1073
|
+
k++;
|
|
1074
|
+
k = Math.min(k + 1, n);
|
|
1075
|
+
emit('str', code.slice(i, k));
|
|
1076
|
+
i = k;
|
|
1077
|
+
continue;
|
|
1078
|
+
}
|
|
1079
|
+
if (isIdentStart(ch) || ch === '-' || ch === ':') {
|
|
1080
|
+
let k = i;
|
|
1081
|
+
while (k < n && (isIdentPart(code[k]) || code[k] === '-' || code[k] === ':'))
|
|
1082
|
+
k++;
|
|
1083
|
+
emit(code[k] === '=' ? 'attr' : 'attr', code.slice(i, k));
|
|
1084
|
+
i = k;
|
|
1085
|
+
continue;
|
|
1086
|
+
}
|
|
1087
|
+
out += escapeHtml(ch);
|
|
1088
|
+
i++;
|
|
1089
|
+
}
|
|
1090
|
+
if (code.startsWith('/>', i)) {
|
|
1091
|
+
out += escapeHtml('/>');
|
|
1092
|
+
i += 2;
|
|
1093
|
+
}
|
|
1094
|
+
else if (i < n) {
|
|
1095
|
+
out += escapeHtml('>');
|
|
1096
|
+
i++;
|
|
1097
|
+
}
|
|
1098
|
+
continue;
|
|
1099
|
+
}
|
|
1100
|
+
const next = code.indexOf('<', i);
|
|
1101
|
+
const j = next === -1 ? n : next;
|
|
1102
|
+
emit('txt', code.slice(i, j));
|
|
1103
|
+
i = j;
|
|
1104
|
+
}
|
|
1105
|
+
return out;
|
|
1106
|
+
}
|
|
1107
|
+
// ── Heading anchors ──────────────────────────────────────────
|
|
1108
|
+
function stripMdMarkers(text) {
|
|
1109
|
+
let out = '';
|
|
1110
|
+
let i = 0;
|
|
1111
|
+
while (i < text.length) {
|
|
1112
|
+
const ch = text[i];
|
|
1113
|
+
if (ch === '`' || ch === '*' || ch === '_' || ch === '~') {
|
|
1114
|
+
i++;
|
|
1115
|
+
continue;
|
|
1116
|
+
}
|
|
1117
|
+
if (ch === '\\') {
|
|
1118
|
+
out += text[i + 1] || '';
|
|
1119
|
+
i += 2;
|
|
1120
|
+
continue;
|
|
1121
|
+
}
|
|
1122
|
+
if (ch === '[' || ch === ']') {
|
|
1123
|
+
i++;
|
|
1124
|
+
continue;
|
|
1125
|
+
}
|
|
1126
|
+
if (ch === '!') {
|
|
1127
|
+
i++;
|
|
1128
|
+
continue;
|
|
1129
|
+
}
|
|
1130
|
+
out += ch;
|
|
1131
|
+
i++;
|
|
1132
|
+
}
|
|
1133
|
+
return out;
|
|
1134
|
+
}
|
|
1135
|
+
function slugify(text) {
|
|
1136
|
+
const plain = stripMdMarkers(text).toLowerCase().trim();
|
|
1137
|
+
let out = '';
|
|
1138
|
+
let lastDash = true;
|
|
1139
|
+
for (let i = 0; i < plain.length; i++) {
|
|
1140
|
+
const ch = plain[i];
|
|
1141
|
+
const ok = (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9');
|
|
1142
|
+
if (ok) {
|
|
1143
|
+
out += ch;
|
|
1144
|
+
lastDash = false;
|
|
1145
|
+
continue;
|
|
1146
|
+
}
|
|
1147
|
+
if (!lastDash) {
|
|
1148
|
+
out += '-';
|
|
1149
|
+
lastDash = true;
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
while (out.endsWith('-'))
|
|
1153
|
+
out = out.slice(0, -1);
|
|
1154
|
+
return out || 'section';
|
|
1155
|
+
}
|
|
1156
|
+
// ── Autolinks ────────────────────────────────────────────────
|
|
1157
|
+
function tryAutolink(text, i) {
|
|
1158
|
+
let schemeLen = -1;
|
|
1159
|
+
let prefix = '';
|
|
1160
|
+
if (text.startsWith('https://', i)) {
|
|
1161
|
+
schemeLen = 8;
|
|
1162
|
+
prefix = '';
|
|
1163
|
+
}
|
|
1164
|
+
else if (text.startsWith('http://', i)) {
|
|
1165
|
+
schemeLen = 7;
|
|
1166
|
+
prefix = '';
|
|
1167
|
+
}
|
|
1168
|
+
else if (text.startsWith('mailto:', i)) {
|
|
1169
|
+
schemeLen = 7;
|
|
1170
|
+
prefix = '';
|
|
1171
|
+
}
|
|
1172
|
+
else if (text.startsWith('www.', i) && (i === 0 || !isIdentPart(text[i - 1]))) {
|
|
1173
|
+
schemeLen = 0;
|
|
1174
|
+
prefix = 'https://';
|
|
1175
|
+
}
|
|
1176
|
+
if (schemeLen === -1)
|
|
1177
|
+
return null;
|
|
1178
|
+
let j = i + schemeLen;
|
|
1179
|
+
let lastGood = j;
|
|
1180
|
+
let parenDepth = 0;
|
|
1181
|
+
while (j < text.length) {
|
|
1182
|
+
const ch = text[j];
|
|
1183
|
+
if (ch === ' ' || ch === '\t' || ch === '<' || ch === '"' || ch === '\'')
|
|
1184
|
+
break;
|
|
1185
|
+
if (ch === '(')
|
|
1186
|
+
parenDepth++;
|
|
1187
|
+
if (ch === ')') {
|
|
1188
|
+
if (parenDepth === 0)
|
|
1189
|
+
break;
|
|
1190
|
+
parenDepth--;
|
|
1191
|
+
}
|
|
1192
|
+
j++;
|
|
1193
|
+
// trailing punctuation does not belong to the link
|
|
1194
|
+
if (!(ch === '.' || ch === ',' || ch === ';' || ch === '!' || ch === '?' || ch === ':'))
|
|
1195
|
+
lastGood = j;
|
|
1196
|
+
}
|
|
1197
|
+
if (lastGood === i + schemeLen && schemeLen === 0)
|
|
1198
|
+
return null;
|
|
1199
|
+
const raw = text.slice(i, lastGood);
|
|
1200
|
+
const href = prefix + raw;
|
|
1201
|
+
return { html: `<a href="${escapeHtml(sanitizeUrl(href))}">${escapeHtml(raw)}</a>`, end: lastGood };
|
|
1202
|
+
}
|
|
1203
|
+
// ── Entities ─────────────────────────────────────────────────
|
|
1204
|
+
const NAMED_ENTITIES = {
|
|
1205
|
+
amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: '\u00a0',
|
|
1206
|
+
copy: '\u00a9', reg: '\u00ae', trade: '\u2122', hellip: '\u2026',
|
|
1207
|
+
mdash: '\u2014', ndash: '\u2013', lsquo: '\u2018', rsquo: '\u2019',
|
|
1208
|
+
ldquo: '\u201c', rdquo: '\u201d', laquo: '\u00ab', raquo: '\u00bb',
|
|
1209
|
+
times: '\u00d7', divide: '\u00f7', plusmn: '\u00b1', deg: '\u00b0',
|
|
1210
|
+
middot: '\u00b7', bull: '\u2022', dagger: '\u2020', sect: '\u00a7',
|
|
1211
|
+
para: '\u00b6', euro: '\u20ac', pound: '\u00a3', yen: '\u00a5',
|
|
1212
|
+
cent: '\u00a2', sup2: '\u00b2', sup3: '\u00b3', frac12: '\u00bd',
|
|
1213
|
+
frac14: '\u00bc', frac34: '\u00be', micro: '\u00b5', agrave: '\u00e0',
|
|
1214
|
+
eacute: '\u00e9', egrave: '\u00e8', uuml: '\u00fc', ouml: '\u00f6',
|
|
1215
|
+
auml: '\u00e4', szlig: '\u00df', ntilde: '\u00f1', ccedil: '\u00e7',
|
|
1216
|
+
};
|
|
1217
|
+
/**
|
|
1218
|
+
* Decodes an entity reference at text[i] and returns it re-escaped, so
|
|
1219
|
+
* `©` renders © while `<` still renders as visible "<" (the decoded
|
|
1220
|
+
* char goes back through escapeHtml). Returns null when there is no valid
|
|
1221
|
+
* reference — the caller escapes the '&' literally.
|
|
1222
|
+
*/
|
|
1223
|
+
function tryEntity(text, i) {
|
|
1224
|
+
if (text[i] !== '&')
|
|
1225
|
+
return null;
|
|
1226
|
+
const n = text.length;
|
|
1227
|
+
if (text[i + 1] === '#') {
|
|
1228
|
+
let j = i + 2;
|
|
1229
|
+
let value = 0;
|
|
1230
|
+
if (text[j] === 'x' || text[j] === 'X') {
|
|
1231
|
+
j++;
|
|
1232
|
+
while (j < n && isHexDigit(text[j])) {
|
|
1233
|
+
value = value * 16 + parseInt(text[j], 16);
|
|
1234
|
+
j++;
|
|
1235
|
+
}
|
|
1236
|
+
if (j === i + 3)
|
|
1237
|
+
return null;
|
|
1238
|
+
}
|
|
1239
|
+
else {
|
|
1240
|
+
while (j < n && isDigit(text[j])) {
|
|
1241
|
+
value = value * 10 + (text.charCodeAt(j) - 48);
|
|
1242
|
+
j++;
|
|
1243
|
+
}
|
|
1244
|
+
if (j === i + 2)
|
|
1245
|
+
return null;
|
|
1246
|
+
}
|
|
1247
|
+
if (text[j] !== ';' || value === 0 || value > 0x10ffff)
|
|
1248
|
+
return null;
|
|
1249
|
+
return { html: escapeHtml(String.fromCodePoint(value)), end: j + 1 };
|
|
1250
|
+
}
|
|
1251
|
+
let j = i + 1;
|
|
1252
|
+
while (j < n && j <= i + 32 && isIdentPart(text[j]))
|
|
1253
|
+
j++;
|
|
1254
|
+
if (j === i + 1 || text[j] !== ';')
|
|
1255
|
+
return null;
|
|
1256
|
+
const name = text.slice(i + 1, j);
|
|
1257
|
+
const decoded = NAMED_ENTITIES[name];
|
|
1258
|
+
if (decoded === undefined)
|
|
1259
|
+
return null;
|
|
1260
|
+
return { html: escapeHtml(decoded), end: j + 1 };
|
|
1261
|
+
}
|
|
1262
|
+
function parseLink(text, start) {
|
|
1263
|
+
let i = start;
|
|
1264
|
+
let isImage = false;
|
|
1265
|
+
if (text[i] === '!' && text[i + 1] === '[') {
|
|
1266
|
+
isImage = true;
|
|
1267
|
+
i++;
|
|
1268
|
+
}
|
|
1269
|
+
if (text[i] !== '[')
|
|
1270
|
+
return null;
|
|
1271
|
+
i++;
|
|
1272
|
+
let depth = 1;
|
|
1273
|
+
let labelEnd = -1;
|
|
1274
|
+
let j = i;
|
|
1275
|
+
while (j < text.length) {
|
|
1276
|
+
if (text[j] === '[')
|
|
1277
|
+
depth++;
|
|
1278
|
+
else if (text[j] === ']') {
|
|
1279
|
+
depth--;
|
|
1280
|
+
if (depth === 0) {
|
|
1281
|
+
labelEnd = j;
|
|
1282
|
+
break;
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
j++;
|
|
1286
|
+
}
|
|
1287
|
+
if (labelEnd === -1)
|
|
1288
|
+
return null;
|
|
1289
|
+
const label = text.slice(i, labelEnd);
|
|
1290
|
+
j = labelEnd + 1;
|
|
1291
|
+
if (text[j] !== '(')
|
|
1292
|
+
return null;
|
|
1293
|
+
j++;
|
|
1294
|
+
while (j < text.length && text[j] === ' ')
|
|
1295
|
+
j++;
|
|
1296
|
+
const urlStart = j;
|
|
1297
|
+
depth = 1;
|
|
1298
|
+
let urlEnd = -1;
|
|
1299
|
+
while (j < text.length) {
|
|
1300
|
+
if (text[j] === '(')
|
|
1301
|
+
depth++;
|
|
1302
|
+
else if (text[j] === ')') {
|
|
1303
|
+
depth--;
|
|
1304
|
+
if (depth === 0) {
|
|
1305
|
+
urlEnd = j;
|
|
1306
|
+
break;
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
j++;
|
|
1310
|
+
}
|
|
1311
|
+
if (urlEnd === -1)
|
|
1312
|
+
return null;
|
|
1313
|
+
const rawDest = text.slice(urlStart, urlEnd);
|
|
1314
|
+
const parts = splitLinkTitle(rawDest);
|
|
1315
|
+
if (!parts || parts.url === '')
|
|
1316
|
+
return null;
|
|
1317
|
+
return { text: label, url: parts.url, title: parts.title, alt: label, image: isImage, end: urlEnd + 1 };
|
|
1318
|
+
}
|
|
1319
|
+
/**
|
|
1320
|
+
* Splits an inline link destination into URL + optional title. A title must be
|
|
1321
|
+
* separated from the URL by whitespace ("double quoted", 'single quoted' or
|
|
1322
|
+
* (parenthesized)); quotes that are part of the URL itself (e.g. ?q="1") stay
|
|
1323
|
+
* in the destination. A backslash escapes the quote character inside.
|
|
1324
|
+
*/
|
|
1325
|
+
function splitLinkTitle(dest) {
|
|
1326
|
+
// Find the first quote/paren that follows whitespace — that starts a title.
|
|
1327
|
+
let titleStart = -1;
|
|
1328
|
+
for (let i = 1; i < dest.length; i++) {
|
|
1329
|
+
const c = dest[i];
|
|
1330
|
+
if ((c === '"' || c === '\'' || c === '(') && (dest[i - 1] === ' ' || dest[i - 1] === '\t')) {
|
|
1331
|
+
titleStart = i;
|
|
1332
|
+
break;
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
if (titleStart === -1)
|
|
1336
|
+
return { url: dest.trim(), title: '' };
|
|
1337
|
+
let k = titleStart;
|
|
1338
|
+
while (k > 0 && (dest[k - 1] === ' ' || dest[k - 1] === '\t'))
|
|
1339
|
+
k--;
|
|
1340
|
+
const url = dest.slice(0, k).trim();
|
|
1341
|
+
const open = dest[titleStart];
|
|
1342
|
+
const close = open === '(' ? ')' : open;
|
|
1343
|
+
let m = titleStart + 1;
|
|
1344
|
+
const titleChars = [];
|
|
1345
|
+
let closed = false;
|
|
1346
|
+
while (m < dest.length) {
|
|
1347
|
+
const c = dest[m];
|
|
1348
|
+
if (c === '\\' && m + 1 < dest.length) {
|
|
1349
|
+
titleChars.push(dest[m + 1]);
|
|
1350
|
+
m += 2;
|
|
1351
|
+
continue;
|
|
1352
|
+
}
|
|
1353
|
+
if (c === close) {
|
|
1354
|
+
closed = true;
|
|
1355
|
+
m++;
|
|
1356
|
+
break;
|
|
1357
|
+
}
|
|
1358
|
+
titleChars.push(c);
|
|
1359
|
+
m++;
|
|
1360
|
+
}
|
|
1361
|
+
if (!closed)
|
|
1362
|
+
return null;
|
|
1363
|
+
while (m < dest.length && (dest[m] === ' ' || dest[m] === '\t'))
|
|
1364
|
+
m++;
|
|
1365
|
+
if (m < dest.length)
|
|
1366
|
+
return null; // trailing garbage after the title
|
|
1367
|
+
return { url, title: titleChars.join('') };
|
|
1368
|
+
}
|
|
1369
|
+
function findDelimiter(text, from, delim) {
|
|
1370
|
+
return text.indexOf(delim, from);
|
|
1371
|
+
}
|
|
1372
|
+
function isAsciiLetter(c) {
|
|
1373
|
+
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z');
|
|
1374
|
+
}
|
|
1375
|
+
function isTagNameChar(c) {
|
|
1376
|
+
return isAsciiLetter(c) || (c >= '0' && c <= '9') || c === '-';
|
|
1377
|
+
}
|
|
1378
|
+
/**
|
|
1379
|
+
* Scans an HTML tag starting at `text[start] === '<'`. Handles comments
|
|
1380
|
+
* (`<!-- … -->`), quoted attribute values containing `>`, and self-closing
|
|
1381
|
+
* tags. Returns null when this is not a well-formed tag.
|
|
1382
|
+
*/
|
|
1383
|
+
function scanHtmlTag(text, start) {
|
|
1384
|
+
if (text[start] !== '<')
|
|
1385
|
+
return null;
|
|
1386
|
+
if (text.startsWith('<!--', start)) {
|
|
1387
|
+
const close = text.indexOf('-->', start + 4);
|
|
1388
|
+
if (close === -1)
|
|
1389
|
+
return null;
|
|
1390
|
+
return { end: close + 3, tag: '!--', closing: false, selfClosing: false, attrSrc: '' };
|
|
1391
|
+
}
|
|
1392
|
+
let i = start + 1;
|
|
1393
|
+
let closing = false;
|
|
1394
|
+
if (text[i] === '/') {
|
|
1395
|
+
closing = true;
|
|
1396
|
+
i++;
|
|
1397
|
+
}
|
|
1398
|
+
if (!isAsciiLetter(text[i]))
|
|
1399
|
+
return null;
|
|
1400
|
+
const nameStart = i;
|
|
1401
|
+
while (i < text.length && isTagNameChar(text[i]))
|
|
1402
|
+
i++;
|
|
1403
|
+
const tag = text.slice(nameStart, i).toLowerCase();
|
|
1404
|
+
// find the terminating '>' honoring quotes
|
|
1405
|
+
let k = i;
|
|
1406
|
+
let quote = '';
|
|
1407
|
+
while (k < text.length) {
|
|
1408
|
+
const c = text[k];
|
|
1409
|
+
if (quote !== '') {
|
|
1410
|
+
if (c === quote)
|
|
1411
|
+
quote = '';
|
|
1412
|
+
}
|
|
1413
|
+
else if (c === '"' || c === "'") {
|
|
1414
|
+
quote = c;
|
|
1415
|
+
}
|
|
1416
|
+
else if (c === '>') {
|
|
1417
|
+
break;
|
|
1418
|
+
}
|
|
1419
|
+
k++;
|
|
1420
|
+
}
|
|
1421
|
+
if (k >= text.length)
|
|
1422
|
+
return null; // unterminated tag — treat as text
|
|
1423
|
+
let selfClosing = false;
|
|
1424
|
+
let attrEnd = k;
|
|
1425
|
+
if (text[k - 1] === '/') {
|
|
1426
|
+
selfClosing = true;
|
|
1427
|
+
attrEnd = k - 1;
|
|
1428
|
+
}
|
|
1429
|
+
return { end: k + 1, tag, closing, selfClosing, attrSrc: text.slice(i, attrEnd) };
|
|
1430
|
+
}
|
|
1431
|
+
/** Emits one passthrough warning into the render's collector. */
|
|
1432
|
+
function recordRawHtml(warnings, tag, sample) {
|
|
1433
|
+
if (!warnings)
|
|
1434
|
+
return;
|
|
1435
|
+
warnings.push({ tag, sample: sample.length > 60 ? sample.slice(0, 57) + '…' : sample });
|
|
1436
|
+
}
|
|
1437
|
+
const URL_ATTRS = new Set(['href', 'src', 'xlink:href', 'poster', 'formaction']);
|
|
1438
|
+
/** Escapes a value for embedding in a double-quoted attribute. */
|
|
1439
|
+
function escapeAttrValue(v) {
|
|
1440
|
+
return v.split('"').join('"').split('&').join('&');
|
|
1441
|
+
}
|
|
1442
|
+
/**
|
|
1443
|
+
* Rebuilds an attribute source string under the allowlist policy:
|
|
1444
|
+
* event-handler attributes (`on*`) are dropped, URL-bearing attributes are
|
|
1445
|
+
* sanitized via sanitizeUrl, everything else is kept verbatim.
|
|
1446
|
+
*/
|
|
1447
|
+
function filterHtmlAttributes(attrSrc) {
|
|
1448
|
+
let out = '';
|
|
1449
|
+
let i = 0;
|
|
1450
|
+
const n = attrSrc.length;
|
|
1451
|
+
while (i < n) {
|
|
1452
|
+
// skip whitespace between attributes
|
|
1453
|
+
while (i < n && (attrSrc[i] === ' ' || attrSrc[i] === '\t' || attrSrc[i] === '\n')) {
|
|
1454
|
+
out += attrSrc[i];
|
|
1455
|
+
i++;
|
|
1456
|
+
}
|
|
1457
|
+
if (i >= n)
|
|
1458
|
+
break;
|
|
1459
|
+
const tokenStart = i;
|
|
1460
|
+
// attribute name
|
|
1461
|
+
while (i < n && attrSrc[i] !== '=' && attrSrc[i] !== ' ' && attrSrc[i] !== '\t' && attrSrc[i] !== '\n')
|
|
1462
|
+
i++;
|
|
1463
|
+
const name = attrSrc.slice(tokenStart, i);
|
|
1464
|
+
const nameLower = name.toLowerCase();
|
|
1465
|
+
let rawValue = null;
|
|
1466
|
+
if (attrSrc[i] === '=') {
|
|
1467
|
+
i++;
|
|
1468
|
+
while (i < n && (attrSrc[i] === ' ' || attrSrc[i] === '\t'))
|
|
1469
|
+
i++;
|
|
1470
|
+
if (attrSrc[i] === '"' || attrSrc[i] === "'") {
|
|
1471
|
+
const q = attrSrc[i];
|
|
1472
|
+
const vStart = ++i;
|
|
1473
|
+
while (i < n && attrSrc[i] !== q)
|
|
1474
|
+
i++;
|
|
1475
|
+
rawValue = attrSrc.slice(vStart, i);
|
|
1476
|
+
if (i < n)
|
|
1477
|
+
i++; // closing quote
|
|
1478
|
+
}
|
|
1479
|
+
else {
|
|
1480
|
+
const vStart = i;
|
|
1481
|
+
while (i < n && attrSrc[i] !== ' ' && attrSrc[i] !== '\t' && attrSrc[i] !== '\n')
|
|
1482
|
+
i++;
|
|
1483
|
+
rawValue = attrSrc.slice(vStart, i);
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
const tokenEnd = i;
|
|
1487
|
+
if (nameLower.startsWith('on'))
|
|
1488
|
+
continue; // drop inline handlers
|
|
1489
|
+
if (rawValue !== null && URL_ATTRS.has(nameLower)) {
|
|
1490
|
+
out += `${name}="${escapeAttrValue(sanitizeUrl(rawValue))}"`;
|
|
1491
|
+
continue;
|
|
1492
|
+
}
|
|
1493
|
+
out += attrSrc.slice(tokenStart, tokenEnd);
|
|
1494
|
+
}
|
|
1495
|
+
return out;
|
|
1496
|
+
}
|
|
1497
|
+
/**
|
|
1498
|
+
* Renders a raw-HTML tag at `text[start]` according to the policy:
|
|
1499
|
+
* - 'allow': verbatim.
|
|
1500
|
+
* - 'allowlist': allowed tags only; attrs filtered; closing tags normalized.
|
|
1501
|
+
* Returns null when the policy does not apply — the caller then falls back to
|
|
1502
|
+
* escaping, so disallowed tags stay visible as literal text.
|
|
1503
|
+
*/
|
|
1504
|
+
function tryRawHtml(text, start, opts) {
|
|
1505
|
+
const mode = opts.htmlMode;
|
|
1506
|
+
if (mode !== 'allow' && mode !== 'allowlist')
|
|
1507
|
+
return null;
|
|
1508
|
+
const scan = scanHtmlTag(text, start);
|
|
1509
|
+
if (!scan)
|
|
1510
|
+
return null;
|
|
1511
|
+
if (mode === 'allowlist') {
|
|
1512
|
+
if (scan.tag === '!--')
|
|
1513
|
+
return null;
|
|
1514
|
+
const allowedTags = opts.allowTags || MD_DEFAULT_ALLOW_TAGS;
|
|
1515
|
+
if (!allowedTags.includes(scan.tag))
|
|
1516
|
+
return null;
|
|
1517
|
+
if (scan.closing) {
|
|
1518
|
+
recordRawHtml(opts.rawHtmlWarnings, scan.tag, text.slice(start, scan.end));
|
|
1519
|
+
return { html: `</${scan.tag}>`, end: scan.end };
|
|
1520
|
+
}
|
|
1521
|
+
recordRawHtml(opts.rawHtmlWarnings, scan.tag, text.slice(start, scan.end));
|
|
1522
|
+
const filtered = filterHtmlAttributes(scan.attrSrc);
|
|
1523
|
+
const tail = scan.selfClosing ? ' />' : '>';
|
|
1524
|
+
return { html: `<${scan.tag}${filtered}${tail}`, end: scan.end };
|
|
1525
|
+
}
|
|
1526
|
+
// mode === 'allow': verbatim, but still record so callers can warn.
|
|
1527
|
+
recordRawHtml(opts.rawHtmlWarnings, scan.tag, text.slice(start, scan.end));
|
|
1528
|
+
return { html: text.slice(start, scan.end), end: scan.end };
|
|
1529
|
+
}
|
|
1530
|
+
function renderInline(text, opts = {}) {
|
|
1531
|
+
let out = '';
|
|
1532
|
+
let i = 0;
|
|
1533
|
+
while (i < text.length) {
|
|
1534
|
+
const ch = text[i];
|
|
1535
|
+
if (ch === '\\' && i + 1 < text.length) {
|
|
1536
|
+
out += escapeHtml(text[i + 1]);
|
|
1537
|
+
i += 2;
|
|
1538
|
+
continue;
|
|
1539
|
+
}
|
|
1540
|
+
if (opts.autolink && (ch === 'h' || ch === 'm' || ch === 'w')) {
|
|
1541
|
+
const auto = tryAutolink(text, i);
|
|
1542
|
+
if (auto) {
|
|
1543
|
+
out += auto.html;
|
|
1544
|
+
i = auto.end;
|
|
1545
|
+
continue;
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
if (ch === '`') {
|
|
1549
|
+
let n = 0;
|
|
1550
|
+
while (i + n < text.length && text[i + n] === '`')
|
|
1551
|
+
n++;
|
|
1552
|
+
const delim = '`'.repeat(n);
|
|
1553
|
+
const close = findDelimiter(text, i + n, delim);
|
|
1554
|
+
if (close !== -1) {
|
|
1555
|
+
let code = text.slice(i + n, close);
|
|
1556
|
+
code = code.split('\n').join(' ');
|
|
1557
|
+
out += `<code>${escapeHtml(code)}</code>`;
|
|
1558
|
+
i = close + n;
|
|
1559
|
+
continue;
|
|
1560
|
+
}
|
|
1561
|
+
out += '`';
|
|
1562
|
+
i++;
|
|
1563
|
+
continue;
|
|
1564
|
+
}
|
|
1565
|
+
if (ch === '~' && text[i + 1] === '~') {
|
|
1566
|
+
const close = findDelimiter(text, i + 2, '~~');
|
|
1567
|
+
if (close !== -1 && close > i + 2) {
|
|
1568
|
+
out += `<del>${renderInline(text.slice(i + 2, close), opts)}</del>`;
|
|
1569
|
+
i = close + 2;
|
|
1570
|
+
continue;
|
|
1571
|
+
}
|
|
1572
|
+
out += '~';
|
|
1573
|
+
i++;
|
|
1574
|
+
continue;
|
|
1575
|
+
}
|
|
1576
|
+
if (ch === '*' || ch === '_') {
|
|
1577
|
+
const next = text[i + 1];
|
|
1578
|
+
if (next !== ' ' && next !== '\t' && next !== undefined) {
|
|
1579
|
+
const inner = tryEmphasis(text, i, opts);
|
|
1580
|
+
if (inner) {
|
|
1581
|
+
out += inner.html;
|
|
1582
|
+
i = inner.end;
|
|
1583
|
+
continue;
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
out += ch;
|
|
1587
|
+
i++;
|
|
1588
|
+
continue;
|
|
1589
|
+
}
|
|
1590
|
+
if (ch === '[' || (ch === '!' && text[i + 1] === '[')) {
|
|
1591
|
+
let link = parseLink(text, i);
|
|
1592
|
+
if (!link)
|
|
1593
|
+
link = parseRefLink(text, i, opts.defs);
|
|
1594
|
+
if (link) {
|
|
1595
|
+
const titleAttr = link.title !== '' ? ` title="${escapeHtml(link.title)}"` : '';
|
|
1596
|
+
if (link.image) {
|
|
1597
|
+
out += `<img src="${escapeHtml(sanitizeUrl(link.url))}" alt="${escapeHtml(link.alt)}"${titleAttr} loading="lazy" />`;
|
|
1598
|
+
}
|
|
1599
|
+
else {
|
|
1600
|
+
out += `<a href="${escapeHtml(sanitizeUrl(link.url))}"${titleAttr}>${renderInline(link.text, opts)}</a>`;
|
|
1601
|
+
}
|
|
1602
|
+
i = link.end;
|
|
1603
|
+
continue;
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
if (ch === '<' && opts.autolink) {
|
|
1607
|
+
const angle = tryAngleAutolink(text, i);
|
|
1608
|
+
if (angle) {
|
|
1609
|
+
out += angle.html;
|
|
1610
|
+
i = angle.end;
|
|
1611
|
+
continue;
|
|
1612
|
+
}
|
|
1613
|
+
}
|
|
1614
|
+
if (ch === '<' && opts.htmlMode && opts.htmlMode !== 'escape') {
|
|
1615
|
+
const raw = tryRawHtml(text, i, opts);
|
|
1616
|
+
if (raw) {
|
|
1617
|
+
out += raw.html;
|
|
1618
|
+
i = raw.end;
|
|
1619
|
+
continue;
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
if (ch === '&') {
|
|
1623
|
+
const ent = tryEntity(text, i);
|
|
1624
|
+
if (ent) {
|
|
1625
|
+
out += ent.html;
|
|
1626
|
+
i = ent.end;
|
|
1627
|
+
continue;
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1630
|
+
out += escapeHtml(ch);
|
|
1631
|
+
i++;
|
|
1632
|
+
}
|
|
1633
|
+
return out;
|
|
1634
|
+
}
|
|
1635
|
+
/**
|
|
1636
|
+
* CommonMark angle autolinks: `<https://…>`, `<http://…>`, `<mailto:…>` and
|
|
1637
|
+
* bare `<user@host.tld>` emails. Everything else returns null so the `<` is
|
|
1638
|
+
* escaped as before. Hrefs still pass through sanitizeUrl.
|
|
1639
|
+
*/
|
|
1640
|
+
function tryAngleAutolink(text, start) {
|
|
1641
|
+
let end = -1;
|
|
1642
|
+
for (let j = start + 1; j < text.length && j <= start + 512; j++) {
|
|
1643
|
+
const c = text[j];
|
|
1644
|
+
if (c === ' ' || c === '\t' || c === '\n' || c === '<')
|
|
1645
|
+
return null;
|
|
1646
|
+
if (c === '>') {
|
|
1647
|
+
end = j;
|
|
1648
|
+
break;
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
1651
|
+
if (end === -1)
|
|
1652
|
+
return null;
|
|
1653
|
+
const inner = text.slice(start + 1, end);
|
|
1654
|
+
let href = null;
|
|
1655
|
+
if (inner.startsWith('https://') || inner.startsWith('http://') || inner.startsWith('mailto:')) {
|
|
1656
|
+
href = inner;
|
|
1657
|
+
}
|
|
1658
|
+
else if (isEmailLike(inner)) {
|
|
1659
|
+
href = 'mailto:' + inner;
|
|
1660
|
+
}
|
|
1661
|
+
if (!href)
|
|
1662
|
+
return null;
|
|
1663
|
+
return { html: `<a href="${escapeHtml(sanitizeUrl(href))}">${escapeHtml(inner)}</a>`, end: end + 1 };
|
|
1664
|
+
}
|
|
1665
|
+
function isEmailLike(s) {
|
|
1666
|
+
const at = s.indexOf('@');
|
|
1667
|
+
if (at <= 0 || at !== s.lastIndexOf('@'))
|
|
1668
|
+
return false;
|
|
1669
|
+
const domain = s.slice(at + 1);
|
|
1670
|
+
const dot = domain.lastIndexOf('.');
|
|
1671
|
+
if (dot < 1 || dot === domain.length - 1)
|
|
1672
|
+
return false;
|
|
1673
|
+
const okChar = (c) => isIdentPart(c) || c === '.' || c === '-' || c === '+' || c === '%' || c === '_' || c === '@';
|
|
1674
|
+
for (const c of s) {
|
|
1675
|
+
if (!okChar(c))
|
|
1676
|
+
return false;
|
|
1677
|
+
}
|
|
1678
|
+
return true;
|
|
1679
|
+
}
|
|
1680
|
+
/**
|
|
1681
|
+
* Reference links/images: full `[text][label]`, collapsed `[text][]` and
|
|
1682
|
+
* shortcut `[text]` forms, resolved against the document's definition map.
|
|
1683
|
+
* A failed inline link (`[a](no close`) is never re-interpreted as shortcut —
|
|
1684
|
+
* it stays literal.
|
|
1685
|
+
*/
|
|
1686
|
+
function parseRefLink(text, start, defs) {
|
|
1687
|
+
if (!defs || defs.size === 0)
|
|
1688
|
+
return null;
|
|
1689
|
+
let i = start;
|
|
1690
|
+
let isImage = false;
|
|
1691
|
+
if (text[i] === '!' && text[i + 1] === '[') {
|
|
1692
|
+
isImage = true;
|
|
1693
|
+
i++;
|
|
1694
|
+
}
|
|
1695
|
+
if (text[i] !== '[')
|
|
1696
|
+
return null;
|
|
1697
|
+
i++;
|
|
1698
|
+
let depth = 1;
|
|
1699
|
+
let labelEnd = -1;
|
|
1700
|
+
let j = i;
|
|
1701
|
+
while (j < text.length) {
|
|
1702
|
+
if (text[j] === '\\') {
|
|
1703
|
+
j += 2;
|
|
1704
|
+
continue;
|
|
1705
|
+
}
|
|
1706
|
+
if (text[j] === '[')
|
|
1707
|
+
depth++;
|
|
1708
|
+
else if (text[j] === ']') {
|
|
1709
|
+
depth--;
|
|
1710
|
+
if (depth === 0) {
|
|
1711
|
+
labelEnd = j;
|
|
1712
|
+
break;
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1715
|
+
j++;
|
|
1716
|
+
}
|
|
1717
|
+
if (labelEnd === -1)
|
|
1718
|
+
return null;
|
|
1719
|
+
const label = text.slice(i, labelEnd);
|
|
1720
|
+
let refLabel;
|
|
1721
|
+
let end;
|
|
1722
|
+
const next = text[labelEnd + 1];
|
|
1723
|
+
if (next === '[') {
|
|
1724
|
+
const close = text.indexOf(']', labelEnd + 2);
|
|
1725
|
+
if (close === -1)
|
|
1726
|
+
return null;
|
|
1727
|
+
refLabel = text.slice(labelEnd + 2, close);
|
|
1728
|
+
end = close + 1;
|
|
1729
|
+
// Collapsed form `[text][]` resolves through the text itself.
|
|
1730
|
+
if (refLabel.trim() === '')
|
|
1731
|
+
refLabel = label;
|
|
1732
|
+
}
|
|
1733
|
+
else if (next === '(') {
|
|
1734
|
+
return null; // inline-form attempt already failed → literal
|
|
1735
|
+
}
|
|
1736
|
+
else {
|
|
1737
|
+
refLabel = label; // collapsed / shortcut
|
|
1738
|
+
end = labelEnd + 1;
|
|
1739
|
+
}
|
|
1740
|
+
const def = defs.get(normalizeRefLabel(refLabel));
|
|
1741
|
+
if (!def)
|
|
1742
|
+
return null;
|
|
1743
|
+
return { text: label, url: def.url, title: def.title, alt: label, image: isImage, end };
|
|
1744
|
+
}
|
|
1745
|
+
function isAlphaNum(ch) {
|
|
1746
|
+
if (ch === undefined)
|
|
1747
|
+
return false;
|
|
1748
|
+
return isIdentPart(ch);
|
|
1749
|
+
}
|
|
1750
|
+
function tryEmphasis(text, start, opts = {}) {
|
|
1751
|
+
const ch = text[start];
|
|
1752
|
+
// CommonMark flanking rules for `_`: intraword emphasis (`some_var_name`)
|
|
1753
|
+
// never applies. Asterisks keep allowing it.
|
|
1754
|
+
const underscoreOk = (open, pos) => {
|
|
1755
|
+
if (ch !== '_')
|
|
1756
|
+
return true;
|
|
1757
|
+
return open ? !isAlphaNum(text[pos - 1]) : !isAlphaNum(text[pos + 1]);
|
|
1758
|
+
};
|
|
1759
|
+
if (!underscoreOk(true, start))
|
|
1760
|
+
return null;
|
|
1761
|
+
if (text[start + 1] === ch) {
|
|
1762
|
+
let j = start + 2;
|
|
1763
|
+
while (j < text.length) {
|
|
1764
|
+
if (text[j] === ch && text[j + 1] === ch) {
|
|
1765
|
+
if (j > start + 2 && (text[j - 1] === ' ' || text[j - 1] === '\t')) {
|
|
1766
|
+
j += 2;
|
|
1767
|
+
continue;
|
|
1768
|
+
}
|
|
1769
|
+
if (!underscoreOk(false, j)) {
|
|
1770
|
+
j++;
|
|
1771
|
+
continue;
|
|
1772
|
+
}
|
|
1773
|
+
return { html: `<strong>${renderInline(text.slice(start + 2, j), opts)}</strong>`, end: j + 2 };
|
|
1774
|
+
}
|
|
1775
|
+
j++;
|
|
1776
|
+
}
|
|
1777
|
+
}
|
|
1778
|
+
let j = start + 1;
|
|
1779
|
+
while (j < text.length) {
|
|
1780
|
+
if (text[j] === ch) {
|
|
1781
|
+
if (j > start + 1 && (text[j - 1] === ' ' || text[j - 1] === '\t')) {
|
|
1782
|
+
j++;
|
|
1783
|
+
continue;
|
|
1784
|
+
}
|
|
1785
|
+
if (!underscoreOk(false, j)) {
|
|
1786
|
+
j++;
|
|
1787
|
+
continue;
|
|
1788
|
+
}
|
|
1789
|
+
return { html: `<em>${renderInline(text.slice(start + 1, j), opts)}</em>`, end: j + 1 };
|
|
1790
|
+
}
|
|
1791
|
+
j++;
|
|
1792
|
+
}
|
|
1793
|
+
return null;
|
|
1794
|
+
}
|
|
1795
|
+
/**
|
|
1796
|
+
* Tags allowed by default when html = 'allowlist'. Inline, formatting-level
|
|
1797
|
+
* tags only — structural/embedding tags (script, iframe, img, div, …) must be
|
|
1798
|
+
* opted into explicitly via allowTags. Keep in sync with
|
|
1799
|
+
* packages/compiler/src/config.ts MD_DEFAULT_ALLOW_TAGS.
|
|
1800
|
+
*/
|
|
1801
|
+
export const MD_DEFAULT_ALLOW_TAGS = [
|
|
1802
|
+
'a', 'abbr', 'b', 'bdi', 'bdo', 'br', 'cite', 'code', 'data', 'del', 'dfn', 'em',
|
|
1803
|
+
'i', 'ins', 'kbd', 'mark', 'q', 'rp', 'rt', 'ruby', 's', 'samp', 'small', 'span',
|
|
1804
|
+
'strong', 'sub', 'sup', 'time', 'u', 'var', 'wbr',
|
|
1805
|
+
];
|
|
1806
|
+
function ctxOf(o) {
|
|
1807
|
+
// Fall back to the process-wide policy (configureMd) per key, so direct
|
|
1808
|
+
// renderMarkdown calls honor vesk.config.ts just like the <Md> component.
|
|
1809
|
+
return {
|
|
1810
|
+
highlight: o.highlight === true,
|
|
1811
|
+
chrome: o.chrome === true,
|
|
1812
|
+
lineNumbers: o.lineNumbers === true,
|
|
1813
|
+
copy: o.copy !== false,
|
|
1814
|
+
ids: o.ids === true,
|
|
1815
|
+
autolink: o.autolink === true,
|
|
1816
|
+
hardBreaks: o.hardBreaks === true,
|
|
1817
|
+
html: o.html || __globalMode,
|
|
1818
|
+
allowTags: o.allowTags || __globalAllowTags || MD_DEFAULT_ALLOW_TAGS,
|
|
1819
|
+
headings: new Map(),
|
|
1820
|
+
refs: new Map(),
|
|
1821
|
+
warnings: [],
|
|
1822
|
+
};
|
|
1823
|
+
}
|
|
1824
|
+
function inlineOpts(ctx) {
|
|
1825
|
+
return { autolink: ctx.autolink, defs: ctx.refs, htmlMode: ctx.html, allowTags: ctx.allowTags, rawHtmlWarnings: ctx.warnings };
|
|
1826
|
+
}
|
|
1827
|
+
function renderListItem(item, ctx) {
|
|
1828
|
+
const blocks = item.blocks;
|
|
1829
|
+
let checkbox = '';
|
|
1830
|
+
if (item.task === true)
|
|
1831
|
+
checkbox = '<input type="checkbox" checked disabled class="md-task-box" />';
|
|
1832
|
+
else if (item.task === false)
|
|
1833
|
+
checkbox = '<input type="checkbox" disabled class="md-task-box" />';
|
|
1834
|
+
const liClass = item.task !== null ? ' class="md-task"' : '';
|
|
1835
|
+
if (blocks.length === 1 && blocks[0].type === 'paragraph') {
|
|
1836
|
+
return `<li${liClass}>${checkbox}${renderInline(blocks[0].text, inlineOpts(ctx))}</li>`;
|
|
1837
|
+
}
|
|
1838
|
+
const parts = [];
|
|
1839
|
+
for (let i = 0; i < blocks.length; i++) {
|
|
1840
|
+
const b = blocks[i];
|
|
1841
|
+
if (i === 0 && b.type === 'paragraph') {
|
|
1842
|
+
parts.push(renderInline(b.text, inlineOpts(ctx)));
|
|
1843
|
+
}
|
|
1844
|
+
else {
|
|
1845
|
+
parts.push(renderBlock(b, ctx));
|
|
1846
|
+
}
|
|
1847
|
+
}
|
|
1848
|
+
return `<li${liClass}>${checkbox}${parts.join('\n')}</li>`;
|
|
1849
|
+
}
|
|
1850
|
+
function renderHeadingId(text, ctx) {
|
|
1851
|
+
const base = slugify(text);
|
|
1852
|
+
const seen = ctx.headings.get(base) || 0;
|
|
1853
|
+
ctx.headings.set(base, seen + 1);
|
|
1854
|
+
return seen === 0 ? base : `${base}-${seen + 1}`;
|
|
1855
|
+
}
|
|
1856
|
+
function trimTrailingSpaces(s) {
|
|
1857
|
+
let e = s.length;
|
|
1858
|
+
while (e > 0 && s[e - 1] === ' ')
|
|
1859
|
+
e--;
|
|
1860
|
+
return s.slice(0, e);
|
|
1861
|
+
}
|
|
1862
|
+
function hardJoin(lines, ctx) {
|
|
1863
|
+
let out = '';
|
|
1864
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1865
|
+
let line = lines[i];
|
|
1866
|
+
let br = false;
|
|
1867
|
+
if (ctx.hardBreaks) {
|
|
1868
|
+
if (line.endsWith(' ')) {
|
|
1869
|
+
br = true;
|
|
1870
|
+
line = trimTrailingSpaces(line);
|
|
1871
|
+
}
|
|
1872
|
+
else if (line.endsWith('\\')) {
|
|
1873
|
+
br = true;
|
|
1874
|
+
line = line.slice(0, -1);
|
|
1875
|
+
}
|
|
1876
|
+
}
|
|
1877
|
+
out += renderInline(trimTrailingSpaces(line), inlineOpts(ctx));
|
|
1878
|
+
if (br && i < lines.length - 1)
|
|
1879
|
+
out += '<br />\n';
|
|
1880
|
+
else if (i < lines.length - 1)
|
|
1881
|
+
out += ' ';
|
|
1882
|
+
}
|
|
1883
|
+
return out;
|
|
1884
|
+
}
|
|
1885
|
+
/**
|
|
1886
|
+
* Wraps each rendered line in <span class="tok-line"> for CSS-counter line
|
|
1887
|
+
* numbers. Token spans never intentionally cross lines, except multi-line
|
|
1888
|
+
* block comments — the splitter reopens any open spans on the next line so
|
|
1889
|
+
* nesting stays valid.
|
|
1890
|
+
*/
|
|
1891
|
+
function wrapCodeLines(html) {
|
|
1892
|
+
const stack = [];
|
|
1893
|
+
let out = '';
|
|
1894
|
+
let i = 0;
|
|
1895
|
+
const n = html.length;
|
|
1896
|
+
const closeAll = () => {
|
|
1897
|
+
for (let k = stack.length - 1; k >= 0; k--)
|
|
1898
|
+
out += '</span>';
|
|
1899
|
+
};
|
|
1900
|
+
const reopen = () => {
|
|
1901
|
+
for (const c of stack)
|
|
1902
|
+
out += c;
|
|
1903
|
+
};
|
|
1904
|
+
out += '<span class="tok-line">';
|
|
1905
|
+
while (i < n) {
|
|
1906
|
+
if (html.startsWith('<span class="tok-', i)) {
|
|
1907
|
+
const close = html.indexOf('>', i);
|
|
1908
|
+
if (close === -1) {
|
|
1909
|
+
out += html[i];
|
|
1910
|
+
i++;
|
|
1911
|
+
continue;
|
|
1912
|
+
}
|
|
1913
|
+
const tag = html.slice(i, close + 1);
|
|
1914
|
+
stack.push(tag);
|
|
1915
|
+
out += tag;
|
|
1916
|
+
i = close + 1;
|
|
1917
|
+
continue;
|
|
1918
|
+
}
|
|
1919
|
+
if (html.startsWith('</span>', i)) {
|
|
1920
|
+
stack.pop();
|
|
1921
|
+
out += '</span>';
|
|
1922
|
+
i += 7;
|
|
1923
|
+
continue;
|
|
1924
|
+
}
|
|
1925
|
+
if (html[i] === '\n') {
|
|
1926
|
+
closeAll();
|
|
1927
|
+
out += '</span>\n<span class="tok-line">';
|
|
1928
|
+
reopen();
|
|
1929
|
+
i++;
|
|
1930
|
+
continue;
|
|
1931
|
+
}
|
|
1932
|
+
out += html[i];
|
|
1933
|
+
i++;
|
|
1934
|
+
}
|
|
1935
|
+
closeAll();
|
|
1936
|
+
out += '</span>';
|
|
1937
|
+
return out;
|
|
1938
|
+
}
|
|
1939
|
+
function renderCodeBlock(b, ctx) {
|
|
1940
|
+
const legacy = !ctx.chrome && !ctx.highlight;
|
|
1941
|
+
if (legacy) {
|
|
1942
|
+
const lang = b.lang ? ` class="language-${escapeHtml(b.lang)}"` : '';
|
|
1943
|
+
return `<pre><code${lang}>${escapeHtml(b.code)}</code></pre>`;
|
|
1944
|
+
}
|
|
1945
|
+
const rawBody = ctx.highlight && b.lang ? highlightCode(b.code, b.lang) : escapeHtml(b.code);
|
|
1946
|
+
const body = ctx.lineNumbers && ctx.chrome ? wrapCodeLines(rawBody) : rawBody;
|
|
1947
|
+
const codeCls = b.lang ? `language-${escapeHtml(b.lang)}` : '';
|
|
1948
|
+
const lineAttr = ctx.lineNumbers && ctx.chrome ? ' md-lines' : '';
|
|
1949
|
+
if (!ctx.chrome) {
|
|
1950
|
+
return `<pre><code${codeCls ? ` class="${codeCls}"` : ''}>${body}</code></pre>`;
|
|
1951
|
+
}
|
|
1952
|
+
const meta = parseFenceMeta(b.info || b.lang);
|
|
1953
|
+
const styleParts = [];
|
|
1954
|
+
if (meta.params.bg !== undefined) {
|
|
1955
|
+
const v = safeColorValue(meta.params.bg);
|
|
1956
|
+
styleParts.push(`--md-code-bg:${v === 'none' ? 'transparent' : (v || 'transparent')}`);
|
|
1957
|
+
}
|
|
1958
|
+
if (meta.params.fg !== undefined) {
|
|
1959
|
+
const v = safeColorValue(meta.params.fg);
|
|
1960
|
+
if (v && v !== 'none')
|
|
1961
|
+
styleParts.push(`--md-code-fg:${v}`);
|
|
1962
|
+
}
|
|
1963
|
+
const styleAttr = styleParts.length > 0 ? ` style="${escapeHtml(styleParts.join(';'))}"` : '';
|
|
1964
|
+
const langLabel = meta.lang ? `<span class="md-code-lang">${escapeHtml(meta.lang)}</span>` : '<span class="md-code-lang"></span>';
|
|
1965
|
+
const copyBtn = ctx.copy ? '<button type="button" class="md-copy" data-md-copy aria-label="Copy code">Copy</button>' : '';
|
|
1966
|
+
return (`<div class="md-code"${meta.lang ? ` data-lang="${escapeHtml(meta.lang)}"` : ''}${styleAttr}>` +
|
|
1967
|
+
`<div class="md-code-bar">${langLabel}${copyBtn}</div>` +
|
|
1968
|
+
`<pre><code${codeCls ? ` class="${codeCls}"` : ''}${lineAttr}>${body}</code></pre>` +
|
|
1969
|
+
`</div>`);
|
|
1970
|
+
}
|
|
1971
|
+
function renderBlock(b, ctx) {
|
|
1972
|
+
if (b.type === 'paragraph') {
|
|
1973
|
+
return `<p>${hardJoin(b.lines || [b.text], ctx)}</p>`;
|
|
1974
|
+
}
|
|
1975
|
+
if (b.type === 'heading') {
|
|
1976
|
+
const id = ctx.ids ? ` id="${escapeHtml(renderHeadingId(b.text, ctx))}"` : '';
|
|
1977
|
+
return `<h${b.level}${id}>${renderInline(b.text, inlineOpts(ctx))}</h${b.level}>`;
|
|
1978
|
+
}
|
|
1979
|
+
if (b.type === 'hr') {
|
|
1980
|
+
return '<hr />';
|
|
1981
|
+
}
|
|
1982
|
+
if (b.type === 'code') {
|
|
1983
|
+
return renderCodeBlock(b, ctx);
|
|
1984
|
+
}
|
|
1985
|
+
if (b.type === 'blockquote') {
|
|
1986
|
+
return `<blockquote>${renderBlocks(b.blocks, ctx)}</blockquote>`;
|
|
1987
|
+
}
|
|
1988
|
+
if (b.type === 'table') {
|
|
1989
|
+
return renderTable(b, ctx);
|
|
1990
|
+
}
|
|
1991
|
+
return renderList(b, ctx);
|
|
1992
|
+
}
|
|
1993
|
+
function renderList(b, ctx) {
|
|
1994
|
+
const tag = b.ordered ? 'ol' : 'ul';
|
|
1995
|
+
const startAttr = b.ordered && b.start !== 1 ? ` start="${b.start}"` : '';
|
|
1996
|
+
const items = b.items.map((it) => renderListItem(it, ctx)).join('\n');
|
|
1997
|
+
return `<${tag}${startAttr}${b.items.some(it => it.task !== null) ? ' class="md-tasks"' : ''}>\n${items}\n</${tag}>`;
|
|
1998
|
+
}
|
|
1999
|
+
function renderTable(b, ctx) {
|
|
2000
|
+
const styleFor = (a) => (a ? ` style="text-align:${a}"` : '');
|
|
2001
|
+
const headCells = b.head
|
|
2002
|
+
.map((c, idx) => `<th${styleFor(b.align[idx] || null)}>${renderInline(c, inlineOpts(ctx))}</th>`)
|
|
2003
|
+
.join('');
|
|
2004
|
+
const bodyRows = b.rows
|
|
2005
|
+
.map((row) => {
|
|
2006
|
+
const cells = b.head
|
|
2007
|
+
.map((_, idx) => `<td${styleFor(b.align[idx] || null)}>${renderInline(row[idx] || '', inlineOpts(ctx))}</td>`)
|
|
2008
|
+
.join('');
|
|
2009
|
+
return `<tr>${cells}</tr>`;
|
|
2010
|
+
})
|
|
2011
|
+
.join('\n');
|
|
2012
|
+
return `<div class="md-table-wrap"><table class="md-table"><thead><tr>${headCells}</tr></thead><tbody>\n${bodyRows}\n</tbody></table></div>`;
|
|
2013
|
+
}
|
|
2014
|
+
function renderBlocks(blocks, ctx) {
|
|
2015
|
+
const out = [];
|
|
2016
|
+
for (const b of blocks) {
|
|
2017
|
+
out.push(renderBlock(b, ctx));
|
|
2018
|
+
}
|
|
2019
|
+
return out.join('\n');
|
|
2020
|
+
}
|
|
2021
|
+
// ── Base stylesheet (opt-in via <Md css>) ────────────────────
|
|
2022
|
+
export const MD_BASE_CSS = `
|
|
2023
|
+
.vesk-md { color: #24292f; font-size: 16px; line-height: 1.7; word-wrap: break-word; }
|
|
2024
|
+
.vesk-md h1,.vesk-md h2,.vesk-md h3,.vesk-md h4,.vesk-md h5,.vesk-md h6 { margin: 1.4em 0 .6em; font-weight: 650; line-height: 1.25; }
|
|
2025
|
+
.vesk-md h1 { font-size: 2em; }
|
|
2026
|
+
.vesk-md h2 { font-size: 1.5em; }
|
|
2027
|
+
.vesk-md h3 { font-size: 1.25em; } .vesk-md h4 { font-size: 1em; }
|
|
2028
|
+
.vesk-md p { margin: .8em 0; }
|
|
2029
|
+
.vesk-md a { color: #0969da; text-decoration: none; } .vesk-md a:hover { text-decoration: underline; }
|
|
2030
|
+
.vesk-md img { max-width: 100%; border-radius: 6px; }
|
|
2031
|
+
.vesk-md hr { border: 0; height: 1px; background: #d0d7de; margin: 1.6em 0; }
|
|
2032
|
+
.vesk-md blockquote { margin: .8em 0; padding: .2em 1em; color: #57606a; border-left: .25em solid #d0d7de; }
|
|
2033
|
+
.vesk-md ul,.vesk-md ol { margin: .8em 0; padding-left: 2em; }
|
|
2034
|
+
.vesk-md ul { list-style: disc; } .vesk-md ul ul { list-style: circle; } .vesk-md ul ul ul { list-style: square; }
|
|
2035
|
+
.vesk-md ol { list-style: decimal; } .vesk-md ol ol { list-style: lower-alpha; }
|
|
2036
|
+
.vesk-md li { margin: .25em 0; }
|
|
2037
|
+
.vesk-md li.md-task { list-style: none; margin-left: -1.4em; display: flex; gap: .5em; align-items: baseline; }
|
|
2038
|
+
.vesk-md code:not(pre code) { background: rgba(175,184,193,.2); padding: .15em .35em; border-radius: 5px; font-family: ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; font-size: 85%; }
|
|
2039
|
+
.vesk-md .md-table-wrap { overflow-x: auto; margin: .8em 0; }
|
|
2040
|
+
.vesk-md table.md-table { border-collapse: collapse; width: max-content; max-width: 100%; font-size: .95em; }
|
|
2041
|
+
.vesk-md .md-table th,.vesk-md .md-table td { border: 1px solid #d0d7de; padding: 6px 13px; }
|
|
2042
|
+
.vesk-md .md-table th { background: #f6f8fa; font-weight: 650; }
|
|
2043
|
+
.vesk-md .md-table tr:nth-child(2n) td { background: #f6f8fa80; }
|
|
2044
|
+
.vesk-md .md-code { --md-code-bg: #f6f8fa; margin: 1em 0; border-radius: 8px; overflow: hidden; background: var(--md-code-bg); border: 1px solid #d1d9e0; }
|
|
2045
|
+
.vesk-md .md-code-bar { display: flex; justify-content: space-between; align-items: center; padding: 6px 12px; background: var(--md-code-bg); border-bottom: 1px solid #d1d9e0; }
|
|
2046
|
+
.vesk-md .md-code-lang { font-family: ui-monospace,monospace; font-size: 11px; letter-spacing: .04em; text-transform: uppercase; color: #59636e; }
|
|
2047
|
+
.vesk-md .md-copy { all: unset; cursor: pointer; font: 11px ui-monospace,monospace; color: #59636e; padding: 2px 8px; border-radius: 6px; }
|
|
2048
|
+
.vesk-md .md-copy:hover { color: #1f2328; background: #eaeef2; }
|
|
2049
|
+
.vesk-md .md-copy.md-copied { color: #1a7f37; }
|
|
2050
|
+
.vesk-md .md-code pre { margin: 0; overflow-x: auto; padding: 14px 16px; }
|
|
2051
|
+
.vesk-md .md-code code { font-family: ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; font-size: 13.5px; line-height: 1.55; color: var(--md-code-fg,#1f2328); background: none; padding: 0; }
|
|
2052
|
+
.vesk-md code[md-lines] { counter-reset: ln; display: block; }
|
|
2053
|
+
.vesk-md code[md-lines] .tok-line { display: block; counter-increment: ln; padding-left: 3.2em; position: relative; }
|
|
2054
|
+
.vesk-md code[md-lines] .tok-line::before { content: counter(ln); position: absolute; left: 0; width: 2.4em; text-align: right; color: #8c959f; user-select: none; }
|
|
2055
|
+
.tok-kw { color: #cf222e; } .tok-lit { color: #0550ae; } .tok-str { color: #0a3069; }
|
|
2056
|
+
.tok-num { color: #0550ae; } .tok-com { color: #59636e; font-style: italic; }
|
|
2057
|
+
.tok-fn { color: #8250df; } .tok-attr { color: #0550ae; } .tok-prop { color: #0550ae; }
|
|
2058
|
+
.tok-sel { color: #116329; } .tok-add { color: #116329; background: #dafbe1; display: inline-block; width: 100%; }
|
|
2059
|
+
.tok-del { color: #cf222e; background: #ffebe9; display: inline-block; width: 100%; }
|
|
2060
|
+
.vesk-md-dark { --md-code-bg: #0d1117; --md-code-fg: #c9d1d9; }
|
|
2061
|
+
.vesk-md-dark .md-code-bar { border-bottom-color: #30363d; }
|
|
2062
|
+
.vesk-md-dark .md-code-lang { color: #8b949e; }
|
|
2063
|
+
.vesk-md-dark .md-copy { color: #8b949e; }
|
|
2064
|
+
.vesk-md-dark .md-copy:hover { color: #c9d1d9; background: #30363d; }
|
|
2065
|
+
.vesk-md-dark .md-copy.md-copied { color: #3fb950; }
|
|
2066
|
+
.vesk-md-dark code[md-lines] .tok-line::before { color: #484f58; }
|
|
2067
|
+
.vesk-md-dark .tok-kw { color: #ff7b72; } .vesk-md-dark .tok-lit { color: #79c0ff; }
|
|
2068
|
+
.vesk-md-dark .tok-str { color: #a5d6ff; } .vesk-md-dark .tok-num { color: #f2cc60; }
|
|
2069
|
+
.vesk-md-dark .tok-com { color: #8b949e; font-style: italic; }
|
|
2070
|
+
.vesk-md-dark .tok-fn { color: #d2a8ff; } .vesk-md-dark .tok-attr { color: #79c0ff; }
|
|
2071
|
+
.vesk-md-dark .tok-prop { color: #79c0ff; } .vesk-md-dark .tok-sel { color: #7ee787; }
|
|
2072
|
+
.vesk-md-dark .tok-add { color: #aff5b4; background: rgba(46,160,67,.15); }
|
|
2073
|
+
.vesk-md-dark .tok-del { color: #ffdcd7; background: rgba(248,81,73,.15); }
|
|
2074
|
+
`;
|
|
2075
|
+
// ── Public entry points ──────────────────────────────────────
|
|
2076
|
+
/**
|
|
2077
|
+
* Renders markdown to HTML with default (legacy) options — output shape is
|
|
2078
|
+
* stable for existing consumers. Pass options for advanced features:
|
|
2079
|
+
* `{ highlight, chrome, lineNumbers, copy, ids, autolink, hardBreaks }`.
|
|
2080
|
+
*/
|
|
2081
|
+
export function renderMarkdown(md, options = {}) {
|
|
2082
|
+
return renderMarkdownEx(md, options).html;
|
|
2083
|
+
}
|
|
2084
|
+
/** Like renderMarkdown, but also returns the raw-HTML passthrough warnings. */
|
|
2085
|
+
export function renderMarkdownEx(md, options = {}) {
|
|
2086
|
+
const ctx = ctxOf(options);
|
|
2087
|
+
// Normalize CRLF/CR so pasted content parses identically to typed input.
|
|
2088
|
+
const normalized = String(md == null ? '' : md).split('\r\n').join('\n').split('\r').join('\n');
|
|
2089
|
+
const extracted = extractRefDefinitions(normalized.split('\n'));
|
|
2090
|
+
ctx.refs = extracted.defs;
|
|
2091
|
+
const html = renderBlocks(parseBlocks(extracted.lines), ctx);
|
|
2092
|
+
return { html, warnings: ctx.warnings };
|
|
2093
|
+
}
|
|
2094
|
+
// ── Global Md policy (configured from vesk.config.ts `md` key) ────
|
|
2095
|
+
let __globalMode = 'escape';
|
|
2096
|
+
let __globalAllowTags = null;
|
|
2097
|
+
/**
|
|
2098
|
+
* Sets the process-wide default raw-HTML policy for `<Md>`. Called by the CLI
|
|
2099
|
+
* and servers with the `md` key from vesk.config.ts. Individual `<Md>` usages
|
|
2100
|
+
* can still override per-instance via the `html` / `allowTags` props.
|
|
2101
|
+
*/
|
|
2102
|
+
export function configureMd(policy) {
|
|
2103
|
+
if (!policy || typeof policy !== 'object')
|
|
2104
|
+
return;
|
|
2105
|
+
if (policy.html)
|
|
2106
|
+
__globalMode = policy.html;
|
|
2107
|
+
if (policy.allowTags) {
|
|
2108
|
+
__globalAllowTags = policy.allowTags
|
|
2109
|
+
.map((t) => String(t).toLowerCase().replace(/[^a-z0-9-]/g, ''))
|
|
2110
|
+
.filter(Boolean);
|
|
2111
|
+
}
|
|
2112
|
+
}
|
|
2113
|
+
/** The effective process-wide policy (after configureMd). */
|
|
2114
|
+
export function getMdPolicy() {
|
|
2115
|
+
return { html: __globalMode, allowTags: (__globalAllowTags || MD_DEFAULT_ALLOW_TAGS).slice() };
|
|
2116
|
+
}
|
|
2117
|
+
// Bounded collector for build-time summaries (`vesk build` prints it).
|
|
2118
|
+
const __sessionWarnings = [];
|
|
2119
|
+
const __warnedKeys = new Set();
|
|
2120
|
+
/** Test hook: silence per-render console warnings without changing policy. */
|
|
2121
|
+
let __suppressMdConsoleWarnings = false;
|
|
2122
|
+
export function setMdConsoleWarnings(enabled) {
|
|
2123
|
+
__suppressMdConsoleWarnings = !enabled;
|
|
2124
|
+
}
|
|
2125
|
+
function rememberSessionWarnings(warnings) {
|
|
2126
|
+
for (const w of warnings) {
|
|
2127
|
+
if (__warnedKeys.has(w.tag))
|
|
2128
|
+
continue;
|
|
2129
|
+
__warnedKeys.add(w.tag);
|
|
2130
|
+
__sessionWarnings.push(w);
|
|
2131
|
+
if (__warnedKeys.size > 500)
|
|
2132
|
+
break; // bounded
|
|
2133
|
+
}
|
|
2134
|
+
}
|
|
2135
|
+
/** Drains the collected passthrough samples (for build-time summaries). */
|
|
2136
|
+
export function drainMdHtmlWarnings() {
|
|
2137
|
+
return __sessionWarnings.splice(0, __sessionWarnings.length);
|
|
2138
|
+
}
|
|
2139
|
+
function wireCopyHandlers(root) {
|
|
2140
|
+
root.addEventListener('click', (ev) => {
|
|
2141
|
+
const target = ev.target;
|
|
2142
|
+
if (!target)
|
|
2143
|
+
return;
|
|
2144
|
+
const btn = target.closest('[data-md-copy]');
|
|
2145
|
+
if (!btn)
|
|
2146
|
+
return;
|
|
2147
|
+
const wrap = btn.closest('.md-code');
|
|
2148
|
+
const code = wrap ? wrap.querySelector('pre > code') : null;
|
|
2149
|
+
const text = code ? (code.textContent || '') : '';
|
|
2150
|
+
const done = () => {
|
|
2151
|
+
btn.classList.add('md-copied');
|
|
2152
|
+
btn.textContent = 'Copied!';
|
|
2153
|
+
window.setTimeout(() => {
|
|
2154
|
+
btn.classList.remove('md-copied');
|
|
2155
|
+
btn.textContent = 'Copy';
|
|
2156
|
+
}, 1500);
|
|
2157
|
+
};
|
|
2158
|
+
try {
|
|
2159
|
+
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
|
|
2160
|
+
void navigator.clipboard.writeText(text).then(done, done);
|
|
2161
|
+
return;
|
|
2162
|
+
}
|
|
2163
|
+
}
|
|
2164
|
+
catch { /* fall through */ }
|
|
2165
|
+
try {
|
|
2166
|
+
const ta = document.createElement('textarea');
|
|
2167
|
+
ta.value = text;
|
|
2168
|
+
document.body.appendChild(ta);
|
|
2169
|
+
ta.select();
|
|
2170
|
+
document.execCommand('copy');
|
|
2171
|
+
document.body.removeChild(ta);
|
|
2172
|
+
done();
|
|
2173
|
+
}
|
|
2174
|
+
catch { /* clipboard unavailable */ }
|
|
2175
|
+
});
|
|
467
2176
|
}
|
|
468
2177
|
function mdIsSSR() {
|
|
469
2178
|
return typeof document === 'undefined';
|
|
470
2179
|
}
|
|
2180
|
+
function buildHtml(content, props) {
|
|
2181
|
+
// Per-instance props override the process-wide policy from vesk.config.ts.
|
|
2182
|
+
const global = getMdPolicy();
|
|
2183
|
+
const mode = props.html || global.html;
|
|
2184
|
+
const allowTags = Array.isArray(props.allowTags)
|
|
2185
|
+
? props.allowTags.map((t) => String(t).toLowerCase().replace(/[^a-z0-9-]/g, '')).filter(Boolean)
|
|
2186
|
+
: global.allowTags;
|
|
2187
|
+
const { html, warnings } = renderMarkdownEx(content, {
|
|
2188
|
+
highlight: props.highlight !== false,
|
|
2189
|
+
chrome: props.highlight !== false,
|
|
2190
|
+
lineNumbers: props.lineNumbers === true,
|
|
2191
|
+
copy: props.copy !== false,
|
|
2192
|
+
ids: props.ids !== false,
|
|
2193
|
+
autolink: props.autolink !== false,
|
|
2194
|
+
hardBreaks: props.hardBreaks === true,
|
|
2195
|
+
html: mode,
|
|
2196
|
+
allowTags,
|
|
2197
|
+
});
|
|
2198
|
+
if (warnings.length > 0 && mode !== 'escape') {
|
|
2199
|
+
rememberSessionWarnings(warnings);
|
|
2200
|
+
if (!__suppressMdConsoleWarnings) {
|
|
2201
|
+
const byTag = new Map();
|
|
2202
|
+
for (const w of warnings)
|
|
2203
|
+
byTag.set(w.tag, (byTag.get(w.tag) || 0) + 1);
|
|
2204
|
+
const tags = [...byTag.entries()].map(([t, n]) => `<${t}>×${n}`).join(', ');
|
|
2205
|
+
console.warn(`[vesk-md] ${mode === 'allow' ? 'raw HTML rendered verbatim' : 'allowlisted HTML rendered'} — ${tags}. ` +
|
|
2206
|
+
'Only use with trusted markdown content. Policy source: md.html in vesk.config.ts (or <Md html> prop).');
|
|
2207
|
+
}
|
|
2208
|
+
}
|
|
2209
|
+
if (props.css === true) {
|
|
2210
|
+
return `<style data-vesk-md-css>${MD_BASE_CSS}</style>${html}`;
|
|
2211
|
+
}
|
|
2212
|
+
if (typeof props.css === 'string' && props.css !== '') {
|
|
2213
|
+
return `<style data-vesk-md-css>${props.css}</style>${html}`;
|
|
2214
|
+
}
|
|
2215
|
+
return html;
|
|
2216
|
+
}
|
|
471
2217
|
/**
|
|
472
2218
|
* Renders markdown as HTML. On the server it returns an HTML string; on the
|
|
473
2219
|
* client it claims the SSR-rendered subtree during hydration (or builds a
|
|
474
|
-
* fresh wrapper element for SPA navigation).
|
|
2220
|
+
* fresh wrapper element for SPA navigation). Advanced rendering (highlighted
|
|
2221
|
+
* code with chrome, heading anchors, autolinks) is on by default; tune with
|
|
2222
|
+
* the css / lineNumbers / copy / highlight / ids / autolink / hardBreaks props.
|
|
475
2223
|
*/
|
|
476
2224
|
export function Md(props, _registry, hydrate) {
|
|
477
|
-
const
|
|
478
|
-
const
|
|
479
|
-
const
|
|
2225
|
+
const rawContent = props.content;
|
|
2226
|
+
const content = String(unwrapMaybeCell(rawContent) ?? '');
|
|
2227
|
+
const html = buildHtml(content, props);
|
|
2228
|
+
const classNameRaw = props.className != null ? String(props.className) : props.class != null ? String(props.class) : '';
|
|
2229
|
+
const themeClass = props.theme === 'dark' ? ' vesk-md-dark' : '';
|
|
2230
|
+
const className = `vesk-md${themeClass}${classNameRaw ? ' ' + classNameRaw : ''}`;
|
|
480
2231
|
const style = props.style != null ? String(props.style) : '';
|
|
2232
|
+
// Component-level code defaults; fence-level bg=/fg= emit the same
|
|
2233
|
+
// custom properties on the block, overriding these by inheritance.
|
|
2234
|
+
const wrapperParts = [];
|
|
2235
|
+
const propBg = safeColorValue(String(props.codeBg ?? ''));
|
|
2236
|
+
if (propBg)
|
|
2237
|
+
wrapperParts.push(`--md-code-bg:${propBg === 'none' ? 'transparent' : propBg}`);
|
|
2238
|
+
const propFg = safeColorValue(String(props.codeFg ?? ''));
|
|
2239
|
+
if (propFg && propFg !== 'none')
|
|
2240
|
+
wrapperParts.push(`--md-code-fg:${propFg}`);
|
|
2241
|
+
const wrapperStyle = wrapperParts.length > 0 ? escapeHtml(wrapperParts.join(';')) : '';
|
|
2242
|
+
// A tracked `content` cell re-renders the markdown reactively on the
|
|
2243
|
+
// client (per-keystroke live editing works without any extra wiring).
|
|
2244
|
+
const reactive = isCell(rawContent);
|
|
481
2245
|
if (mdIsSSR()) {
|
|
482
2246
|
const attrs = className ? ` class="${escapeHtml(className)}"` : '';
|
|
483
|
-
const styleAttr = style ? ` style="${style.split('"').join('"')}"` : '';
|
|
2247
|
+
const styleAttr = style || wrapperStyle ? ` style="${[wrapperStyle, style.split('"').join('"')].filter(Boolean).join(';')}"` : '';
|
|
484
2248
|
return `<div${attrs}${styleAttr}>${html}</div>`;
|
|
485
2249
|
}
|
|
486
2250
|
if (hydrate && typeof hydrate.nextElement === 'function') {
|
|
@@ -491,17 +2255,32 @@ export function Md(props, _registry, hydrate) {
|
|
|
491
2255
|
el = existing;
|
|
492
2256
|
}
|
|
493
2257
|
el.innerHTML = html;
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
2258
|
+
el.className = className;
|
|
2259
|
+
el.style.cssText = [wrapperStyle, style].filter(Boolean).join(';');
|
|
2260
|
+
wireCopyHandlers(el);
|
|
2261
|
+
if (reactive)
|
|
2262
|
+
subscribeContent(el, rawContent, props);
|
|
2263
|
+
// When the element came from SSR it is already in the document —
|
|
2264
|
+
// return an empty fragment. When the walker handed us a FRESH
|
|
2265
|
+
// element (dynamic branch that had no SSR markup), hand the element
|
|
2266
|
+
// back so the caller places it.
|
|
2267
|
+
if (el.parentNode)
|
|
2268
|
+
return document.createDocumentFragment();
|
|
2269
|
+
return el;
|
|
499
2270
|
}
|
|
500
2271
|
const div = document.createElement('div');
|
|
501
2272
|
div.innerHTML = html;
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
2273
|
+
div.className = className;
|
|
2274
|
+
div.style.cssText = [wrapperStyle, style].filter(Boolean).join(';');
|
|
2275
|
+
wireCopyHandlers(div);
|
|
2276
|
+
if (reactive)
|
|
2277
|
+
subscribeContent(div, rawContent, props);
|
|
506
2278
|
return div;
|
|
507
2279
|
}
|
|
2280
|
+
function subscribeContent(el, rawContent, props) {
|
|
2281
|
+
effect(() => {
|
|
2282
|
+
const value = String(unwrapMaybeCell(rawContent) ?? '');
|
|
2283
|
+
el.innerHTML = buildHtml(value, props);
|
|
2284
|
+
wireCopyHandlers(el);
|
|
2285
|
+
});
|
|
2286
|
+
}
|