@vectojs/markdown 0.18.2 → 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() });
@@ -2802,6 +3259,22 @@ var Markdown = class _Markdown extends UIComponent3 {
2802
3259
  * affordance.
2803
3260
  */
2804
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;
2805
3278
  /** Clipboard writer used by the copy controls. */
2806
3279
  writeClipboard;
2807
3280
  /** File saver used by the download controls. */
@@ -3084,6 +3557,8 @@ var Markdown = class _Markdown extends UIComponent3 {
3084
3557
  this.selectable = opts.selectable ?? true;
3085
3558
  this._userTiming = opts.userTiming ?? false;
3086
3559
  this.blockAffordances = opts.blockAffordances ?? false;
3560
+ this.affordanceConfig = resolveBlockAffordanceConfig(opts.affordances);
3561
+ this.showCodeLanguage = opts.showCodeLanguage ?? false;
3087
3562
  this.writeClipboard = opts.writeClipboard ?? defaultWriteClipboard;
3088
3563
  this.saveFile = opts.saveFile ?? defaultSaveFile;
3089
3564
  this.content = new Stack({
@@ -4045,40 +4520,60 @@ var Markdown = class _Markdown extends UIComponent3 {
4045
4520
  const controls = make();
4046
4521
  return controls.length > 0 ? new BlockWithAffordances(block, controls) : block;
4047
4522
  }
4048
- /** Copy and download controls for one fenced code block. */
4523
+ /** Copy and download controls for one fenced code block, per {@link affordanceConfig}. */
4049
4524
  codeBlockAffordances(source, lang) {
4050
4525
  const opts = this.affordanceButtonOptions();
4051
- return [
4052
- new BlockAffordanceButton("Copy code", "Copied", () => this.writeClipboard(source), opts),
4053
- new BlockAffordanceButton(
4054
- "Download code",
4055
- "Saved",
4056
- () => this.saveFile(`code.${extensionForLanguage(lang)}`, source, mimeForLanguage(lang)),
4057
- opts
4058
- )
4059
- ];
4060
- }
4061
- /** 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}. */
4062
4551
  tableAffordances(tblToken) {
4552
+ const { copy, download, labels } = this.affordanceConfig;
4063
4553
  const content = tableContentOf(tblToken);
4064
4554
  const opts = this.affordanceButtonOptions();
4065
- return [
4066
- // Markdown rather than CSV for the clipboard: the reader copied it out of a
4067
- // Markdown document and the overwhelmingly likely destination is another
4068
- // one. CSV is what the download is for, where a spreadsheet is the target.
4069
- new BlockAffordanceButton(
4070
- "Copy table",
4071
- "Copied",
4072
- () => this.writeClipboard(tableToMarkdown(content)),
4073
- opts
4074
- ),
4075
- new BlockAffordanceButton(
4076
- "Download table",
4077
- "Saved",
4078
- () => this.saveFile("table.csv", tableToCsv(content), "text/csv;charset=utf-8"),
4079
- opts
4080
- )
4081
- ];
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;
4082
4577
  }
4083
4578
  /**
4084
4579
  * Button styling for the affordances, derived from the document theme.
@@ -5222,7 +5717,9 @@ var Markdown = class _Markdown extends UIComponent3 {
5222
5717
  }
5223
5718
  }
5224
5719
  return this.withBlockAffordances(
5225
- new CodeBlock(codeToken.text, lang, availableWidth, t, this.selectable),
5720
+ new CodeBlock(codeToken.text, lang, availableWidth, t, this.selectable, {
5721
+ showLanguage: this.showCodeLanguage
5722
+ }),
5226
5723
  () => this.codeBlockAffordances(codeToken.text, lang)
5227
5724
  );
5228
5725
  }
@@ -5453,6 +5950,7 @@ export {
5453
5950
  extensionForLanguage,
5454
5951
  footnoteMarker,
5455
5952
  hasFencedBlockRenderer,
5953
+ highlightedLanguages,
5456
5954
  isFencedBlockRendererReady,
5457
5955
  isMathJaxReady,
5458
5956
  isPresetName,
@@ -5461,6 +5959,7 @@ export {
5461
5959
  preloadMathJax,
5462
5960
  registerFencedBlockRenderer,
5463
5961
  renderFencedBlock,
5962
+ resolveBlockAffordanceConfig,
5464
5963
  resolvePresetTheme,
5465
5964
  scanFrontMatter,
5466
5965
  tableContentOf,