@vectojs/markdown 0.18.1 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -554,11 +554,13 @@ var DEFAULT_THEME = {
554
554
  syntaxStringColor: "#86efac",
555
555
  syntaxCommentColor: "#64748b",
556
556
  syntaxNumberColor: "#fbbf24",
557
+ codeLangColor: "#64748b",
557
558
  bodyFont: "Inter, system-ui, sans-serif",
558
559
  codeFont: 'ui-monospace, "JetBrains Mono", "Fira Code", monospace',
559
560
  fontSize: 16,
560
561
  headingSizes: [32, 28, 24, 20, 18, 16],
561
562
  codeFontSize: 15,
563
+ codeLangFontSize: 12,
562
564
  tableFontSize: 14,
563
565
  footnoteMarkerScale: 0.75,
564
566
  subscriptScale: 0.75,
@@ -593,6 +595,12 @@ function resolveTheme(theme) {
593
595
  if (theme?.footnoteColor === void 0) {
594
596
  merged.footnoteColor = merged.linkColor;
595
597
  }
598
+ if (theme?.codeLangColor === void 0) {
599
+ merged.codeLangColor = merged.syntaxCommentColor;
600
+ }
601
+ if (theme?.codeLangFontSize === void 0) {
602
+ merged.codeLangFontSize = Math.max(1, merged.codeFontSize - 3);
603
+ }
596
604
  return merged;
597
605
  }
598
606
  function headingSize(theme, depth) {
@@ -932,14 +940,215 @@ var KEYWORD_SETS = {
932
940
  "static"
933
941
  ])
934
942
  };
943
+ KEYWORD_SETS["bash"] = /* @__PURE__ */ new Set([
944
+ // Shell builtins and control words. Deliberately not the whole of coreutils:
945
+ // a keyword table that includes every command name colors an entire script
946
+ // uniformly, which reads worse than coloring only the control flow.
947
+ "if",
948
+ "then",
949
+ "else",
950
+ "elif",
951
+ "fi",
952
+ "for",
953
+ "while",
954
+ "until",
955
+ "do",
956
+ "done",
957
+ "case",
958
+ "esac",
959
+ "in",
960
+ "function",
961
+ "return",
962
+ "exit",
963
+ "break",
964
+ "continue",
965
+ "local",
966
+ "export",
967
+ "readonly",
968
+ "declare",
969
+ "unset",
970
+ "shift",
971
+ "source",
972
+ "alias",
973
+ "set",
974
+ "trap",
975
+ "echo",
976
+ "cd",
977
+ "sudo",
978
+ "true",
979
+ "false"
980
+ ]);
981
+ KEYWORD_SETS["json"] = /* @__PURE__ */ new Set(["true", "false", "null"]);
982
+ KEYWORD_SETS["css"] = /* @__PURE__ */ new Set([
983
+ "important",
984
+ "inherit",
985
+ "initial",
986
+ "unset",
987
+ "revert",
988
+ "auto",
989
+ "none",
990
+ "var",
991
+ "calc"
992
+ ]);
993
+ KEYWORD_SETS["html"] = /* @__PURE__ */ new Set([
994
+ // Tag names are the meaningful tokens a reader scans for. The tokenizer is
995
+ // word-based, so `<div>` yields the word `div`.
996
+ "html",
997
+ "head",
998
+ "body",
999
+ "title",
1000
+ "meta",
1001
+ "link",
1002
+ "script",
1003
+ "style",
1004
+ "div",
1005
+ "span",
1006
+ "p",
1007
+ "a",
1008
+ "img",
1009
+ "ul",
1010
+ "ol",
1011
+ "li",
1012
+ "table",
1013
+ "tr",
1014
+ "td",
1015
+ "th",
1016
+ "form",
1017
+ "input",
1018
+ "button",
1019
+ "label",
1020
+ "select",
1021
+ "option",
1022
+ "textarea",
1023
+ "header",
1024
+ "footer",
1025
+ "nav",
1026
+ "main",
1027
+ "section",
1028
+ "article",
1029
+ "aside",
1030
+ "canvas",
1031
+ "svg",
1032
+ "template",
1033
+ "slot"
1034
+ ]);
935
1035
  KEYWORD_SETS["javascript"] = KEYWORD_SETS["js"];
936
1036
  KEYWORD_SETS["typescript"] = KEYWORD_SETS["ts"];
937
1037
  KEYWORD_SETS["python"] = KEYWORD_SETS["py"];
938
1038
  KEYWORD_SETS["rs"] = KEYWORD_SETS["rust"];
