@vesk/runtime 0.1.14 → 0.2.9

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/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, lang: t.slice(n).trim() };
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.lang });
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
- const itemLines = [l.slice(m.contentStart)];
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 = [trimmed];
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(t);
504
+ paraLines.push(l.slice(leadingSpaces(l)));
253
505
  i++;
254
506
  }
255
- blocks.push({ type: 'paragraph', text: paraLines.join(' ') });
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
- function renderListItem(item) {
260
- const blocks = item.blocks;
261
- if (blocks.length === 1 && blocks[0].type === 'paragraph') {
262
- return `<li>${renderInline(blocks[0].text)}</li>`;
263
- }
264
- const parts = [];
265
- for (let i = 0; i < blocks.length; i++) {
266
- const b = blocks[i];
267
- if (i === 0 && b.type === 'paragraph') {
268
- parts.push(renderInline(b.text));
269
- }
270
- else {
271
- parts.push(renderBlock(b));
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 `<li>${parts.join('\n')}</li>`;
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 renderBlocks(blocks) {
301
- const out = [];
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
- function parseLink(text, start) {
308
- let i = start;
309
- let isImage = false;
310
- if (text[i] === '!' && text[i + 1] === '[') {
311
- isImage = true;
312
- i++;
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 labelEnd = -1;
319
- let j = i;
320
- while (j < text.length) {
321
- if (text[j] === '[')
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 (text[j] === ']') {
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 (labelEnd === -1)
572
+ if (j >= line.length || line[j] !== ']')
333
573
  return null;
334
- const label = text.slice(i, labelEnd);
335
- j = labelEnd + 1;
336
- if (text[j] !== '(')
574
+ const label = line.slice(1, j);
575
+ if (label.trim() === '')
337
576
  return null;
338
- j++;
339
- while (j < text.length && text[j] === ' ')
340
- j++;
341
- const urlStart = j;
342
- depth = 1;
343
- let urlEnd = -1;
344
- while (j < text.length) {
345
- if (text[j] === '(')
346
- depth++;
347
- else if (text[j] === ')') {
348
- depth--;
349
- if (depth === 0) {
350
- urlEnd = j;
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
- j++;
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
- if (urlEnd === -1)
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
- function renderInline(text) {
367
- let out = '';
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 < text.length) {
370
- const ch = text[i];
371
- if (ch === '\\' && i + 1 < text.length) {
372
- out += escapeHtml(text[i + 1]);
373
- i += 2;
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
- continue;
392
- }
393
- if (ch === '~' && text[i + 1] === '~') {
394
- const close = findDelimiter(text, i + 2, '~~');
395
- if (close !== -1 && close > i + 2) {
396
- out += `<del>${renderInline(text.slice(i + 2, close))}</del>`;
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
- if (ch === '*' || ch === '_') {
405
- const next = text[i + 1];
406
- if (next !== ' ' && next !== '\t' && next !== undefined) {
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
- if (ch === '[' || (ch === '!' && text[i + 1] === '[')) {
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
- function tryEmphasis(text, start) {
437
- const ch = text[start];
438
- if (text[start + 1] === ch) {
439
- let j = start + 2;
440
- while (j < text.length) {
441
- if (text[j] === ch && text[j + 1] === ch) {
442
- if (j > start + 2 && (text[j - 1] === ' ' || text[j - 1] === '\t')) {
443
- j += 2;
444
- continue;
445
- }
446
- return { html: `<strong>${renderInline(text.slice(start + 2, j))}</strong>`, end: j + 2 };
447
- }
448
- j++;
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
- let j = start + 1;
452
- while (j < text.length) {
453
- if (text[j] === ch) {
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
- return null;
463
- }
464
- export function renderMarkdown(md) {
465
- const lines = String(md == null ? '' : md).split('\n');
466
- return renderBlocks(parseBlocks(lines));
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
+ * `&copy;` renders © while `&lt;` 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('&quot;').split('&').join('&amp;');
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 content = props.content == null ? '' : String(props.content);
478
- const html = renderMarkdown(content);
479
- const className = props.className != null ? String(props.className) : props.class != null ? String(props.class) : '';
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('&quot;')}"` : '';
2247
+ const styleAttr = style || wrapperStyle ? ` style="${[wrapperStyle, style.split('"').join('&quot;')].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
- if (className)
495
- el.className = className;
496
- if (style)
497
- el.style.cssText = style;
498
- return document.createDocumentFragment();
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
- if (className)
503
- div.className = className;
504
- if (style)
505
- div.style.cssText = style;
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
+ }