@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.js CHANGED
@@ -44,6 +44,7 @@ __export(index_exports, {
44
44
  extensionForLanguage: () => extensionForLanguage,
45
45
  footnoteMarker: () => footnoteMarker,
46
46
  hasFencedBlockRenderer: () => hasFencedBlockRenderer,
47
+ highlightedLanguages: () => highlightedLanguages,
47
48
  isFencedBlockRendererReady: () => isFencedBlockRendererReady,
48
49
  isMathJaxReady: () => isMathJaxReady,
49
50
  isPresetName: () => isPresetName,
@@ -52,6 +53,7 @@ __export(index_exports, {
52
53
  preloadMathJax: () => preloadMathJax,
53
54
  registerFencedBlockRenderer: () => registerFencedBlockRenderer,
54
55
  renderFencedBlock: () => renderFencedBlock,
56
+ resolveBlockAffordanceConfig: () => resolveBlockAffordanceConfig,
55
57
  resolvePresetTheme: () => resolvePresetTheme,
56
58
  scanFrontMatter: () => scanFrontMatter,
57
59
  tableContentOf: () => tableContentOf,
@@ -606,11 +608,13 @@ var DEFAULT_THEME = {
606
608
  syntaxStringColor: "#86efac",
607
609
  syntaxCommentColor: "#64748b",
608
610
  syntaxNumberColor: "#fbbf24",
611
+ codeLangColor: "#64748b",
609
612
  bodyFont: "Inter, system-ui, sans-serif",
610
613
  codeFont: 'ui-monospace, "JetBrains Mono", "Fira Code", monospace',
611
614
  fontSize: 16,
612
615
  headingSizes: [32, 28, 24, 20, 18, 16],
613
616
  codeFontSize: 15,
617
+ codeLangFontSize: 12,
614
618
  tableFontSize: 14,
615
619
  footnoteMarkerScale: 0.75,
616
620
  subscriptScale: 0.75,
@@ -645,6 +649,12 @@ function resolveTheme(theme) {
645
649
  if (theme?.footnoteColor === void 0) {
646
650
  merged.footnoteColor = merged.linkColor;
647
651
  }
652
+ if (theme?.codeLangColor === void 0) {
653
+ merged.codeLangColor = merged.syntaxCommentColor;
654
+ }
655
+ if (theme?.codeLangFontSize === void 0) {
656
+ merged.codeLangFontSize = Math.max(1, merged.codeFontSize - 3);
657
+ }
648
658
  return merged;
649
659
  }
650
660
  function headingSize(theme, depth) {
@@ -984,14 +994,215 @@ var KEYWORD_SETS = {
984
994
  "static"
985
995
  ])
986
996
  };
997
+ KEYWORD_SETS["bash"] = /* @__PURE__ */ new Set([
998
+ // Shell builtins and control words. Deliberately not the whole of coreutils:
999
+ // a keyword table that includes every command name colors an entire script
1000
+ // uniformly, which reads worse than coloring only the control flow.
1001
+ "if",
1002
+ "then",
1003
+ "else",
1004
+ "elif",
1005
+ "fi",
1006
+ "for",
1007
+ "while",
1008
+ "until",
1009
+ "do",
1010
+ "done",
1011
+ "case",
1012
+ "esac",
1013
+ "in",
1014
+ "function",
1015
+ "return",
1016
+ "exit",
1017
+ "break",
1018
+ "continue",
1019
+ "local",
1020
+ "export",
1021
+ "readonly",
1022
+ "declare",
1023
+ "unset",
1024
+ "shift",
1025
+ "source",
1026
+ "alias",
1027
+ "set",
1028
+ "trap",
1029
+ "echo",
1030
+ "cd",
1031
+ "sudo",
1032
+ "true",
1033
+ "false"
1034
+ ]);
1035
+ KEYWORD_SETS["json"] = /* @__PURE__ */ new Set(["true", "false", "null"]);
1036
+ KEYWORD_SETS["css"] = /* @__PURE__ */ new Set([
1037
+ "important",
1038
+ "inherit",
1039
+ "initial",
1040
+ "unset",
1041
+ "revert",
1042
+ "auto",
1043
+ "none",
1044
+ "var",
1045
+ "calc"
1046
+ ]);
1047
+ KEYWORD_SETS["html"] = /* @__PURE__ */ new Set([
1048
+ // Tag names are the meaningful tokens a reader scans for. The tokenizer is
1049
+ // word-based, so `<div>` yields the word `div`.
1050
+ "html",
1051
+ "head",
1052
+ "body",
1053
+ "title",
1054
+ "meta",
1055
+ "link",
1056
+ "script",
1057
+ "style",
1058
+ "div",
1059
+ "span",
1060
+ "p",
1061
+ "a",
1062
+ "img",
1063
+ "ul",
1064
+ "ol",
1065
+ "li",
1066
+ "table",
1067
+ "tr",
1068
+ "td",
1069
+ "th",
1070
+ "form",
1071
+ "input",
1072
+ "button",
1073
+ "label",
1074
+ "select",
1075
+ "option",
1076
+ "textarea",
1077
+ "header",
1078
+ "footer",
1079
+ "nav",
1080
+ "main",
1081
+ "section",
1082
+ "article",
1083
+ "aside",
1084
+ "canvas",
1085
+ "svg",
1086
+ "template",
1087
+ "slot"
1088
+ ]);
987
1089
  KEYWORD_SETS["javascript"] = KEYWORD_SETS["js"];
988
1090
  KEYWORD_SETS["typescript"] = KEYWORD_SETS["ts"];
989
1091
  KEYWORD_SETS["python"] = KEYWORD_SETS["py"];
990
1092
  KEYWORD_SETS["rs"] = KEYWORD_SETS["rust"];
991
- function highlightLine(line, lang, theme) {
992
- const keywords = KEYWORD_SETS[lang];
993
- if (!keywords) {
994
- return [{ text: line, color: theme.codeColor }];
1093
+ KEYWORD_SETS["jsx"] = KEYWORD_SETS["js"];
1094
+ KEYWORD_SETS["mjs"] = KEYWORD_SETS["js"];
1095
+ KEYWORD_SETS["cjs"] = KEYWORD_SETS["js"];
1096
+ KEYWORD_SETS["tsx"] = KEYWORD_SETS["ts"];
1097
+ KEYWORD_SETS["mts"] = KEYWORD_SETS["ts"];
1098
+ KEYWORD_SETS["cts"] = KEYWORD_SETS["ts"];
1099
+ KEYWORD_SETS["sh"] = KEYWORD_SETS["bash"];
1100
+ KEYWORD_SETS["zsh"] = KEYWORD_SETS["bash"];
1101
+ KEYWORD_SETS["shell"] = KEYWORD_SETS["bash"];
1102
+ KEYWORD_SETS["console"] = KEYWORD_SETS["bash"];
1103
+ KEYWORD_SETS["jsonc"] = KEYWORD_SETS["json"];
1104
+ KEYWORD_SETS["json5"] = KEYWORD_SETS["json"];
1105
+ KEYWORD_SETS["scss"] = KEYWORD_SETS["css"];
1106
+ KEYWORD_SETS["sass"] = KEYWORD_SETS["css"];
1107
+ KEYWORD_SETS["less"] = KEYWORD_SETS["css"];
1108
+ KEYWORD_SETS["vue"] = KEYWORD_SETS["html"];
1109
+ KEYWORD_SETS["svelte"] = KEYWORD_SETS["html"];
1110
+ KEYWORD_SETS["xml"] = KEYWORD_SETS["html"];
1111
+ KEYWORD_SETS["svg"] = KEYWORD_SETS["html"];
1112
+ var C_LIKE = {
1113
+ lineComments: ["//"],
1114
+ quotes: ['"', "'", "`"],
1115
+ numbers: true,
1116
+ blockComments: [["/*", "*/"]],
1117
+ // A JS/TS template literal spans lines. Listed here as well as in `quotes`:
1118
+ // `quotes` handles the common single-line case, and this carries the rest.
1119
+ multilineStrings: ["`"]
1120
+ };
1121
+ var HASH_COMMENT = {
1122
+ lineComments: ["#"],
1123
+ quotes: ['"', "'"],
1124
+ numbers: true
1125
+ };
1126
+ var LANGUAGE_SYNTAX = {
1127
+ js: C_LIKE,
1128
+ ts: C_LIKE,
1129
+ // A Python docstring is the language's block comment in practice, and it is
1130
+ // lexically a string, so it is carried as one rather than invented as a third
1131
+ // kind. Triple delimiters are listed before the single ones so the longest
1132
+ // match wins.
1133
+ py: { ...HASH_COMMENT, multilineStrings: ['"""', "'''"] },
1134
+ // Rust has `//` line comments AND `'` lifetimes. The unterminated-quote
1135
+ // fallback already keeps a lifetime from swallowing the line, so `'` stays
1136
+ // listed: `'a'` is a valid char literal and should color as a string.
1137
+ rust: C_LIKE,
1138
+ bash: HASH_COMMENT,
1139
+ // JSON has no comments and no single-quoted strings. JSONC does have `//`,
1140
+ // and is aliased separately below rather than sharing this entry.
1141
+ json: { lineComments: [], quotes: ['"'], numbers: true },
1142
+ // CSS has only block comments — which now span lines, so this entry claims
1143
+ // them. Numbers are everywhere in CSS and coloring them is most of the visible
1144
+ // benefit.
1145
+ css: {
1146
+ lineComments: [],
1147
+ quotes: ['"', "'"],
1148
+ numbers: true,
1149
+ blockComments: [["/*", "*/"]]
1150
+ },
1151
+ // Markup: no line comments, and numbers inside attribute values are noise
1152
+ // rather than signal. An SGML comment spans lines like any other block form.
1153
+ html: {
1154
+ lineComments: [],
1155
+ quotes: ['"', "'"],
1156
+ numbers: false,
1157
+ blockComments: [["<!--", "-->"]]
1158
+ }
1159
+ };
1160
+ LANGUAGE_SYNTAX["javascript"] = LANGUAGE_SYNTAX["js"];
1161
+ LANGUAGE_SYNTAX["typescript"] = LANGUAGE_SYNTAX["ts"];
1162
+ LANGUAGE_SYNTAX["python"] = LANGUAGE_SYNTAX["py"];
1163
+ LANGUAGE_SYNTAX["rs"] = LANGUAGE_SYNTAX["rust"];
1164
+ LANGUAGE_SYNTAX["jsx"] = LANGUAGE_SYNTAX["js"];
1165
+ LANGUAGE_SYNTAX["mjs"] = LANGUAGE_SYNTAX["js"];
1166
+ LANGUAGE_SYNTAX["cjs"] = LANGUAGE_SYNTAX["js"];
1167
+ LANGUAGE_SYNTAX["tsx"] = LANGUAGE_SYNTAX["ts"];
1168
+ LANGUAGE_SYNTAX["mts"] = LANGUAGE_SYNTAX["ts"];
1169
+ LANGUAGE_SYNTAX["cts"] = LANGUAGE_SYNTAX["ts"];
1170
+ LANGUAGE_SYNTAX["sh"] = LANGUAGE_SYNTAX["bash"];
1171
+ LANGUAGE_SYNTAX["zsh"] = LANGUAGE_SYNTAX["bash"];
1172
+ LANGUAGE_SYNTAX["shell"] = LANGUAGE_SYNTAX["bash"];
1173
+ LANGUAGE_SYNTAX["console"] = LANGUAGE_SYNTAX["bash"];
1174
+ LANGUAGE_SYNTAX["yaml"] = HASH_COMMENT;
1175
+ LANGUAGE_SYNTAX["yml"] = HASH_COMMENT;
1176
+ LANGUAGE_SYNTAX["toml"] = HASH_COMMENT;
1177
+ LANGUAGE_SYNTAX["ini"] = HASH_COMMENT;
1178
+ LANGUAGE_SYNTAX["dockerfile"] = HASH_COMMENT;
1179
+ LANGUAGE_SYNTAX["makefile"] = HASH_COMMENT;
1180
+ LANGUAGE_SYNTAX["make"] = HASH_COMMENT;
1181
+ LANGUAGE_SYNTAX["jsonc"] = { lineComments: ["//"], quotes: ['"'], numbers: true };
1182
+ LANGUAGE_SYNTAX["json5"] = { lineComments: ["//"], quotes: ['"', "'"], numbers: true };
1183
+ LANGUAGE_SYNTAX["scss"] = C_LIKE;
1184
+ LANGUAGE_SYNTAX["sass"] = C_LIKE;
1185
+ LANGUAGE_SYNTAX["less"] = C_LIKE;
1186
+ LANGUAGE_SYNTAX["glsl"] = C_LIKE;
1187
+ LANGUAGE_SYNTAX["c"] = C_LIKE;
1188
+ LANGUAGE_SYNTAX["cpp"] = C_LIKE;
1189
+ LANGUAGE_SYNTAX["go"] = C_LIKE;
1190
+ LANGUAGE_SYNTAX["java"] = C_LIKE;
1191
+ LANGUAGE_SYNTAX["kotlin"] = C_LIKE;
1192
+ LANGUAGE_SYNTAX["swift"] = C_LIKE;
1193
+ LANGUAGE_SYNTAX["vue"] = LANGUAGE_SYNTAX["html"];
1194
+ LANGUAGE_SYNTAX["svelte"] = LANGUAGE_SYNTAX["html"];
1195
+ LANGUAGE_SYNTAX["xml"] = LANGUAGE_SYNTAX["html"];
1196
+ LANGUAGE_SYNTAX["svg"] = LANGUAGE_SYNTAX["html"];
1197
+ function highlightedLanguages() {
1198
+ return [.../* @__PURE__ */ new Set([...Object.keys(LANGUAGE_SYNTAX), ...Object.keys(KEYWORD_SETS)])].sort();
1199
+ }
1200
+ function highlightLine(line, lang, theme, carry = null) {
1201
+ const key = lang.trim().toLowerCase().split(/[\s:,{]/)[0] ?? "";
1202
+ const keywords = KEYWORD_SETS[key];
1203
+ const syntax = LANGUAGE_SYNTAX[key];
1204
+ if (!keywords && !syntax) {
1205
+ return { segments: [{ text: line, color: theme.codeColor }], carry: null };
995
1206
  }
996
1207
  const segments = [];
997
1208
  const KEYWORD_COLOR = theme.syntaxKeywordColor;
@@ -1006,19 +1217,63 @@ function highlightLine(line, lang, theme) {
1006
1217
  buf = "";
1007
1218
  }
1008
1219
  };
1220
+ const lexical = syntax ?? C_LIKE;
1221
+ const findClose = (from, close, isString) => {
1222
+ let j = from;
1223
+ while (j < line.length) {
1224
+ if (isString && line[j] === "\\") {
1225
+ j += 2;
1226
+ continue;
1227
+ }
1228
+ if (line.startsWith(close, j)) return j + close.length;
1229
+ j++;
1230
+ }
1231
+ return -1;
1232
+ };
1233
+ if (carry) {
1234
+ const color = carry.kind === "comment" ? COMMENT_COLOR : STRING_COLOR;
1235
+ const end = findClose(0, carry.close, carry.kind === "string");
1236
+ if (end === -1) {
1237
+ if (line.length > 0) segments.push({ text: line, color });
1238
+ return { segments, carry };
1239
+ }
1240
+ segments.push({ text: line.slice(0, end), color });
1241
+ i = end;
1242
+ }
1009
1243
  while (i < line.length) {
1010
1244
  const ch = line[i];
1011
- if (ch === "/" && line[i + 1] === "/") {
1245
+ const block = lexical.blockComments?.find(([open]) => line.startsWith(open, i));
1246
+ if (block) {
1247
+ const [open, close] = block;
1012
1248
  flush(theme.codeColor);
1013
- segments.push({ text: line.slice(i), color: COMMENT_COLOR });
1014
- return segments;
1249
+ const end = findClose(i + open.length, close, false);
1250
+ if (end === -1) {
1251
+ segments.push({ text: line.slice(i), color: COMMENT_COLOR });
1252
+ return { segments, carry: { kind: "comment", close } };
1253
+ }
1254
+ segments.push({ text: line.slice(i, end), color: COMMENT_COLOR });
1255
+ i = end;
1256
+ continue;
1015
1257
  }
1016
- if (ch === "#" && (lang === "py" || lang === "python" || lang === "rust" || lang === "rs")) {
1258
+ const comment = lexical.lineComments.find((prefix) => line.startsWith(prefix, i));
1259
+ if (comment !== void 0) {
1017
1260
  flush(theme.codeColor);
1018
1261
  segments.push({ text: line.slice(i), color: COMMENT_COLOR });
1019
- return segments;
1262
+ return { segments, carry: null };
1020
1263
  }
1021
- if (ch === '"' || ch === "'" || ch === "`") {
1264
+ const multi = lexical.multilineStrings?.find((delim) => line.startsWith(delim, i));
1265
+ if (multi !== void 0) {
1266
+ flush(theme.codeColor);
1267
+ const end = findClose(i + multi.length, multi, true);
1268
+ if (end === -1) {
1269
+ segments.push({ text: line.slice(i), color: STRING_COLOR });
1270
+ return { segments, carry: { kind: "string", close: multi } };
1271
+ }
1272
+ segments.push({ text: line.slice(i, end), color: STRING_COLOR });
1273
+ i = end;
1274
+ continue;
1275
+ }
1276
+ if (lexical.quotes.includes(ch)) {
1022
1277
  const quote = ch;
1023
1278
  let j = i + 1;
1024
1279
  let closed = false;
@@ -1043,7 +1298,7 @@ function highlightLine(line, lang, theme) {
1043
1298
  i++;
1044
1299
  continue;
1045
1300
  }
1046
- if (/\d/.test(ch) && (i === 0 || /[\s(,=+\-*/<>[\]{}:;]/.test(line[i - 1]))) {
1301
+ if (lexical.numbers && /\d/.test(ch) && (i === 0 || /[\s(,=+\-*/<>[\]{}:;]/.test(line[i - 1]))) {
1047
1302
  flush(theme.codeColor);
1048
1303
  let j = i;
1049
1304
  while (j < line.length && /[\d._xXa-fA-F]/.test(line[j])) j++;
@@ -1058,7 +1313,9 @@ function highlightLine(line, lang, theme) {
1058
1313
  const word = line.slice(i, j);
1059
1314
  segments.push({
1060
1315
  text: word,
1061
- color: keywords.has(word) ? KEYWORD_COLOR : theme.codeColor
1316
+ // A language may have lexical syntax but no keywords (plain YAML, TOML,
1317
+ // a Dockerfile). Those still get comments, strings and numbers.
1318
+ color: keywords?.has(word) ? KEYWORD_COLOR : theme.codeColor
1062
1319
  });
1063
1320
  i = j;
1064
1321
  continue;
@@ -1067,10 +1324,18 @@ function highlightLine(line, lang, theme) {
1067
1324
  i++;
1068
1325
  }
1069
1326
  flush(theme.codeColor);
1070
- return segments;
1327
+ return { segments, carry: null };
1071
1328
  }
1072
1329
  var CodeBlock = class extends import_ui.UIComponent {
1073
1330
  lines;
1331
+ /**
1332
+ * Lexical state ENTERING each line, index-aligned with {@link lines}.
1333
+ *
1334
+ * Entering rather than leaving, so a streamed append can resume tokenizing at
1335
+ * the prefix-reuse boundary by reading one entry instead of re-scanning the
1336
+ * document for an unclosed block comment.
1337
+ */
1338
+ lineCarry = [];
1074
1339
  grid = null;
1075
1340
  /** Raw (unhighlighted) lines of the last build, for prefix reuse in buildLines. */
1076
1341
  rawLines = null;
@@ -1078,6 +1343,19 @@ var CodeBlock = class extends import_ui.UIComponent {
1078
1343
  source;
1079
1344
  /** Bumped by {@link buildLines} and {@link setSelectable}; read by `Scene`. */
1080
1345
  contentEpoch = 0;
1346
+ /**
1347
+ * Horizontal scroll offset in local px, always in `[0, maxScrollX]`.
1348
+ *
1349
+ * Code does not wrap, so a line wider than the box would otherwise have an
1350
+ * unreachable tail. This offset is subtracted from BOTH the painted cell x and
1351
+ * the projected line x in the same frame — never one without the other, or the
1352
+ * DOM selection carriers detach from the glyphs they are supposed to cover
1353
+ * (the defect class `5cf7119` and `ee1de6f` fixed on the vertical axis).
1354
+ */
1355
+ scrollXValue = 0;
1356
+ /** Memoized widest prepared line, keyed by the grid identity it came from. */
1357
+ contentWidthGrid = null;
1358
+ contentWidthValue = 0;
1081
1359
  lang;
1082
1360
  theme;
1083
1361
  /**
@@ -1089,6 +1367,10 @@ var CodeBlock = class extends import_ui.UIComponent {
1089
1367
  pad;
1090
1368
  codeFont;
1091
1369
  selectable;
1370
+ /** Whether the language header band is drawn. See {@link CodeBlockOptions.showLanguage}. */
1371
+ showLanguage;
1372
+ /** Font of the header label, resolved once from the theme. */
1373
+ langFont;
1092
1374
  /**
1093
1375
  * @param theme Any subset of {@link MarkdownTheme}, or the name of a built-in
1094
1376
  * preset (see {@link MarkdownThemePresetName}). Accepting a partial theme
@@ -1100,7 +1382,7 @@ var CodeBlock = class extends import_ui.UIComponent {
1100
1382
  * be constructed directly with a preset name without going through
1101
1383
  * `Markdown`.
1102
1384
  */
1103
- constructor(code, lang, maxWidth, theme, selectable = true) {
1385
+ constructor(code, lang, maxWidth, theme, selectable = true, options = {}) {
1104
1386
  super();
1105
1387
  const resolved = resolvePresetTheme(theme);
1106
1388
  this.source = code;
@@ -1110,15 +1392,129 @@ var CodeBlock = class extends import_ui.UIComponent {
1110
1392
  this.pad = resolved.codePadding;
1111
1393
  this.codeFont = `${resolved.codeFontSize}px ${resolved.codeFont}`;
1112
1394
  this.selectable = selectable;
1395
+ this.langFont = `${resolved.codeLangFontSize}px ${resolved.codeFont}`;
1396
+ this.showLanguage = options.showLanguage === true && this.languageLabel() !== "";
1113
1397
  this.lines = [];
1114
1398
  this.width = maxWidth;
1115
1399
  this.buildLines(code);
1400
+ this.on("wheel", (e) => {
1401
+ const max = this.maxScrollX;
1402
+ if (max <= 0) return;
1403
+ if (e.ctrlKey === true) return;
1404
+ const deltaMode = e.deltaMode ?? 0;
1405
+ let deltaX = e.deltaX ?? 0;
1406
+ let deltaY = e.deltaY ?? 0;
1407
+ if (deltaMode === 1) {
1408
+ deltaX *= 16;
1409
+ deltaY *= 16;
1410
+ } else if (deltaMode === 2) {
1411
+ deltaX *= this.width;
1412
+ deltaY *= this.height;
1413
+ }
1414
+ const horizontal = e.shiftKey === true ? deltaY || deltaX : deltaX;
1415
+ if (horizontal === 0) return;
1416
+ const before = this.scrollX;
1417
+ this.setScrollX(before + horizontal);
1418
+ if (this.scrollX !== before) e.nativeEvent?.preventDefault?.();
1419
+ });
1420
+ }
1421
+ /**
1422
+ * The language name shown in the header, or `''` when there is nothing to show.
1423
+ *
1424
+ * Normalized exactly as the highlighter normalizes its lookup key, so the label
1425
+ * and the colouring can never disagree about which language this is: a fence
1426
+ * may be written ` ```Bash ` or carry attributes (` ```ts title="a.ts" `), and
1427
+ * the label has to be the language, not the raw info string.
1428
+ *
1429
+ * Lowercased for the same reason `streamdown` lowercases its own
1430
+ * (`lib/code-block/header.tsx:15`): the fence's capitalization is incidental,
1431
+ * and a document mixing ` ```JS ` with ` ```js ` should not render two
1432
+ * different-looking labels for one language.
1433
+ */
1434
+ languageLabel() {
1435
+ return this.lang.trim().toLowerCase().split(/[\s:,{]/)[0] ?? "";
1436
+ }
1437
+ /**
1438
+ * Height in px of the header band, or `0` when it is off.
1439
+ *
1440
+ * The label sits in a band of its own rather than floating over the code,
1441
+ * because a translucent overlay above real glyphs is unreadable at small sizes
1442
+ * and would fight the horizontal scroll: the code slides under it, so any text
1443
+ * drawn on top would collide with a different token every frame.
1444
+ */
1445
+ headerHeight() {
1446
+ if (!this.showLanguage) return 0;
1447
+ return this.theme.codeLangFontSize + Math.round(this.pad * 0.75);
1448
+ }
1449
+ /**
1450
+ * Local y of the first line of code.
1451
+ *
1452
+ * Everything that positions a row — the painter, the projection, the grid's
1453
+ * own origin — goes through this, so the header offset cannot be applied to
1454
+ * one and forgotten on another. That class of mismatch is exactly what
1455
+ * detaches selection carriers from the glyphs they cover.
1456
+ */
1457
+ contentTop() {
1458
+ return this.headerHeight() + this.pad;
1459
+ }
1460
+ /**
1461
+ * Current horizontal scroll offset in local px, clamped to what the content
1462
+ * currently allows.
1463
+ *
1464
+ * Clamped on READ, not only on write, because `setWidth()` may shrink the box
1465
+ * after a scroll and is contractually forbidden from rebuilding anything. Both
1466
+ * the painter and the projection read through here, which is what keeps the
1467
+ * glyphs and the selection carriers on the same offset within a frame.
1468
+ */
1469
+ get scrollX() {
1470
+ return Math.min(this.scrollXValue, this.maxScrollX);
1471
+ }
1472
+ /**
1473
+ * Widest line's overflow past the padded box, i.e. the maximum useful
1474
+ * {@link scrollX}. `0` when every line already fits.
1475
+ */
1476
+ get maxScrollX() {
1477
+ return Math.max(0, this.contentWidth() - (this.width - this.pad * 2));
1478
+ }
1479
+ /**
1480
+ * Widest prepared line, memoized against the grid that produced it.
1481
+ *
1482
+ * Read by {@link scrollX}, which both `render()` and `getContentProjection()`
1483
+ * call every synced frame, so an O(lines) scan here would be an O(document) cost
1484
+ * per frame on a long block — the exact shape the per-line projection window
1485
+ * exists to avoid. The grid is rebuilt only when the content changes, so the
1486
+ * cache key is identity of the grid object.
1487
+ */
1488
+ contentWidth() {
1489
+ const grid = this.ensureGrid();
1490
+ if (this.contentWidthGrid === grid) return this.contentWidthValue;
1491
+ let widest = 0;
1492
+ for (const line of grid.lines) {
1493
+ if (line.width > widest) widest = line.width;
1494
+ }
1495
+ this.contentWidthGrid = grid;
1496
+ this.contentWidthValue = widest;
1497
+ return widest;
1498
+ }
1499
+ /**
1500
+ * Scroll horizontally to `x`, clamped to `[0, maxScrollX]`.
1501
+ *
1502
+ * @returns `this` for chaining.
1503
+ */
1504
+ setScrollX(x) {
1505
+ const next = Math.max(0, Math.min(this.maxScrollX, x));
1506
+ if (next === this.scrollXValue) return this;
1507
+ this.scrollXValue = next;
1508
+ this.contentEpoch++;
1509
+ this.scene?.markDirty();
1510
+ return this;
1116
1511
  }
1117
1512
  /** Re-parse code content (e.g. for live editing). */
1118
1513
  setCode(code, lang) {
1119
1514
  if (lang !== void 0) this.lang = lang;
1120
1515
  this.source = code;
1121
1516
  this.buildLines(code);
1517
+ this.scrollXValue = Math.min(this.scrollXValue, this.maxScrollX);
1122
1518
  this.scene?.markDirty();
1123
1519
  return this;
1124
1520
  }
@@ -1138,9 +1534,13 @@ var CodeBlock = class extends import_ui.UIComponent {
1138
1534
  * Deliberately does **not** rebuild the grid or the highlight, because code does
1139
1535
  * not reflow: lines are placed on a fixed monospace grid at `col × cellWidth` and
1140
1536
  * a long line overflows rather than wrapping, so `height` is a function of line
1141
- * *count* alone. The width only sizes the rounded background. Anything that would
1142
- * change the glyph geometry — the source, the language, the font — goes through
1143
- * {@link setCode} and invalidates the grid there.
1537
+ * *count* alone. The width sizes the rounded background and the clip. Anything
1538
+ * that would change the glyph geometry — the source, the language, the font —
1539
+ * goes through {@link setCode} and invalidates the grid there.
1540
+ *
1541
+ * A narrower box can leave {@link scrollX} past the new end of travel. That is
1542
+ * resolved by clamping on read rather than by adjusting anything here, so this
1543
+ * method keeps costing nothing.
1144
1544
  *
1145
1545
  * @returns `this` for chaining.
1146
1546
  */
@@ -1154,16 +1554,21 @@ var CodeBlock = class extends import_ui.UIComponent {
1154
1554
  getContentProjection(hint) {
1155
1555
  if (!this.source) return null;
1156
1556
  const grid = this.ensureGrid();
1557
+ const scrollX = this.scrollX;
1157
1558
  const rows = [];
1158
1559
  rows.length = grid.lines.length;
1159
1560
  for (let row = 0; row < grid.lines.length; row++) {
1160
1561
  const line = grid.lines[row];
1161
- const y = this.pad + row * this.lineH;
1562
+ const y = this.contentTop() + row * this.lineH;
1162
1563
  if (!(0, import_core2.contentLineInHint)(hint, y, this.lineH)) continue;
1163
1564
  rows[row] = {
1164
1565
  text: this.source.slice(line.sourceStart, line.sourceEnd),
1165
1566
  separatorAfter: this.source.slice(line.sourceEnd, line.nextSourceStart) || void 0,
1166
- x: this.pad,
1567
+ // The SAME offset `render()` subtracts, read through the same clamping
1568
+ // accessor. Cell carriers are `position: relative` inside this `absolute`
1569
+ // line box, so shifting the line's x translates every cell of the line
1570
+ // rigidly and selection stays over the glyphs.
1571
+ x: this.pad - scrollX,
1167
1572
  y,
1168
1573
  baseline: this.lineH * 0.75,
1169
1574
  font: this.codeFont,
@@ -1183,6 +1588,12 @@ var CodeBlock = class extends import_ui.UIComponent {
1183
1588
  // render() draws cell-by-cell (no ligatures can form); the DOM copy
1184
1589
  // must not ligate either or Firefox selection geometry drifts.
1185
1590
  ligatures: "none",
1591
+ // `render()` clips the glyph pass to this box, so the DOM copy must too.
1592
+ // A line wider than the box otherwise projects carriers past the entity,
1593
+ // and the browser paints their selection highlight over whatever is drawn
1594
+ // beside the block — measured 1580px of carrier against a 1566px viewport
1595
+ // on a real page, the highlight running through the prose to its right.
1596
+ clipToBounds: true,
1186
1597
  grid
1187
1598
  };
1188
1599
  }
@@ -1197,6 +1608,11 @@ var CodeBlock = class extends import_ui.UIComponent {
1197
1608
  *
1198
1609
  * The last previously-seen line is deliberately NOT reused: a chunk usually
1199
1610
  * lands mid-line, so that line's text (and therefore its tokenization) changes.
1611
+ *
1612
+ * Prefix reuse survives multi-line constructs because {@link lineCarry} records
1613
+ * the state ENTERING each line, so resuming at the reuse boundary needs no
1614
+ * rescan: a carried state is a pure function of the preceding text, and that
1615
+ * text is byte-identical over the reused prefix by construction.
1200
1616
  */
1201
1617
  buildLines(code) {
1202
1618
  this.contentEpoch++;
@@ -1207,18 +1623,20 @@ var CodeBlock = class extends import_ui.UIComponent {
1207
1623
  const limit = Math.min(previous.length - 1, rawLines.length);
1208
1624
  while (reusable < limit && previous[reusable] === rawLines[reusable]) reusable++;
1209
1625
  }
1210
- if (reusable > 0) {
1211
- const next = this.lines.slice(0, reusable);
1212
- for (let i = reusable; i < rawLines.length; i++) {
1213
- next.push(highlightLine(rawLines[i], this.lang, this.theme));
1214
- }
1215
- this.lines = next;
1216
- } else {
1217
- this.lines = rawLines.map((l) => highlightLine(l, this.lang, this.theme));
1626
+ const lines = reusable > 0 ? this.lines.slice(0, reusable) : [];
1627
+ const carries = reusable > 0 ? this.lineCarry.slice(0, reusable) : [];
1628
+ let carry = reusable > 0 ? this.lineCarry[reusable] ?? null : null;
1629
+ for (let i = reusable; i < rawLines.length; i++) {
1630
+ carries.push(carry);
1631
+ const result = highlightLine(rawLines[i], this.lang, this.theme, carry);
1632
+ lines.push(result.segments);
1633
+ carry = result.carry;
1218
1634
  }
1635
+ this.lines = lines;
1636
+ this.lineCarry = carries;
1219
1637
  this.rawLines = rawLines;
1220
1638
  this.grid = null;
1221
- this.height = this.pad * 2 + rawLines.length * this.lineH;
1639
+ this.height = this.contentTop() + this.pad + rawLines.length * this.lineH;
1222
1640
  }
1223
1641
  ensureGrid() {
1224
1642
  const cellWidth = this.cellWidth || Math.max(1, (0, import_ui.measureText)("M", this.codeFont));
@@ -1232,7 +1650,15 @@ var CodeBlock = class extends import_ui.UIComponent {
1232
1650
  }
1233
1651
  return this.grid;
1234
1652
  }
1235
- /** Code blocks are decorative — not interactive. */
1653
+ /**
1654
+ * Not hit-testable, and deliberately still not `interactive`, even though the
1655
+ * block now consumes wheel events to scroll.
1656
+ *
1657
+ * The wheel arrives from the content-projection div rather than from canvas
1658
+ * hit-testing, so no a11y shadow node is needed. Creating one would place a
1659
+ * `pointer-events: auto` element above the transparent text mirror and swallow
1660
+ * the mousedown that starts a native drag-selection.
1661
+ */
1236
1662
  isPointInside() {
1237
1663
  return false;
1238
1664
  }
@@ -1244,8 +1670,25 @@ var CodeBlock = class extends import_ui.UIComponent {
1244
1670
  const atlas = codeGlyphAtlas(r);
1245
1671
  const atlasSource = atlas?.source ?? null;
1246
1672
  const blit = atlas ? r.drawImageRect : void 0;
1673
+ const header = this.headerHeight();
1674
+ if (header > 0) {
1675
+ r.fillText(
1676
+ this.languageLabel(),
1677
+ this.pad,
1678
+ // Vertically centred in the band by its own cap height rather than by
1679
+ // font size: `fillText` takes a baseline, so centring the em box would
1680
+ // sit the visible letterforms low. 0.7 of the label size below the band's
1681
+ // centre line is where a lowercase-plus-cap run reads as centred.
1682
+ (header + this.theme.codeLangFontSize * 0.7) / 2,
1683
+ this.langFont,
1684
+ this.theme.codeLangColor
1685
+ );
1686
+ }
1687
+ r.save();
1688
+ r.clip(0, header, this.width, this.height - header);
1689
+ const scrollX = this.scrollX;
1247
1690
  for (let row = 0; row < grid.lines.length; row++) {
1248
- const yBaseline = this.pad + row * this.lineH + this.lineH * 0.75;
1691
+ const yBaseline = this.contentTop() + row * this.lineH + this.lineH * 0.75;
1249
1692
  const segments = this.lines[row];
1250
1693
  let segmentIndex = 0;
1251
1694
  let segmentEnd = segments[0]?.text.length ?? 0;
@@ -1259,7 +1702,8 @@ var CodeBlock = class extends import_ui.UIComponent {
1259
1702
  const sourceText = this.source.slice(cell.sourceStart, cell.sourceEnd);
1260
1703
  if (cell.advance <= 0 || sourceText === " " || sourceText === " ") continue;
1261
1704
  const color = segments[segmentIndex]?.color ?? this.theme.codeColor;
1262
- const x = this.pad + cell.x;
1705
+ const x = this.pad + cell.x - scrollX;
1706
+ if (x + cell.advance < 0 || x > this.width) continue;
1263
1707
  if (blit && atlas) {
1264
1708
  const slot = atlas.get(this.codeFont, color, cell.glyph);
1265
1709
  const src = atlasSource ?? atlas.source;
@@ -1282,6 +1726,7 @@ var CodeBlock = class extends import_ui.UIComponent {
1282
1726
  r.fillText(cell.glyph, x, yBaseline, this.codeFont, color);
1283
1727
  }
1284
1728
  }
1729
+ r.restore();
1285
1730
  }
1286
1731
  };
1287
1732
  var codeAtlases = /* @__PURE__ */ new Map();
@@ -2533,6 +2978,20 @@ function defaultSaveFile(filename, content, mimeType) {
2533
2978
  doc.body.removeChild(anchor);
2534
2979
  URL.revokeObjectURL(url);
2535
2980
  }
2981
+ function resolveBlockAffordanceConfig(config = {}) {
2982
+ return {
2983
+ copy: config.copy ?? true,
2984
+ download: config.download ?? true,
2985
+ labels: {
2986
+ copyCode: config.labels?.copyCode ?? "Copy code",
2987
+ downloadCode: config.labels?.downloadCode ?? "Download code",
2988
+ copyTable: config.labels?.copyTable ?? "Copy table",
2989
+ downloadTable: config.labels?.downloadTable ?? "Download table",
2990
+ copied: config.labels?.copied ?? "Copied",
2991
+ saved: config.labels?.saved ?? "Saved"
2992
+ }
2993
+ };
2994
+ }
2536
2995
  var BlockAffordanceButton = class _BlockAffordanceButton extends import_ui3.Button {
2537
2996
  constructor(label, successLabel, act, opts = {}) {
2538
2997
  super(label, { ...opts, onClick: () => this.run() });
@@ -2854,6 +3313,22 @@ var Markdown = class _Markdown extends import_ui4.UIComponent {
2854
3313
  * affordance.
2855
3314
  */
2856
3315
  blockAffordances;
3316
+ /**
3317
+ * Which controls a block carries and what they are called, with defaults
3318
+ * applied.
3319
+ *
3320
+ * Resolved once in the constructor rather than per block: the defaults are
3321
+ * fixed, and re-deriving them for every code fence in a long document would
3322
+ * repeat the same six `??` fallbacks for no benefit.
3323
+ */
3324
+ affordanceConfig;
3325
+ /**
3326
+ * Whether code blocks show their language in a header band.
3327
+ *
3328
+ * Read when a block entity is built, exactly like {@link blockAffordances}, so
3329
+ * it affects blocks rendered from here on rather than retroactively.
3330
+ */
3331
+ showCodeLanguage;
2857
3332
  /** Clipboard writer used by the copy controls. */
2858
3333
  writeClipboard;
2859
3334
  /** File saver used by the download controls. */
@@ -3136,6 +3611,8 @@ var Markdown = class _Markdown extends import_ui4.UIComponent {
3136
3611
  this.selectable = opts.selectable ?? true;
3137
3612
  this._userTiming = opts.userTiming ?? false;
3138
3613
  this.blockAffordances = opts.blockAffordances ?? false;
3614
+ this.affordanceConfig = resolveBlockAffordanceConfig(opts.affordances);
3615
+ this.showCodeLanguage = opts.showCodeLanguage ?? false;
3139
3616
  this.writeClipboard = opts.writeClipboard ?? defaultWriteClipboard;
3140
3617
  this.saveFile = opts.saveFile ?? defaultSaveFile;
3141
3618
  this.content = new import_ui4.Stack({
@@ -4097,40 +4574,60 @@ var Markdown = class _Markdown extends import_ui4.UIComponent {
4097
4574
  const controls = make();
4098
4575
  return controls.length > 0 ? new BlockWithAffordances(block, controls) : block;
4099
4576
  }
4100
- /** Copy and download controls for one fenced code block. */
4577
+ /** Copy and download controls for one fenced code block, per {@link affordanceConfig}. */
4101
4578
  codeBlockAffordances(source, lang) {
4102
4579
  const opts = this.affordanceButtonOptions();
4103
- return [
4104
- new BlockAffordanceButton("Copy code", "Copied", () => this.writeClipboard(source), opts),
4105
- new BlockAffordanceButton(
4106
- "Download code",
4107
- "Saved",
4108
- () => this.saveFile(`code.${extensionForLanguage(lang)}`, source, mimeForLanguage(lang)),
4109
- opts
4110
- )
4111
- ];
4112
- }
4113
- /** Copy (as Markdown) and download (as CSV) controls for one table. */
4580
+ const { copy, download, labels } = this.affordanceConfig;
4581
+ const controls = [];
4582
+ if (copy) {
4583
+ controls.push(
4584
+ new BlockAffordanceButton(
4585
+ labels.copyCode,
4586
+ labels.copied,
4587
+ () => this.writeClipboard(source),
4588
+ opts
4589
+ )
4590
+ );
4591
+ }
4592
+ if (download) {
4593
+ controls.push(
4594
+ new BlockAffordanceButton(
4595
+ labels.downloadCode,
4596
+ labels.saved,
4597
+ () => this.saveFile(`code.${extensionForLanguage(lang)}`, source, mimeForLanguage(lang)),
4598
+ opts
4599
+ )
4600
+ );
4601
+ }
4602
+ return controls;
4603
+ }
4604
+ /** Copy (as Markdown) and download (as CSV) controls for one table, per {@link affordanceConfig}. */
4114
4605
  tableAffordances(tblToken) {
4606
+ const { copy, download, labels } = this.affordanceConfig;
4115
4607
  const content = tableContentOf(tblToken);
4116
4608
  const opts = this.affordanceButtonOptions();
4117
- return [
4118
- // Markdown rather than CSV for the clipboard: the reader copied it out of a
4119
- // Markdown document and the overwhelmingly likely destination is another
4120
- // one. CSV is what the download is for, where a spreadsheet is the target.
4121
- new BlockAffordanceButton(
4122
- "Copy table",
4123
- "Copied",
4124
- () => this.writeClipboard(tableToMarkdown(content)),
4125
- opts
4126
- ),
4127
- new BlockAffordanceButton(
4128
- "Download table",
4129
- "Saved",
4130
- () => this.saveFile("table.csv", tableToCsv(content), "text/csv;charset=utf-8"),
4131
- opts
4132
- )
4133
- ];
4609
+ const controls = [];
4610
+ if (copy) {
4611
+ controls.push(
4612
+ new BlockAffordanceButton(
4613
+ labels.copyTable,
4614
+ labels.copied,
4615
+ () => this.writeClipboard(tableToMarkdown(content)),
4616
+ opts
4617
+ )
4618
+ );
4619
+ }
4620
+ if (download) {
4621
+ controls.push(
4622
+ new BlockAffordanceButton(
4623
+ labels.downloadTable,
4624
+ labels.saved,
4625
+ () => this.saveFile("table.csv", tableToCsv(content), "text/csv;charset=utf-8"),
4626
+ opts
4627
+ )
4628
+ );
4629
+ }
4630
+ return controls;
4134
4631
  }
4135
4632
  /**
4136
4633
  * Button styling for the affordances, derived from the document theme.
@@ -5274,7 +5771,9 @@ var Markdown = class _Markdown extends import_ui4.UIComponent {
5274
5771
  }
5275
5772
  }
5276
5773
  return this.withBlockAffordances(
5277
- new CodeBlock(codeToken.text, lang, availableWidth, t, this.selectable),
5774
+ new CodeBlock(codeToken.text, lang, availableWidth, t, this.selectable, {
5775
+ showLanguage: this.showCodeLanguage
5776
+ }),
5278
5777
  () => this.codeBlockAffordances(codeToken.text, lang)
5279
5778
  );
5280
5779
  }
@@ -5506,6 +6005,7 @@ var Markdown = class _Markdown extends import_ui4.UIComponent {
5506
6005
  extensionForLanguage,
5507
6006
  footnoteMarker,
5508
6007
  hasFencedBlockRenderer,
6008
+ highlightedLanguages,
5509
6009
  isFencedBlockRendererReady,
5510
6010
  isMathJaxReady,
5511
6011
  isPresetName,
@@ -5514,6 +6014,7 @@ var Markdown = class _Markdown extends import_ui4.UIComponent {
5514
6014
  preloadMathJax,
5515
6015
  registerFencedBlockRenderer,
5516
6016
  renderFencedBlock,
6017
+ resolveBlockAffordanceConfig,
5517
6018
  resolvePresetTheme,
5518
6019
  scanFrontMatter,
5519
6020
  tableContentOf,