939
- function highlightLine(line, lang, theme) {
940
- const keywords = KEYWORD_SETS[lang];
941
- if (!keywords) {
942
- return [{ text: line, color: theme.codeColor }];
1039
+ KEYWORD_SETS["jsx"] = KEYWORD_SETS["js"];
1040
+ KEYWORD_SETS["mjs"] = KEYWORD_SETS["js"];
1041
+ KEYWORD_SETS["cjs"] = KEYWORD_SETS["js"];
1042
+ KEYWORD_SETS["tsx"] = KEYWORD_SETS["ts"];
1043
+ KEYWORD_SETS["mts"] = KEYWORD_SETS["ts"];
1044
+ KEYWORD_SETS["cts"] = KEYWORD_SETS["ts"];
1045
+ KEYWORD_SETS["sh"] = KEYWORD_SETS["bash"];
1046
+ KEYWORD_SETS["zsh"] = KEYWORD_SETS["bash"];
1047
+ KEYWORD_SETS["shell"] = KEYWORD_SETS["bash"];
1048
+ KEYWORD_SETS["console"] = KEYWORD_SETS["bash"];
1049
+ KEYWORD_SETS["jsonc"] = KEYWORD_SETS["json"];
1050
+ KEYWORD_SETS["json5"] = KEYWORD_SETS["json"];
1051
+ KEYWORD_SETS["scss"] = KEYWORD_SETS["css"];
1052
+ KEYWORD_SETS["sass"] = KEYWORD_SETS["css"];
1053
+ KEYWORD_SETS["less"] = KEYWORD_SETS["css"];
1054
+ KEYWORD_SETS["vue"] = KEYWORD_SETS["html"];
1055
+ KEYWORD_SETS["svelte"] = KEYWORD_SETS["html"];
1056
+ KEYWORD_SETS["xml"] = KEYWORD_SETS["html"];
1057
+ KEYWORD_SETS["svg"] = KEYWORD_SETS["html"];
1058
+ var C_LIKE = {
1059
+ lineComments: ["//"],
1060
+ quotes: ['"', "'", "`"],
1061
+ numbers: true,
1062
+ blockComments: [["/*", "*/"]],
1063
+ // A JS/TS template literal spans lines. Listed here as well as in `quotes`:
1064
+ // `quotes` handles the common single-line case, and this carries the rest.
1065
+ multilineStrings: ["`"]
1066
+ };
1067
+ var HASH_COMMENT = {
1068
+ lineComments: ["#"],
1069
+ quotes: ['"', "'"],
1070
+ numbers: true
1071
+ };
1072
+ var LANGUAGE_SYNTAX = {
1073
+ js: C_LIKE,
1074
+ ts: C_LIKE,
1075
+ // A Python docstring is the language's block comment in practice, and it is
1076
+ // lexically a string, so it is carried as one rather than invented as a third
1077
+ // kind. Triple delimiters are listed before the single ones so the longest
1078
+ // match wins.
1079
+ py: { ...HASH_COMMENT, multilineStrings: ['"""', "'''"] },
1080
+ // Rust has `//` line comments AND `'` lifetimes. The unterminated-quote
1081
+ // fallback already keeps a lifetime from swallowing the line, so `'` stays
1082
+ // listed: `'a'` is a valid char literal and should color as a string.
1083
+ rust: C_LIKE,
1084
+ bash: HASH_COMMENT,
1085
+ // JSON has no comments and no single-quoted strings. JSONC does have `//`,
1086
+ // and is aliased separately below rather than sharing this entry.
1087
+ json: { lineComments: [], quotes: ['"'], numbers: true },
1088
+ // CSS has only block comments — which now span lines, so this entry claims
1089
+ // them. Numbers are everywhere in CSS and coloring them is most of the visible
1090
+ // benefit.
1091
+ css: {
1092
+ lineComments: [],
1093
+ quotes: ['"', "'"],
1094
+ numbers: true,
1095
+ blockComments: [["/*", "*/"]]
1096
+ },
1097
+ // Markup: no line comments, and numbers inside attribute values are noise
1098
+ // rather than signal. An SGML comment spans lines like any other block form.
1099
+ html: {
1100
+ lineComments: [],
1101
+ quotes: ['"', "'"],
1102
+ numbers: false,
1103
+ blockComments: [["<!--", "-->"]]
1104
+ }
1105
+ };
1106
+ LANGUAGE_SYNTAX["javascript"] = LANGUAGE_SYNTAX["js"];
1107
+ LANGUAGE_SYNTAX["typescript"] = LANGUAGE_SYNTAX["ts"];
1108
+ LANGUAGE_SYNTAX["python"] = LANGUAGE_SYNTAX["py"];
1109
+ LANGUAGE_SYNTAX["rs"] = LANGUAGE_SYNTAX["rust"];
1110
+ LANGUAGE_SYNTAX["jsx"] = LANGUAGE_SYNTAX["js"];
1111
+ LANGUAGE_SYNTAX["mjs"] = LANGUAGE_SYNTAX["js"];
1112
+ LANGUAGE_SYNTAX["cjs"] = LANGUAGE_SYNTAX["js"];
1113
+ LANGUAGE_SYNTAX["tsx"] = LANGUAGE_SYNTAX["ts"];
1114
+ LANGUAGE_SYNTAX["mts"] = LANGUAGE_SYNTAX["ts"];
1115
+ LANGUAGE_SYNTAX["cts"] = LANGUAGE_SYNTAX["ts"];
1116
+ LANGUAGE_SYNTAX["sh"] = LANGUAGE_SYNTAX["bash"];
1117
+ LANGUAGE_SYNTAX["zsh"] = LANGUAGE_SYNTAX["bash"];
1118
+ LANGUAGE_SYNTAX["shell"] = LANGUAGE_SYNTAX["bash"];
1119
+ LANGUAGE_SYNTAX["console"] = LANGUAGE_SYNTAX["bash"];
1120
+ LANGUAGE_SYNTAX["yaml"] = HASH_COMMENT;
1121
+ LANGUAGE_SYNTAX["yml"] = HASH_COMMENT;
1122
+ LANGUAGE_SYNTAX["toml"] = HASH_COMMENT;
1123
+ LANGUAGE_SYNTAX["ini"] = HASH_COMMENT;
1124
+ LANGUAGE_SYNTAX["dockerfile"] = HASH_COMMENT;
1125
+ LANGUAGE_SYNTAX["makefile"] = HASH_COMMENT;
1126
+ LANGUAGE_SYNTAX["make"] = HASH_COMMENT;
1127
+ LANGUAGE_SYNTAX["jsonc"] = { lineComments: ["//"], quotes: ['"'], numbers: true };
1128
+ LANGUAGE_SYNTAX["json5"] = { lineComments: ["//"], quotes: ['"', "'"], numbers: true };
1129
+ LANGUAGE_SYNTAX["scss"] = C_LIKE;
1130
+ LANGUAGE_SYNTAX["sass"] = C_LIKE;
1131
+ LANGUAGE_SYNTAX["less"] = C_LIKE;
1132
+ LANGUAGE_SYNTAX["glsl"] = C_LIKE;
1133
+ LANGUAGE_SYNTAX["c"] = C_LIKE;
1134
+ LANGUAGE_SYNTAX["cpp"] = C_LIKE;
1135
+ LANGUAGE_SYNTAX["go"] = C_LIKE;
1136
+ LANGUAGE_SYNTAX["java"] = C_LIKE;
1137
+ LANGUAGE_SYNTAX["kotlin"] = C_LIKE;
1138
+ LANGUAGE_SYNTAX["swift"] = C_LIKE;
1139
+ LANGUAGE_SYNTAX["vue"] = LANGUAGE_SYNTAX["html"];
1140
+ LANGUAGE_SYNTAX["svelte"] = LANGUAGE_SYNTAX["html"];
1141
+ LANGUAGE_SYNTAX["xml"] = LANGUAGE_SYNTAX["html"];
1142
+ LANGUAGE_SYNTAX["svg"] = LANGUAGE_SYNTAX["html"];
1143
+ function highlightedLanguages() {
1144
+ return [.../* @__PURE__ */ new Set([...Object.keys(LANGUAGE_SYNTAX), ...Object.keys(KEYWORD_SETS)])].sort();
1145
+ }
1146
+ function highlightLine(line, lang, theme, carry = null) {
1147
+ const key = lang.trim().toLowerCase().split(/[\s:,{]/)[0] ?? "";
1148
+ const keywords = KEYWORD_SETS[key];
1149
+ const syntax = LANGUAGE_SYNTAX[key];
1150
+ if (!keywords && !syntax) {
1151
+ return { segments: [{ text: line, color: theme.codeColor }], carry: null };
943
1152
  }
944
1153
  const segments = [];
945
1154
  const KEYWORD_COLOR = theme.syntaxKeywordColor;
@@ -954,19 +1163,63 @@ function highlightLine(line, lang, theme) {
954
1163
  buf = "";
955
1164
  }
956
1165
  };
1166
+ const lexical = syntax ?? C_LIKE;
1167
+ const findClose = (from, close, isString) => {
1168
+ let j = from;
1169
+ while (j < line.length) {
1170
+ if (isString && line[j] === "\\") {
1171
+ j += 2;
1172
+ continue;
1173
+ }
1174
+ if (line.startsWith(close, j)) return j + close.length;
1175
+ j++;
1176
+ }
1177
+ return -1;
1178
+ };
1179
+ if (carry) {
1180
+ const color = carry.kind === "comment" ? COMMENT_COLOR : STRING_COLOR;
1181
+ const end = findClose(0, carry.close, carry.kind === "string");
1182
+ if (end === -1) {
1183
+ if (line.length > 0) segments.push({ text: line, color });
1184
+ return { segments, carry };
1185
+ }
1186
+ segments.push({ text: line.slice(0, end), color });
1187
+ i = end;
1188
+ }
957
1189
  while (i < line.length) {
958
1190
  const ch = line[i];
959
- if (ch === "/" && line[i + 1] === "/") {
1191
+ const block = lexical.blockComments?.find(([open]) => line.startsWith(open, i));
1192
+ if (block) {
1193
+ const [open, close] = block;
960
1194
  flush(theme.codeColor);
961
- segments.push({ text: line.slice(i), color: COMMENT_COLOR });
962
- return segments;
1195
+ const end = findClose(i + open.length, close, false);
1196
+ if (end === -1) {
1197
+ segments.push({ text: line.slice(i), color: COMMENT_COLOR });
1198
+ return { segments, carry: { kind: "comment", close } };
1199
+ }
1200
+ segments.push({ text: line.slice(i, end), color: COMMENT_COLOR });
1201
+ i = end;
1202
+ continue;
963
1203
  }
964
- if (ch === "#" && (lang === "py" || lang === "python" || lang === "rust" || lang === "rs")) {
1204
+ const comment = lexical.lineComments.find((prefix) => line.startsWith(prefix, i));
1205
+ if (comment !== void 0) {
965
1206
  flush(theme.codeColor);
966
1207
  segments.push({ text: line.slice(i), color: COMMENT_COLOR });
967
- return segments;
1208
+ return { segments, carry: null };
968
1209
  }
969
- if (ch === '"' || ch === "'" || ch === "`") {
1210
+ const multi = lexical.multilineStrings?.find((delim) => line.startsWith(delim, i));
1211
+ if (multi !== void 0) {
1212
+ flush(theme.codeColor);
1213
+ const end = findClose(i + multi.length, multi, true);
1214
+ if (end === -1) {
1215
+ segments.push({ text: line.slice(i), color: STRING_COLOR });
1216
+ return { segments, carry: { kind: "string", close: multi } };
1217
+ }
1218
+ segments.push({ text: line.slice(i, end), color: STRING_COLOR });
1219
+ i = end;
1220
+ continue;
1221
+ }
1222
+ if (lexical.quotes.includes(ch)) {
970
1223
  const quote = ch;
971
1224
  let j = i + 1;
972
1225
  let closed = false;
@@ -991,7 +1244,7 @@ function highlightLine(line, lang, theme) {
991
1244
  i++;
992
1245
  continue;
993
1246
  }
994
- if (/\d/.test(ch) && (i === 0 || /[\s(,=+\-*/<>[\]{}:;]/.test(line[i - 1]))) {
1247
+ if (lexical.numbers && /\d/.test(ch) && (i === 0 || /[\s(,=+\-*/<>[\]{}:;]/.test(line[i - 1]))) {
995
1248
  flush(theme.codeColor);
996
1249
  let j = i;
997
1250
  while (j < line.length && /[\d._xXa-fA-F]/.test(line[j])) j++;
@@ -1006,7 +1259,9 @@ function highlightLine(line, lang, theme) {
1006
1259
  const word = line.slice(i, j);
1007
1260
  segments.push({
1008
1261
  text: word,
1009
- color: keywords.has(word) ? KEYWORD_COLOR : theme.codeColor
1262
+ // A language may have lexical syntax but no keywords (plain YAML, TOML,
1263
+ // a Dockerfile). Those still get comments, strings and numbers.
1264
+ color: keywords?.has(word) ? KEYWORD_COLOR : theme.codeColor
1010
1265
  });
1011
1266
  i = j;
1012
1267
  continue;
@@ -1015,10 +1270,18 @@ function highlightLine(line, lang, theme) {
1015
1270
  i++;
1016
1271
  }
1017
1272
  flush(theme.codeColor);
1018
- return segments;
1273
+ return { segments, carry: null };
1019
1274
  }
1020
1275
  var CodeBlock = class extends UIComponent {
1021
1276
  lines;
1277
+ /**
1278
+ * Lexical state ENTERING each line, index-aligned with {@link lines}.
1279
+ *
1280
+ * Entering rather than leaving, so a streamed append can resume tokenizing at
1281
+ * the prefix-reuse boundary by reading one entry instead of re-scanning the
1282
+ * document for an unclosed block comment.
1283
+ */
1284
+ lineCarry = [];
1022
1285
  grid = null;
1023
1286
  /** Raw (unhighlighted) lines of the last build, for prefix reuse in buildLines. */
1024
1287
  rawLines = null;
@@ -1026,6 +1289,19 @@ var CodeBlock = class extends UIComponent {
1026
1289
  source;
1027
1290
  /** Bumped by {@link buildLines} and {@link setSelectable}; read by `Scene`. */
1028
1291
  contentEpoch = 0;
1292
+ /**
1293
+ * Horizontal scroll offset in local px, always in `[0, maxScrollX]`.
1294
+ *
1295
+ * Code does not wrap, so a line wider than the box would otherwise have an
1296
+ * unreachable tail. This offset is subtracted from BOTH the painted cell x and
1297
+ * the projected line x in the same frame — never one without the other, or the
1298
+ * DOM selection carriers detach from the glyphs they are supposed to cover
1299
+ * (the defect class `5cf7119` and `ee1de6f` fixed on the vertical axis).
1300
+ */
1301
+ scrollXValue = 0;
1302
+ /** Memoized widest prepared line, keyed by the grid identity it came from. */
1303
+ contentWidthGrid = null;
1304
+ contentWidthValue = 0;
1029
1305
  lang;
1030
1306
  theme;
1031
1307
  /**
@@ -1037,6 +1313,10 @@ var CodeBlock = class extends UIComponent {
1037
1313
  pad;
1038
1314
  codeFont;
1039
1315
  selectable;
1316
+ /** Whether the language header band is drawn. See {@link CodeBlockOptions.showLanguage}. */
1317
+ showLanguage;
1318
+ /** Font of the header label, resolved once from the theme. */
1319
+ langFont;
1040
1320
  /**
1041
1321
  * @param theme Any subset of {@link MarkdownTheme}, or the name of a built-in
1042
1322
  * preset (see {@link MarkdownThemePresetName}). Accepting a partial theme
@@ -1048,7 +1328,7 @@ var CodeBlock = class extends UIComponent {
1048
1328
  * be constructed directly with a preset name without going through
1049
1329
  * `Markdown`.
1050
1330
  */
1051
- constructor(code, lang, maxWidth, theme, selectable = true) {
1331
+ constructor(code, lang, maxWidth, theme, selectable = true, options = {}) {
1052
1332
  super();
1053
1333
  const resolved = resolvePresetTheme(theme);
1054
1334
  this.source = code;
@@ -1058,15 +1338,129 @@ var CodeBlock = class extends UIComponent {
1058
1338
  this.pad = resolved.codePadding;
1059
1339
  this.codeFont = `${resolved.codeFontSize}px ${resolved.codeFont}`;
1060
1340
  this.selectable = selectable;
1341
+ this.langFont = `${resolved.codeLangFontSize}px ${resolved.codeFont}`;
1342
+ this.showLanguage = options.showLanguage === true && this.languageLabel() !== "";
1061
1343
  this.lines = [];
1062
1344
  this.width = maxWidth;
1063
1345
  this.buildLines(code);
1346
+ this.on("wheel", (e) => {
1347
+ const max = this.maxScrollX;
1348
+ if (max <= 0) return;
1349
+ if (e.ctrlKey === true) return;
1350
+ const deltaMode = e.deltaMode ?? 0;
1351
+ let deltaX = e.deltaX ?? 0;
1352
+ let deltaY = e.deltaY ?? 0;
1353
+ if (deltaMode === 1) {
1354
+ deltaX *= 16;
1355
+ deltaY *= 16;
1356
+ } else if (deltaMode === 2) {
1357
+ deltaX *= this.width;
1358
+ deltaY *= this.height;
1359
+ }
1360
+ const horizontal = e.shiftKey === true ? deltaY || deltaX : deltaX;
1361
+ if (horizontal === 0) return;
1362
+ const before = this.scrollX;
1363
+ this.setScrollX(before + horizontal);
1364
+ if (this.scrollX !== before) e.nativeEvent?.preventDefault?.();
1365
+ });
1366
+ }
1367
+ /**
1368
+ * The language name shown in the header, or `''` when there is nothing to show.
1369
+ *
1370
+ * Normalized exactly as the highlighter normalizes its lookup key, so the label
1371
+ * and the colouring can never disagree about which language this is: a fence
1372
+ * may be written ` ```Bash ` or carry attributes (` ```ts title="a.ts" `), and
1373
+ * the label has to be the language, not the raw info string.
1374
+ *
1375
+ * Lowercased for the same reason `streamdown` lowercases its own
1376
+ * (`lib/code-block/header.tsx:15`): the fence's capitalization is incidental,
1377
+ * and a document mixing ` ```JS ` with ` ```js ` should not render two
1378
+ * different-looking labels for one language.
1379
+ */
1380
+ languageLabel() {
1381
+ return this.lang.trim().toLowerCase().split(/[\s:,{]/)[0] ?? "";
1382
+ }
1383
+ /**
1384
+ * Height in px of the header band, or `0` when it is off.
1385
+ *
1386
+ * The label sits in a band of its own rather than floating over the code,
1387
+ * because a translucent overlay above real glyphs is unreadable at small sizes
1388
+ * and would fight the horizontal scroll: the code slides under it, so any text
1389
+ * drawn on top would collide with a different token every frame.
1390
+ */
1391
+ headerHeight() {
1392
+ if (!this.showLanguage) return 0;
1393
+ return this.theme.codeLangFontSize + Math.round(this.pad * 0.75);
1394
+ }
1395
+ /**
1396
+ * Local y of the first line of code.
1397
+ *
1398
+ * Everything that positions a row — the painter, the projection, the grid's
1399
+ * own origin — goes through this, so the header offset cannot be applied to
1400
+ * one and forgotten on another. That class of mismatch is exactly what
1401
+ * detaches selection carriers from the glyphs they cover.
1402
+ */
1403
+ contentTop() {
1404
+ return this.headerHeight() + this.pad;
1405
+ }
1406
+ /**
1407
+ * Current horizontal scroll offset in local px, clamped to what the content
1408
+ * currently allows.
1409
+ *
1410
+ * Clamped on READ, not only on write, because `setWidth()` may shrink the box
1411
+ * after a scroll and is contractually forbidden from rebuilding anything. Both
1412
+ * the painter and the projection read through here, which is what keeps the
1413
+ * glyphs and the selection carriers on the same offset within a frame.
1414
+ */
1415
+ get scrollX() {
1416
+ return Math.min(this.scrollXValue, this.maxScrollX);
1417
+ }
1418
+ /**
1419
+ * Widest line's overflow past the padded box, i.e. the maximum useful
1420
+ * {@link scrollX}. `0` when every line already fits.
1421
+ */
1422
+ get maxScrollX() {
1423
+ return Math.max(0, this.contentWidth() - (this.width - this.pad * 2));
1424
+ }
1425
+ /**
1426
+ * Widest prepared line, memoized against the grid that produced it.
1427
+ *
1428
+ * Read by {@link scrollX}, which both `render()` and `getContentProjection()`
1429
+ * call every synced frame, so an O(lines) scan here would be an O(document) cost
1430
+ * per frame on a long block — the exact shape the per-line projection window
1431
+ * exists to avoid. The grid is rebuilt only when the content changes, so the
1432
+ * cache key is identity of the grid object.
1433
+ */
1434
+ contentWidth() {
1435
+ const grid = this.ensureGrid();
1436
+ if (this.contentWidthGrid === grid) return this.contentWidthValue;
1437
+ let widest = 0;
1438
+ for (const line of grid.lines) {
1439
+ if (line.width > widest) widest = line.width;
1440
+ }
1441
+ this.contentWidthGrid = grid;
1442
+ this.contentWidthValue = widest;
1443
+ return widest;
1444
+ }
1445
+ /**
1446
+ * Scroll horizontally to `x`, clamped to `[0, maxScrollX]`.
1447
+ *
1448
+ * @returns `this` for chaining.
1449
+ */
1450
+ setScrollX(x) {
1451
+ const next = Math.max(0, Math.min(this.maxScrollX, x));
1452
+ if (next === this.scrollXValue) return this;
1453
+ this.scrollXValue = next;
1454
+ this.contentEpoch++;
1455
+ this.scene?.markDirty();
1456
+ return this;
1064
1457
  }
1065
1458
  /** Re-parse code content (e.g. for live editing). */
1066
1459
  setCode(code, lang) {
1067
1460
  if (lang !== void 0) this.lang = lang;
1068
1461
  this.source = code;
1069
1462
  this.buildLines(code);
1463
+ this.scrollXValue = Math.min(this.scrollXValue, this.maxScrollX);
1070
1464
  this.scene?.markDirty();
1071
1465
  return this;
1072
1466
  }
@@ -1086,9 +1480,13 @@ var CodeBlock = class extends UIComponent {
1086
1480
  * Deliberately does **not** rebuild the grid or the highlight, because code does
1087
1481
  * not reflow: lines are placed on a fixed monospace grid at `col × cellWidth` and
1088
1482
  * a long line overflows rather than wrapping, so `height` is a function of line
1089
- * *count* alone. The width only sizes the rounded background. Anything that would
1090
- * change the glyph geometry — the source, the language, the font — goes through
1091
- * {@link setCode} and invalidates the grid there.
1483
+ * *count* alone. The width sizes the rounded background and the clip. Anything
1484
+ * that would change the glyph geometry — the source, the language, the font —
1485
+ * goes through {@link setCode} and invalidates the grid there.
1486
+ *
1487
+ * A narrower box can leave {@link scrollX} past the new end of travel. That is
1488
+ * resolved by clamping on read rather than by adjusting anything here, so this
1489
+ * method keeps costing nothing.
1092
1490
  *
1093
1491
  * @returns `this` for chaining.
1094
1492
  */
@@ -1102,16 +1500,21 @@ var CodeBlock = class extends UIComponent {
1102
1500
  getContentProjection(hint) {
1103
1501
  if (!this.source) return null;
1104
1502
  const grid = this.ensureGrid();
1503
+ const scrollX = this.scrollX;
1105
1504
  const rows = [];
1106
1505
  rows.length = grid.lines.length;
1107
1506
  for (let row = 0; row < grid.lines.length; row++) {
1108
1507
  const line = grid.lines[row];
1109
- const y = this.pad + row * this.lineH;
1508
+ const y = this.contentTop() + row * this.lineH;
1110
1509
  if (!contentLineInHint(hint, y, this.lineH)) continue;
1111
1510
  rows[row] = {
1112
1511
  text: this.source.slice(line.sourceStart, line.sourceEnd),
1113
1512
  separatorAfter: this.source.slice(line.sourceEnd, line.nextSourceStart) || void 0,
1114
- x: this.pad,
1513
+ // The SAME offset `render()` subtracts, read through the same clamping
1514
+ // accessor. Cell carriers are `position: relative` inside this `absolute`
1515
+ // line box, so shifting the line's x translates every cell of the line
1516
+ // rigidly and selection stays over the glyphs.
1517
+ x: this.pad - scrollX,
1115
1518
  y,
1116
1519
  baseline: this.lineH * 0.75,
1117
1520
  font: this.codeFont,
@@ -1131,6 +1534,12 @@ var CodeBlock = class extends UIComponent {
1131
1534
  // render() draws cell-by-cell (no ligatures can form); the DOM copy
1132
1535
  // must not ligate either or Firefox selection geometry drifts.
1133
1536
  ligatures: "none",
1537
+ // `render()` clips the glyph pass to this box, so the DOM copy must too.
1538
+ // A line wider than the box otherwise projects carriers past the entity,
1539
+ // and the browser paints their selection highlight over whatever is drawn
1540
+ // beside the block — measured 1580px of carrier against a 1566px viewport
1541
+ // on a real page, the highlight running through the prose to its right.
1542
+ clipToBounds: true,
1134
1543
  grid
1135
1544
  };
1136
1545
  }
@@ -1145,6 +1554,11 @@ var CodeBlock = class extends UIComponent {
1145
1554
  *
1146
1555
  * The last previously-seen line is deliberately NOT reused: a chunk usually
1147
1556
  * lands mid-line, so that line's text (and therefore its tokenization) changes.
1557
+ *
1558
+ * Prefix reuse survives multi-line constructs because {@link lineCarry} records
1559
+ * the state ENTERING each line, so resuming at the reuse boundary needs no
1560
+ * rescan: a carried state is a pure function of the preceding text, and that
1561
+ * text is byte-identical over the reused prefix by construction.
1148
1562
  */
1149
1563
  buildLines(code) {
1150
1564
  this.contentEpoch++;
@@ -1155,18 +1569,20 @@ var CodeBlock = class extends UIComponent {
1155
1569
  const limit = Math.min(previous.length - 1, rawLines.length);
1156
1570
  while (reusable < limit && previous[reusable] === rawLines[reusable]) reusable++;
1157
1571
  }
1158
- if (reusable > 0) {
1159
- const next = this.lines.slice(0, reusable);
1160
- for (let i = reusable; i < rawLines.length; i++) {
1161
- next.push(highlightLine(rawLines[i], this.lang, this.theme));
1162
- }
1163
- this.lines = next;
1164
- } else {
1165
- this.lines = rawLines.map((l) => highlightLine(l, this.lang, this.theme));
1572
+ const lines = reusable > 0 ? this.lines.slice(0, reusable) : [];
1573
+ const carries = reusable > 0 ? this.lineCarry.slice(0, reusable) : [];
1574
+ let carry = reusable > 0 ? this.lineCarry[reusable] ?? null : null;
1575
+ for (let i = reusable; i < rawLines.length; i++) {
1576
+ carries.push(carry);
1577
+ const result = highlightLine(rawLines[i], this.lang, this.theme, carry);
1578
+ lines.push(result.segments);
1579
+ carry = result.carry;
1166
1580
  }
1581
+ this.lines = lines;
1582
+ this.lineCarry = carries;
1167
1583
  this.rawLines = rawLines;
1168
1584
  this.grid = null;
1169
- this.height = this.pad * 2 + rawLines.length * this.lineH;
1585
+ this.height = this.contentTop() + this.pad + rawLines.length * this.lineH;
1170
1586
  }
1171
1587
  ensureGrid() {
1172
1588
  const cellWidth = this.cellWidth || Math.max(1, measureText("M", this.codeFont));
@@ -1180,7 +1596,15 @@ var CodeBlock = class extends UIComponent {
1180
1596
  }
1181
1597
  return this.grid;
1182
1598
  }
1183
- /** Code blocks are decorative — not interactive. */
1599
+ /**
1600
+ * Not hit-testable, and deliberately still not `interactive`, even though the
1601
+ * block now consumes wheel events to scroll.
1602
+ *
1603
+ * The wheel arrives from the content-projection div rather than from canvas
1604
+ * hit-testing, so no a11y shadow node is needed. Creating one would place a
1605
+ * `pointer-events: auto` element above the transparent text mirror and swallow
1606
+ * the mousedown that starts a native drag-selection.
1607
+ */
1184
1608
  isPointInside() {
1185
1609
  return false;
1186
1610
  }
@@ -1192,8 +1616,25 @@ var CodeBlock = class extends UIComponent {
1192
1616
  const atlas = codeGlyphAtlas(r);
1193
1617
  const atlasSource = atlas?.source ?? null;
1194
1618
  const blit = atlas ? r.drawImageRect : void 0;
1619
+ const header = this.headerHeight();
1620
+ if (header > 0) {
1621
+ r.fillText(
1622
+ this.languageLabel(),
1623
+ this.pad,
1624
+ // Vertically centred in the band by its own cap height rather than by
1625
+ // font size: `fillText` takes a baseline, so centring the em box would
1626
+ // sit the visible letterforms low. 0.7 of the label size below the band's
1627
+ // centre line is where a lowercase-plus-cap run reads as centred.
1628
+ (header + this.theme.codeLangFontSize * 0.7) / 2,
1629
+ this.langFont,
1630
+ this.theme.codeLangColor
1631
+ );
1632
+ }
1633
+ r.save();
1634
+ r.clip(0, header, this.width, this.height - header);
1635
+ const scrollX = this.scrollX;
1195
1636
  for (let row = 0; row < grid.lines.length; row++) {
1196
- const yBaseline = this.pad + row * this.lineH + this.lineH * 0.75;
1637
+ const yBaseline = this.contentTop() + row * this.lineH + this.lineH * 0.75;
1197
1638
  const segments = this.lines[row];
1198
1639
  let segmentIndex = 0;
1199
1640
  let segmentEnd = segments[0]?.text.length ?? 0;
@@ -1207,7 +1648,8 @@ var CodeBlock = class extends UIComponent {
1207
1648
  const sourceText = this.source.slice(cell.sourceStart, cell.sourceEnd);
1208
1649
  if (cell.advance <= 0 || sourceText === " " || sourceText === " ") continue;
1209
1650
  const color = segments[segmentIndex]?.color ?? this.theme.codeColor;
1210
- const x = this.pad + cell.x;
1651
+ const x = this.pad + cell.x - scrollX;
1652
+ if (x + cell.advance < 0 || x > this.width) continue;
1211
1653
  if (blit && atlas) {
1212
1654
  const slot = atlas.get(this.codeFont, color, cell.glyph);
1213
1655
  const src = atlasSource ?? atlas.source;
@@ -1230,6 +1672,7 @@ var CodeBlock = class extends UIComponent {
1230
1672
  r.fillText(cell.glyph, x, yBaseline, this.codeFont, color);
1231
1673
  }
1232
1674
  }
1675
+ r.restore();
1233
1676
  }
1234
1677
  };
1235
1678
  var codeAtlases = /* @__PURE__ */ new Map();
@@ -2481,6 +2924,20 @@ function defaultSaveFile(filename, content, mimeType) {
2481
2924
  doc.body.removeChild(anchor);
2482
2925
  URL.revokeObjectURL(url);
2483
2926
  }
2927
+ function resolveBlockAffordanceConfig(config = {}) {
2928
+ return {
2929
+ copy: config.copy ?? true,
2930
+ download: config.download ?? true,
2931
+ labels: {
2932
+ copyCode: config.labels?.copyCode ?? "Copy code",
2933
+ downloadCode: config.labels?.downloadCode ?? "Download code",
2934
+ copyTable: config.labels?.copyTable ?? "Copy table",
2935
+ downloadTable: config.labels?.downloadTable ?? "Download table",
2936
+ copied: config.labels?.copied ?? "Copied",
2937
+ saved: config.labels?.saved ?? "Saved"
2938
+ }
2939
+ };
2940
+ }
2484
2941
  var BlockAffordanceButton = class _BlockAffordanceButton extends Button {
2485
2942
  constructor(label, successLabel, act, opts = {}) {
2486
2943
  super(label, { ...opts, onClick: () => this.run() });
@@ -2735,6 +3192,12 @@ marked.use({
2735
3192
  }
2736
3193
  ]
2737
3194
  });
3195
+ var MISWIRED_LAYOUT_HOOK_NAMES = [
3196
+ "onHeightChanged",
3197
+ "onHeightChange",
3198
+ "onLayoutUpdate",
3199
+ "onResize"
3200
+ ];
2738
3201
  var markdownWorker = null;
2739
3202
  var workerIdCounter = 0;
2740
3203
  var workerInstanceCounter = 0;
@@ -2796,20 +3259,62 @@ var Markdown = class _Markdown extends UIComponent3 {
2796
3259
  * affordance.
2797
3260
  */
2798
3261
  blockAffordances;
3262
+ /**
3263
+ * Which controls a block carries and what they are called, with defaults
3264
+ * applied.
3265
+ *
3266
+ * Resolved once in the constructor rather than per block: the defaults are
3267
+ * fixed, and re-deriving them for every code fence in a long document would
3268
+ * repeat the same six `??` fallbacks for no benefit.
3269
+ */
3270
+ affordanceConfig;
3271
+ /**
3272
+ * Whether code blocks show their language in a header band.
3273
+ *
3274
+ * Read when a block entity is built, exactly like {@link blockAffordances}, so
3275
+ * it affects blocks rendered from here on rather than retroactively.
3276
+ */
3277
+ showCodeLanguage;
2799
3278
  /** Clipboard writer used by the copy controls. */
2800
3279
  writeClipboard;
2801
3280
  /** File saver used by the download controls. */
2802
3281
  saveFile;
2803
3282
  activeBlockMetrics = null;
2804
3283
  /**
2805
- * Called after a streamed append has re-laid-out the document.
3284
+ * Called after this entity's own `width`/`height` changed because the document
3285
+ * was re-laid-out. **This is the hook to wire up when a host has to move or
3286
+ * resize anything positioned below the document.**
3287
+ *
3288
+ * Fires from three paths, all of which republish `width`/`height` from
3289
+ * `content`:
3290
+ *
3291
+ * - a streamed append (`updateTokens`),
3292
+ * - a width change ({@link setMaxWidth}),
3293
+ * - a paragraph image whose decoded bitmap corrected the guessed aspect ratio
3294
+ * (`reflowAfterImageResize`) — the guess is a flat 16:10, so this one fires
3295
+ * on essentially every document containing an image, and a host that misses
3296
+ * it leaves every block below the image overlapping it.
3297
+ *
3298
+ * It does **not** fire from `setContent()`, which replaces the whole document
3299
+ * and is a call the host already made, so this is a re-layout signal rather
3300
+ * than a complete size signal.
3301
+ *
3302
+ * A `VirtualList` needs none of this to track a streaming row: it re-reads
3303
+ * `height` on every mounted row each frame, so it sees this entity grow without
3304
+ * being told. Prefer that where it applies. Reach for this callback when the
3305
+ * host owns absolute positions of its own — a page that stacks navigation, a
3306
+ * footer and a scroll height under the document has to recompute them here.
2806
3307
  *
2807
- * Not required for a `VirtualList` to track a streaming row's height: the list
2808
- * re-reads `height` on every mounted row each frame, so it sees this entity grow
2809
- * without being told. Prefer that over wiring this up it fires from the append
2810
- * path only, **not** from `setContent()`, so it is not a complete size signal.
3308
+ * There is no `onHeightChanged`. That name has been assigned by real callers
3309
+ * through an `as unknown as` cast, which compiles, silences the type error and
3310
+ * then never fires; if a layout callback appears dead, check the name first.
2811
3311
  */
2812
3312
  onLayoutUpdated;
3313
+ /**
3314
+ * Latch for the miswired-hook warning, so a streaming document warns once
3315
+ * rather than on every chunk.
3316
+ */
3317
+ hasWarnedLayoutHookName = false;
2813
3318
  /**
2814
3319
  * The document's BODY text — everything after any front matter block.
2815
3320
  *
@@ -3052,6 +3557,8 @@ var Markdown = class _Markdown extends UIComponent3 {
3052
3557
  this.selectable = opts.selectable ?? true;
3053
3558
  this._userTiming = opts.userTiming ?? false;
3054
3559
  this.blockAffordances = opts.blockAffordances ?? false;
3560
+ this.affordanceConfig = resolveBlockAffordanceConfig(opts.affordances);
3561
+ this.showCodeLanguage = opts.showCodeLanguage ?? false;
3055
3562
  this.writeClipboard = opts.writeClipboard ?? defaultWriteClipboard;
3056
3563
  this.saveFile = opts.saveFile ?? defaultSaveFile;
3057
3564
  this.content = new Stack({
@@ -3260,7 +3767,7 @@ var Markdown = class _Markdown extends UIComponent3 {
3260
3767
  this.content.layout();
3261
3768
  this.width = this.content.width;
3262
3769
  this.height = this.content.height;
3263
- this.onLayoutUpdated?.();
3770
+ this.notifyLayoutUpdated();
3264
3771
  this.scene?.markDirty();
3265
3772
  return this;
3266
3773
  }
@@ -4013,40 +4520,60 @@ var Markdown = class _Markdown extends UIComponent3 {
4013
4520
  const controls = make();
4014
4521
  return controls.length > 0 ? new BlockWithAffordances(block, controls) : block;
4015
4522
  }
4016
- /** Copy and download controls for one fenced code block. */
4523
+ /** Copy and download controls for one fenced code block, per {@link affordanceConfig}. */
4017
4524
  codeBlockAffordances(source, lang) {
4018
4525
  const opts = this.affordanceButtonOptions();
4019
- return [
4020
- new BlockAffordanceButton("Copy code", "Copied", () => this.writeClipboard(source), opts),
4021
- new BlockAffordanceButton(
4022
- "Download code",
4023
- "Saved",
4024
- () => this.saveFile(`code.${extensionForLanguage(lang)}`, source, mimeForLanguage(lang)),
4025
- opts
4026
- )
4027
- ];
4028
- }
4029
- /** Copy (as Markdown) and download (as CSV) controls for one table. */
4526
+ const { copy, download, labels } = this.affordanceConfig;
4527
+ const controls = [];
4528
+ if (copy) {
4529
+ controls.push(
4530
+ new BlockAffordanceButton(
4531
+ labels.copyCode,
4532
+ labels.copied,
4533
+ () => this.writeClipboard(source),
4534
+ opts
4535
+ )
4536
+ );
4537
+ }
4538
+ if (download) {
4539
+ controls.push(
4540
+ new BlockAffordanceButton(
4541
+ labels.downloadCode,
4542
+ labels.saved,
4543
+ () => this.saveFile(`code.${extensionForLanguage(lang)}`, source, mimeForLanguage(lang)),
4544
+ opts
4545
+ )
4546
+ );
4547
+ }
4548
+ return controls;
4549
+ }
4550
+ /** Copy (as Markdown) and download (as CSV) controls for one table, per {@link affordanceConfig}. */
4030
4551
  tableAffordances(tblToken) {
4552
+ const { copy, download, labels } = this.affordanceConfig;
4031
4553
  const content = tableContentOf(tblToken);
4032
4554
  const opts = this.affordanceButtonOptions();
4033
- return [
4034
- // Markdown rather than CSV for the clipboard: the reader copied it out of a
4035
- // Markdown document and the overwhelmingly likely destination is another
4036
- // one. CSV is what the download is for, where a spreadsheet is the target.
4037
- new BlockAffordanceButton(
4038
- "Copy table",
4039
- "Copied",
4040
- () => this.writeClipboard(tableToMarkdown(content)),
4041
- opts
4042
- ),
4043
- new BlockAffordanceButton(
4044
- "Download table",
4045
- "Saved",
4046
- () => this.saveFile("table.csv", tableToCsv(content), "text/csv;charset=utf-8"),
4047
- opts
4048
- )
4049
- ];
4555
+ const controls = [];
4556
+ if (copy) {
4557
+ controls.push(
4558
+ new BlockAffordanceButton(
4559
+ labels.copyTable,
4560
+ labels.copied,
4561
+ () => this.writeClipboard(tableToMarkdown(content)),
4562
+ opts
4563
+ )
4564
+ );
4565
+ }
4566
+ if (download) {
4567
+ controls.push(
4568
+ new BlockAffordanceButton(
4569
+ labels.downloadTable,
4570
+ labels.saved,
4571
+ () => this.saveFile("table.csv", tableToCsv(content), "text/csv;charset=utf-8"),
4572
+ opts
4573
+ )
4574
+ );
4575
+ }
4576
+ return controls;
4050
4577
  }
4051
4578
  /**
4052
4579
  * Button styling for the affordances, derived from the document theme.
@@ -4130,7 +4657,40 @@ var Markdown = class _Markdown extends UIComponent3 {
4130
4657
  }
4131
4658
  this.width = this.content.width;
4132
4659
  this.height = this.content.height;
4133
- this.onLayoutUpdated?.();
4660
+ this.notifyLayoutUpdated();
4661
+ }
4662
+ /**
4663
+ * Publish a completed re-layout to the host.
4664
+ *
4665
+ * Every path that republishes `width`/`height` from `content` ends here rather
4666
+ * than calling {@link onLayoutUpdated} directly, so the misuse check below is
4667
+ * reached however the re-layout was triggered.
4668
+ *
4669
+ * The check exists because the failure it catches is silent and was found in
4670
+ * production, not in review. A host wired its reflow to `onHeightChanged` — a
4671
+ * name this class has never had — through an `as unknown as` cast. The cast
4672
+ * satisfied the compiler, the callback never fired, and every post containing
4673
+ * an image stayed laid out against the guessed 16:10 aspect ratio with a stale
4674
+ * document scroll height. Nothing in the type system, the tests or the console
4675
+ * said anything. A property that is *only ever assigned* has no read site to
4676
+ * fail, so the one place that can notice is the moment we would have called it.
4677
+ */
4678
+ notifyLayoutUpdated() {
4679
+ if (this.onLayoutUpdated) {
4680
+ this.onLayoutUpdated();
4681
+ return;
4682
+ }
4683
+ if (!this.hasWarnedLayoutHookName) {
4684
+ const wrongName = MISWIRED_LAYOUT_HOOK_NAMES.find(
4685
+ (name) => typeof this[name] === "function"
4686
+ );
4687
+ if (wrongName) {
4688
+ this.hasWarnedLayoutHookName = true;
4689
+ console.warn(
4690
+ `[VectoJS] Markdown.${wrongName} is not a VectoJS callback and will never fire. The document just re-laid-out and nothing was notified. Assign \`onLayoutUpdated\` instead \u2014 anything positioned below the document needs it, and a paragraph image corrects its guessed aspect ratio on decode, so a missed signal leaves following blocks overlapping the image.`
4691
+ );
4692
+ }
4693
+ }
4134
4694
  }
4135
4695
  /**
4136
4696
  * Re-derive one `MarkdownContainer`'s cached box from its children.
@@ -4941,9 +5501,7 @@ var Markdown = class _Markdown extends UIComponent3 {
4941
5501
  this.width = this.content.width;
4942
5502
  this.height = this.content.height;
4943
5503
  this.scene?.markDirty();
4944
- if (this.onLayoutUpdated) {
4945
- this.onLayoutUpdated();
4946
- }
5504
+ this.notifyLayoutUpdated();
4947
5505
  }
4948
5506
  /**
4949
5507
  * Render one nested block with a temporary width/margin context while
@@ -5159,7 +5717,9 @@ var Markdown = class _Markdown extends UIComponent3 {
5159
5717
  }
5160
5718
  }
5161
5719
  return this.withBlockAffordances(
5162
- new CodeBlock(codeToken.text, lang, availableWidth, t, this.selectable),
5720
+ new CodeBlock(codeToken.text, lang, availableWidth, t, this.selectable, {
5721
+ showLanguage: this.showCodeLanguage
5722
+ }),
5163
5723
  () => this.codeBlockAffordances(codeToken.text, lang)
5164
5724
  );
5165
5725
  }
@@ -5390,6 +5950,7 @@ export {
5390
5950
  extensionForLanguage,
5391
5951
  footnoteMarker,
5392
5952
  hasFencedBlockRenderer,
5953
+ highlightedLanguages,
5393
5954
  isFencedBlockRendererReady,
5394
5955
  isMathJaxReady,
5395
5956
  isPresetName,
@@ -5398,6 +5959,7 @@ export {
5398
5959
  preloadMathJax,
5399
5960
  registerFencedBlockRenderer,
5400
5961
  renderFencedBlock,
5962
+ resolveBlockAffordanceConfig,
5401
5963
  resolvePresetTheme,
5402
5964
  scanFrontMatter,
5403
5965
  tableContentOf,