@jpeakai/mermaidjs-lint 0.4.2 → 0.6.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/README.md CHANGED
@@ -28,7 +28,7 @@ Installed as a dev dependency, the command is `mermaidjs-lint`, and `--help` lis
28
28
 
29
29
  Three tenets are guarantees about every diagram the tool passes, and the fourth is a commitment about the tool.
30
30
 
31
- - **Readable in light and dark.** Declared fills, label colours and borders are scored as the host draws them, on nodes, erDiagram rows and edge labels, in both themes.
31
+ - **Readable in light and dark.** Declared fills, label colours and borders are scored as the host draws them, on nodes, erDiagram rows, edge labels and sequence diagrams, in both themes.
32
32
  - **Renders.** Every diagram parses with the mermaid parsers this package ships, and every mermaid fence is closed.
33
33
  - **Simple enough to follow.** Node count and visual complexity are scored against a density preset, so an oversized diagram is flagged to be split.
34
34
  - **Extensible.** A plugin adds rules of your own, which run through the same config, fix engine and verification as the built-ins.
@@ -54,7 +54,7 @@ It does not compare edge endpoints, edge labels or node text, so review the diff
54
54
  ## Requirements and compatibility
55
55
 
56
56
  - **Runtime:** Bun, or Node `>=20` as the `engines` field of `package.json` states.
57
- - **Mermaid:** the parse rules use the `mermaid` package, at `^11.14.0`, and `@mermaid-js/parser`, both installed with this package.
57
+ - **Mermaid:** the parse and contrast rules use the `mermaid` package at exactly `11.17.2`, and `@mermaid-js/parser`, both installed with this package. The pin holds sequence diagram colours, which change between mermaid releases.
58
58
  - **Your host's mermaid may differ.** GitHub and other hosts draw diagrams with their own mermaid version. A diagram type newer than the host's may parse here and still fail to draw there.
59
59
 
60
60
  `npm ls mermaid` in your project prints the mermaid version a run uses.
package/dist/bin.js CHANGED
@@ -9,7 +9,7 @@ import { parseArgs } from "node:util";
9
9
  // package.json
10
10
  var package_default = {
11
11
  name: "@jpeakai/mermaidjs-lint",
12
- version: "0.4.2",
12
+ version: "0.6.0",
13
13
  type: "module",
14
14
  description: "Deterministic lint gates and verified fixers for Mermaid diagrams in markdown",
15
15
  license: "MIT",
@@ -67,7 +67,7 @@ var package_default = {
67
67
  "mdast-util-from-markdown": "^2.0.3",
68
68
  "mdast-util-frontmatter": "^2.0.1",
69
69
  "mdast-util-gfm": "^3.1.0",
70
- mermaid: "^11.14.0",
70
+ mermaid: "11.17.2",
71
71
  "micromark-extension-frontmatter": "^2.0.0",
72
72
  "micromark-extension-gfm": "^3.0.0"
73
73
  },
@@ -179,6 +179,14 @@ class RuleOptionError extends UsageError {
179
179
  }
180
180
  }
181
181
 
182
+ class CanvasProfileError extends UsageError {
183
+ file;
184
+ constructor(file) {
185
+ super(`${file}: a canvas applies under the github profile only, and this file is scored under mkdocs-material, whose page is Material's own; drop the canvas for it, or pass --profile github`, "canvas-profile");
186
+ this.file = file;
187
+ }
188
+ }
189
+
182
190
  class FixOutsideRootError extends UsageError {
183
191
  file;
184
192
  target;
@@ -395,6 +403,7 @@ var getMermaid = async () => {
395
403
  mermaidCore = (await import("mermaid")).default;
396
404
  return mermaidCore;
397
405
  };
406
+ var getMermaidCore = async () => await getMermaid();
398
407
  var LANGIUM_TYPES = {
399
408
  "architecture-beta": "architecture",
400
409
  architecture: "architecture",
@@ -1040,15 +1049,27 @@ var MKDOCS_MATERIAL = {
1040
1049
  light: { bg: "#ffffff", text: "#36464e" },
1041
1050
  dark: { bg: "#1e2129", text: "hsl(225 18% 86% / 0.82)" }
1042
1051
  };
1043
- var GITHUB = { light: { bg: "#ffffff" }, dark: { bg: "#0d1117" } };
1052
+ var GITHUB_CANVAS = { light: "#ffffff", dark: "#0d1117" };
1044
1053
  var THEMES = ["light", "dark"];
1054
+ var canvasColourProblem = (value) => {
1055
+ if (typeof value !== "string")
1056
+ return "is not a colour string";
1057
+ let alpha;
1058
+ try {
1059
+ alpha = alphaOf(value);
1060
+ } catch {
1061
+ return "is not a CSS colour";
1062
+ }
1063
+ return alpha < 1 ? "is translucent, and a page canvas must be opaque" : null;
1064
+ };
1065
+ var resolveCanvas = (setting) => ({ ...GITHUB_CANVAS, ...setting });
1045
1066
  var MERMAID_EDGE_LABEL = {
1046
1067
  light: { box: "rgba(232,232,232,0.8)", backing: "rgba(232,232,232,0.5)" },
1047
1068
  dark: { box: "#585858", backing: "rgba(88,88,88,0.5)" }
1048
1069
  };
1049
- var edgeLabelBackground = (theme) => {
1070
+ var edgeLabelBackground = (theme, canvas = GITHUB_CANVAS) => {
1050
1071
  const { box, backing } = MERMAID_EDGE_LABEL[theme];
1051
- return compositeOver(box, compositeOver(box, compositeOver(backing, GITHUB[theme].bg)));
1072
+ return compositeOver(box, compositeOver(box, compositeOver(backing, canvas[theme])));
1052
1073
  };
1053
1074
  var MERMAID_ER = {
1054
1075
  light: { odd: "#ffffff", even: "#f1f1ff", label: "#333333" },
@@ -1070,12 +1091,12 @@ var scorePair = (kind, d, fg, bg) => {
1070
1091
  };
1071
1092
  };
1072
1093
  var message = (err) => err instanceof Error ? err.message : String(err);
1073
- var scoreEdgeLabel = (d, pairs, skipped) => {
1094
+ var scoreEdgeLabel = (d, canvas, pairs, skipped) => {
1074
1095
  const { color } = d.properties;
1075
1096
  if (!color)
1076
1097
  return;
1077
1098
  try {
1078
- pairs.push(...THEMES.map((t) => scoreHostPair("text", d, t, color, edgeLabelBackground(t))));
1099
+ pairs.push(...THEMES.map((t) => scoreHostPair("text", d, t, color, edgeLabelBackground(t, canvas))));
1079
1100
  } catch (err) {
1080
1101
  skipped.push({
1081
1102
  selector: d.selector,
@@ -1085,12 +1106,12 @@ var scoreEdgeLabel = (d, pairs, skipped) => {
1085
1106
  });
1086
1107
  }
1087
1108
  };
1088
- var scoreGithub = (directives) => {
1109
+ var scoreGithub = (directives, canvas = GITHUB_CANVAS) => {
1089
1110
  const pairs = [];
1090
1111
  const skipped = [];
1091
1112
  for (const d of directives) {
1092
1113
  if (d.kind === "linkStyle")
1093
- scoreEdgeLabel(d, pairs, skipped);
1114
+ scoreEdgeLabel(d, canvas, pairs, skipped);
1094
1115
  if (d.kind !== "classDef" && d.kind !== "style")
1095
1116
  continue;
1096
1117
  const { fill, color, stroke } = d.properties;
@@ -1112,7 +1133,7 @@ var scoreGithub = (directives) => {
1112
1133
  note("fill is fully transparent, so there is no box to anchor contrast");
1113
1134
  continue;
1114
1135
  }
1115
- const boxes = alpha < 1 ? THEMES.map((t) => [compositeOver(fill, GITHUB[t].bg), t]) : [[fill, undefined]];
1136
+ const boxes = alpha < 1 ? THEMES.map((t) => [compositeOver(fill, canvas[t]), t]) : [[fill, undefined]];
1116
1137
  if (!color) {
1117
1138
  note("no color: declared beside fill:, and the github profile requires one " + "(if the host forces label text, use the mkdocs-material profile)", true);
1118
1139
  }
@@ -1245,11 +1266,144 @@ var scoreEr = (directives) => {
1245
1266
  }
1246
1267
  return { pairs, skipped };
1247
1268
  };
1248
- var scoreForProfile = (directives, profile, keyword) => {
1269
+ var scoreForProfile = (directives, profile, keyword, canvas = GITHUB_CANVAS) => {
1249
1270
  if (profile === "mkdocs-material")
1250
1271
  return scoreMkdocs(directives);
1251
- return keyword === "erDiagram" ? scoreEr(directives) : scoreGithub(directives);
1272
+ return keyword === "erDiagram" ? scoreEr(directives) : scoreGithub(directives, canvas);
1273
+ };
1274
+ var SEQUENCE_THEMES = ["base", "default", "dark", "forest", "neutral"];
1275
+ var LINE = { NOTE: 2, ACTIVE_START: 17, RECT_START: 22, AUTONUMBER: 26 };
1276
+ var FRAME_STARTS = new Set([10, 12, 15, 19, 27, 30, 32]);
1277
+ var sequenceFeatures = (db) => {
1278
+ const messages = db.getMessages();
1279
+ const has = (type) => messages.some((m) => m.type === type);
1280
+ return {
1281
+ humanActor: [...db.getActors().values()].some((a) => a.type === "actor"),
1282
+ note: has(LINE.NOTE),
1283
+ frame: messages.some((m) => FRAME_STARTS.has(m.type)),
1284
+ activation: has(LINE.ACTIVE_START),
1285
+ numbered: messages.some((m) => m.type === LINE.AUTONUMBER && m.message?.visible !== false),
1286
+ rects: messages.filter((m) => m.type === LINE.RECT_START).map((m) => String(m.message))
1287
+ };
1252
1288
  };
1289
+ var resolveVariables = (mermaid, config, themes) => {
1290
+ const prior = mermaid.mermaidAPI.getSiteConfig();
1291
+ try {
1292
+ const out = {};
1293
+ for (const t of THEMES) {
1294
+ mermaid.initialize({ ...structuredClone(config), theme: themes[t] });
1295
+ out[t] = { ...mermaid.mermaidAPI.getSiteConfig().themeVariables };
1296
+ }
1297
+ return out;
1298
+ } finally {
1299
+ mermaid.initialize(prior);
1300
+ }
1301
+ };
1302
+ var themeVariable = (selector, line) => ({
1303
+ kind: "themeVariables",
1304
+ selector,
1305
+ properties: {},
1306
+ spans: {},
1307
+ line
1308
+ });
1309
+ var scoreSequence = async (source, profile, canvas = GITHUB_CANVAS) => {
1310
+ const pairs = [];
1311
+ const skipped = [];
1312
+ const lines = source.split(`
1313
+ `);
1314
+ const keywordLine = lines.findIndex((l) => /^\s*sequenceDiagram\b/.test(l)) + 1;
1315
+ const lineOf = (key) => {
1316
+ const found = lines.findLastIndex((l) => new RegExp(`\\b${key}["']?\\s*:`).test(l));
1317
+ return found < 0 ? keywordLine : found + 1;
1318
+ };
1319
+ const rectLines = lines.flatMap((l, i) => /^\s*rect\s/.test(l) ? [i + 1] : []);
1320
+ const mermaid = await getMermaidCore();
1321
+ let config;
1322
+ let db;
1323
+ try {
1324
+ config = (await mermaid.parse(source)).config;
1325
+ db = (await mermaid.mermaidAPI.getDiagramFromText(source)).db;
1326
+ } catch {
1327
+ return { pairs, skipped };
1328
+ }
1329
+ const block = (selector, reason) => {
1330
+ skipped.push({ selector, line: lineOf(selector), reason, blocking: true });
1331
+ return { pairs, skipped };
1332
+ };
1333
+ if (config.themeCSS !== undefined)
1334
+ return block("themeCSS", "themeCSS restyles the diagram with CSS the audit cannot model");
1335
+ const pinned = config.theme;
1336
+ if (pinned !== undefined && !SEQUENCE_THEMES.includes(String(pinned))) {
1337
+ return block("theme", `the ${String(pinned)} theme is not modelled, only ${SEQUENCE_THEMES.join(", ")}`);
1338
+ }
1339
+ const features = sequenceFeatures(db);
1340
+ const host = profile === "mkdocs-material";
1341
+ const theme = (fallback) => pinned === undefined ? fallback : String(pinned);
1342
+ let variables;
1343
+ try {
1344
+ variables = resolveVariables(mermaid, config, { light: theme("default"), dark: theme(host ? "default" : "dark") });
1345
+ } catch (err) {
1346
+ return block("themeVariables", `Mermaid could not resolve the theme: ${message(err)}`);
1347
+ }
1348
+ for (const t of THEMES) {
1349
+ const page = host ? MKDOCS_MATERIAL[t].bg : canvas[t];
1350
+ const vars = variables[t];
1351
+ const color = (key) => {
1352
+ const value = vars[key];
1353
+ if (typeof value !== "string")
1354
+ throw new Error(`${key} is not a resolved colour`);
1355
+ return value;
1356
+ };
1357
+ const pair = (kind, fgKey, bg, suffix = "", at) => {
1358
+ const selector = fgKey + suffix;
1359
+ const line = at ?? lineOf(fgKey);
1360
+ try {
1361
+ const box = compositeOver("key" in bg ? color(bg.key) : bg.colour, page);
1362
+ pairs.push({ ...scoreHostPair(kind, themeVariable(selector, line), t, color(fgKey), box), advisory: host });
1363
+ } catch (err) {
1364
+ skipped.push({ selector, line, reason: `${t} sequence pair unparseable: ${message(err)}`, blocking: true });
1365
+ }
1366
+ };
1367
+ const onPage = { colour: page };
1368
+ if (host) {
1369
+ pair("border", "actorLineColor", onPage);
1370
+ pair("border", "signalColor", onPage, " (arrowhead)");
1371
+ if (features.activation)
1372
+ pair("border", "activationBorderColor", { key: "activationBkgColor" });
1373
+ continue;
1374
+ }
1375
+ pair("text", "actorTextColor", { key: "actorBkg" });
1376
+ pair("border", "actorBorder", { key: "actorBkg" });
1377
+ if (features.humanActor) {
1378
+ pair("text", "actorTextColor", onPage, " (human actor label)");
1379
+ pair("border", "actorBorder", onPage, " (human actor glyph)");
1380
+ }
1381
+ pair("border", "actorLineColor", onPage);
1382
+ pair("text", "signalTextColor", onPage);
1383
+ pair("border", "signalColor", onPage);
1384
+ if (features.note)
1385
+ pair("text", "noteTextColor", { key: "noteBkgColor" });
1386
+ if (features.frame) {
1387
+ pair("text", "labelTextColor", { key: "labelBoxBkgColor" });
1388
+ pair("text", "loopTextColor", onPage);
1389
+ pair("border", "labelBoxBorderColor", onPage, " (loop frame)");
1390
+ }
1391
+ if (features.activation)
1392
+ pair("border", "activationBorderColor", { key: "activationBkgColor" });
1393
+ if (features.numbered)
1394
+ pair("text", "sequenceNumberColor", { key: "signalColor" });
1395
+ for (const [i, rect] of features.rects.entries()) {
1396
+ const fill = { colour: rect };
1397
+ const at = rectLines[i];
1398
+ pair("text", "signalTextColor", fill, " (rect)", at);
1399
+ pair("border", "signalColor", fill, " (rect)", at);
1400
+ if (features.frame)
1401
+ pair("text", "loopTextColor", fill, " (rect)", at);
1402
+ }
1403
+ }
1404
+ return { pairs, skipped };
1405
+ };
1406
+ var scoreDiagram = (source, directives, profile, keyword, canvas = GITHUB_CANVAS) => keyword === "sequenceDiagram" ? scoreSequence(source, profile, canvas) : Promise.resolve(scoreForProfile(directives, profile, keyword, canvas));
1253
1407
  var detectProfile = (filePath) => {
1254
1408
  let dir = dirname(resolve(filePath));
1255
1409
  for (let i = 0;i < 20; i++) {
@@ -1413,7 +1567,7 @@ var COMPLEXITY_OPTIONS = {
1413
1567
  subgraphWeight: "number",
1414
1568
  depthWeight: "number"
1415
1569
  };
1416
- var CONTRAST_OPTIONS = { profile: "string" };
1570
+ var CONTRAST_OPTIONS = { profile: "string", canvas: "canvas" };
1417
1571
  var measure = async (diagram, config) => {
1418
1572
  const stats = await diagram.stats();
1419
1573
  if (parseFailed(diagram.source, stats))
@@ -1443,17 +1597,35 @@ var profileOf = (ctx) => {
1443
1597
  }
1444
1598
  return wanted === "auto" ? detectProfile(ctx.file) : wanted;
1445
1599
  };
1446
- var scored = new WeakMap;
1447
- var contrastOf = (diagram, profile) => {
1448
- let byProfile = scored.get(diagram);
1449
- if (!byProfile) {
1450
- byProfile = new Map;
1451
- scored.set(diagram, byProfile);
1600
+ var settingsOf = (ctx) => {
1601
+ const profile = profileOf(ctx);
1602
+ const setting = ctx.options.canvas;
1603
+ if (setting === undefined)
1604
+ return { profile, canvas: GITHUB_CANVAS };
1605
+ for (const side of THEMES) {
1606
+ if (!(side in setting))
1607
+ continue;
1608
+ const problem = canvasColourProblem(setting[side]);
1609
+ if (problem)
1610
+ throw new Error(`canvas.${side} ${problem}: ${JSON.stringify(setting[side])}`);
1452
1611
  }
1453
- let result = byProfile.get(profile);
1612
+ if (profile === "mkdocs-material")
1613
+ throw new CanvasProfileError(ctx.file);
1614
+ return { profile, canvas: resolveCanvas(setting) };
1615
+ };
1616
+ var scored = new WeakMap;
1617
+ var contrastOf = (diagram, settings) => {
1618
+ const { profile, canvas } = settings;
1619
+ const key = `${profile}\x00${canvas.light}\x00${canvas.dark}`;
1620
+ let bySettings = scored.get(diagram);
1621
+ if (!bySettings) {
1622
+ bySettings = new Map;
1623
+ scored.set(diagram, bySettings);
1624
+ }
1625
+ let result = bySettings.get(key);
1454
1626
  if (!result) {
1455
- result = scoreForProfile(diagram.directives, profile, diagram.keyword);
1456
- byProfile.set(profile, result);
1627
+ result = scoreDiagram(diagram.source, diagram.directives, profile, diagram.keyword, canvas);
1628
+ bySettings.set(key, result);
1457
1629
  }
1458
1630
  return result;
1459
1631
  };
@@ -1565,11 +1737,11 @@ var contrastBorderBelowUiMinimum = {
1565
1737
  category: "contrast",
1566
1738
  summary: "a border below 3:1 against its fill",
1567
1739
  options: CONTRAST_OPTIONS,
1568
- check: (ctx) => {
1569
- const profile = profileOf(ctx);
1740
+ check: async (ctx) => {
1741
+ const settings = settingsOf(ctx);
1570
1742
  const found = [];
1571
1743
  for (const diagram of ctx.doc.diagrams) {
1572
- for (const p of contrastOf(diagram, profile).pairs) {
1744
+ for (const p of (await contrastOf(diagram, settings)).pairs) {
1573
1745
  if (p.kind !== "border" || p.passes || p.advisory)
1574
1746
  continue;
1575
1747
  const line = fileLine(diagram, p.line);
@@ -1579,7 +1751,7 @@ var contrastBorderBelowUiMinimum = {
1579
1751
  endLine: line,
1580
1752
  rule: RULE.BORDER,
1581
1753
  message: `${p.directive.kind} ${p.selector}: border ${p.assessment.ratio.toFixed(2)}:1 < ${AA_NON_TEXT2} ` + `(${p.foreground} on ${p.background}${p.theme ? `, ${p.theme}` : ""})`,
1582
- remediation: "Darken the stroke or lighten the fill until the ratio reaches 3:1."
1754
+ remediation: p.directive.kind === "themeVariables" ? "Set the theme variable to a colour that reaches 3:1 on its background in both themes, or remove it and let the theme set it." : "Darken the stroke or lighten the fill until the ratio reaches 3:1."
1583
1755
  });
1584
1756
  }
1585
1757
  }
@@ -1593,11 +1765,11 @@ var contrastDirectiveUnauditable = {
1593
1765
  category: "contrast",
1594
1766
  summary: "a style directive whose contrast cannot be audited",
1595
1767
  options: CONTRAST_OPTIONS,
1596
- check: (ctx) => {
1597
- const profile = profileOf(ctx);
1768
+ check: async (ctx) => {
1769
+ const settings = settingsOf(ctx);
1598
1770
  const found = [];
1599
1771
  for (const diagram of ctx.doc.diagrams) {
1600
- for (const s of contrastOf(diagram, profile).skipped) {
1772
+ for (const s of (await contrastOf(diagram, settings)).skipped) {
1601
1773
  if (!s.blocking)
1602
1774
  continue;
1603
1775
  const line = fileLine(diagram, s.line);
@@ -1607,7 +1779,7 @@ var contrastDirectiveUnauditable = {
1607
1779
  endLine: line,
1608
1780
  rule: RULE.UNAUDITABLE,
1609
1781
  message: `${s.selector}: unaudited, ${s.reason}`,
1610
- remediation: "Declare the missing property or fix the colour so the pair can be scored."
1782
+ remediation: "Declare the missing property, fix the colour, or remove the config the audit cannot model, so the pair can be scored."
1611
1783
  });
1612
1784
  }
1613
1785
  }
@@ -1624,11 +1796,11 @@ var contrastTextBelowAa = {
1624
1796
  category: "contrast",
1625
1797
  summary: "label text below 4.5:1 against its fill",
1626
1798
  options: CONTRAST_OPTIONS,
1627
- check: (ctx) => {
1628
- const profile = profileOf(ctx);
1799
+ check: async (ctx) => {
1800
+ const settings = settingsOf(ctx);
1629
1801
  const found = [];
1630
1802
  for (const diagram of ctx.doc.diagrams) {
1631
- for (const p of contrastOf(diagram, profile).pairs) {
1803
+ for (const p of (await contrastOf(diagram, settings)).pairs) {
1632
1804
  if (p.kind !== "text" || p.passes)
1633
1805
  continue;
1634
1806
  const where = [p.theme, p.row].filter(Boolean).join("/");
@@ -1639,19 +1811,19 @@ var contrastTextBelowAa = {
1639
1811
  endLine: line,
1640
1812
  rule: RULE.TEXT,
1641
1813
  message: `${p.directive.kind} ${p.selector}: text ${p.assessment.ratio.toFixed(2)}:1 < ${AA_NORMAL2} ` + `(${p.foreground} on ${p.background}${where ? `, ${where}` : ""})`,
1642
- remediation: profile === "mkdocs-material" ? "The host theme forces the label text, so change the fill until the host text reads on it in both themes." : p.directive.kind === "linkStyle" ? "An edge label sits on Mermaid's own label box, light in one theme and grey in the other, and no one colour reads on both, so drop color: from the linkStyle and let the theme set the label text." : "Darken or lighten the fill, or change color:, until the ratio reaches 4.5:1 in every theme."
1814
+ remediation: settings.profile === "mkdocs-material" ? "The host theme forces the label text, so change the fill until the host text reads on it in both themes." : p.directive.kind === "themeVariables" ? "A sequence diagram colour is a theme variable, and text with no box of its own sits on the page, so set the variable to a colour that reads on its background in both themes, or remove it and let the theme set it." : p.directive.kind === "linkStyle" ? "An edge label sits on Mermaid's own label box, light in one theme and grey in the other, and no one colour reads on both, so drop color: from the linkStyle and let the theme set the label text." : "Darken or lighten the fill, or change color:, until the ratio reaches 4.5:1 in every theme."
1643
1815
  });
1644
1816
  }
1645
1817
  }
1646
1818
  return found;
1647
1819
  },
1648
- fix: (ctx) => {
1649
- const profile = profileOf(ctx);
1650
- if (profile === "mkdocs-material")
1820
+ fix: async (ctx) => {
1821
+ const settings = settingsOf(ctx);
1822
+ if (settings.profile === "mkdocs-material")
1651
1823
  return [];
1652
1824
  const edits = [];
1653
1825
  for (const diagram of ctx.doc.diagrams) {
1654
- const { pairs } = contrastOf(diagram, profile);
1826
+ const { pairs } = await contrastOf(diagram, settings);
1655
1827
  const failing = new Set(pairs.filter((p) => p.kind === "text" && !p.passes).map((p) => p.directive));
1656
1828
  for (const directive of failing) {
1657
1829
  const span = directive.spans.color;
@@ -1771,8 +1943,27 @@ var typeOf = (value) => {
1771
1943
  return "boolean";
1772
1944
  if (Array.isArray(value) && value.every((v) => typeof v === "string"))
1773
1945
  return "string[]";
1946
+ if (isCanvasShape(value))
1947
+ return "canvas";
1774
1948
  return null;
1775
1949
  };
1950
+ var isCanvasShape = (value) => {
1951
+ if (typeof value !== "object" || value === null || Array.isArray(value))
1952
+ return false;
1953
+ const keys = Object.keys(value);
1954
+ return keys.length > 0 && keys.every((k) => THEMES.includes(k));
1955
+ };
1956
+ var describeType = (want) => want === "canvas" ? 'an object such as { "light": "#ffffff", "dark": "#0d1117" }' : `a ${want}`;
1957
+ var checkCanvas = (where, rule, name, value) => {
1958
+ for (const side of THEMES) {
1959
+ if (!(side in value))
1960
+ continue;
1961
+ const problem = canvasColourProblem(value[side]);
1962
+ if (problem) {
1963
+ throw new RuleOptionError(where, rule.id, name, `option "${name}.${side}" of "${rule.id}" ${problem}: ${JSON.stringify(value[side])}`);
1964
+ }
1965
+ }
1966
+ };
1776
1967
  var checkOptions = (where, rule, options) => {
1777
1968
  const accepted = Object.keys(rule.options ?? {});
1778
1969
  for (const [name, value] of Object.entries(options)) {
@@ -1782,8 +1973,13 @@ var checkOptions = (where, rule, options) => {
1782
1973
  throw new RuleOptionError(where, rule.id, name, `rule "${rule.id}" has no option "${name}"; ${list}`);
1783
1974
  }
1784
1975
  if (typeOf(value) !== want) {
1785
- throw new RuleOptionError(where, rule.id, name, `option "${name}" of "${rule.id}" must be a ${want}`);
1976
+ throw new RuleOptionError(where, rule.id, name, `option "${name}" of "${rule.id}" must be ${describeType(want)}`);
1786
1977
  }
1978
+ if (want === "canvas")
1979
+ checkCanvas(where, rule, name, value);
1980
+ }
1981
+ if (options.profile === "mkdocs-material" && options.canvas !== undefined && rule.options?.canvas === "canvas") {
1982
+ throw new RuleOptionError(where, rule.id, "canvas", `option "canvas" of "${rule.id}" applies under the github profile only, and this rule sets profile mkdocs-material, whose page is Material's own`);
1787
1983
  }
1788
1984
  if (typeof options.preset === "string" && canonicalPreset(options.preset) === null) {
1789
1985
  throw new RuleOptionError(where, rule.id, "preset", `option "preset" of "${rule.id}" is not a known preset`);
@@ -1827,8 +2023,24 @@ var buildRegistry = (input) => {
1827
2023
  var BUILTIN = buildRegistry({ rules: RULES, fixOrder: FIX_ORDER, categories: BUILTIN_CATEGORIES });
1828
2024
 
1829
2025
  // src/check.ts
2026
+ var canvasName = (side, prefix) => prefix ? `${prefix}canvas-${side}` : `canvas.${side}`;
1830
2027
  var checkRunFlags = (flags, prefix = "") => {
1831
- const { preset, profile } = flags;
2028
+ const { preset, profile, canvas } = flags;
2029
+ for (const side of THEMES) {
2030
+ if (canvas?.[side] === undefined)
2031
+ continue;
2032
+ const problem = canvasColourProblem(canvas[side]);
2033
+ if (problem) {
2034
+ const name = canvasName(side, prefix);
2035
+ throw new FlagError(name, `${name} ${problem}: "${canvas[side]}"; give an opaque CSS colour, such as #ffffff`);
2036
+ }
2037
+ }
2038
+ const sides = THEMES.filter((side) => canvas?.[side] !== undefined);
2039
+ const first = sides[0];
2040
+ if (profile === "mkdocs-material" && first !== undefined) {
2041
+ const name = canvasName(first, prefix);
2042
+ throw new FlagError(name, `${name} applies under the github profile only; mkdocs-material scores Material's own page colours`);
2043
+ }
1832
2044
  if (preset !== undefined && canonicalPreset(preset) === null) {
1833
2045
  throw new FlagError(`${prefix}preset`, `${prefix}preset must be one of ${PRESET_NAMES.join(", ")}, not "${preset}"`);
1834
2046
  }
@@ -1845,6 +2057,17 @@ var optionsFor = (rule, configured, flags) => {
1845
2057
  }
1846
2058
  if (typeof options.preset === "string")
1847
2059
  options.preset = canonicalPreset(options.preset) ?? options.preset;
2060
+ const canvas = flags.canvas;
2061
+ if (canvas && THEMES.some((side) => canvas[side] !== undefined) && rule.options?.canvas === "canvas") {
2062
+ const configured2 = typeof options.canvas === "object" && options.canvas !== null ? options.canvas : {};
2063
+ const merged = { ...configured2 };
2064
+ for (const side of THEMES) {
2065
+ const value = canvas[side];
2066
+ if (value !== undefined)
2067
+ merged[side] = value;
2068
+ }
2069
+ options.canvas = merged;
2070
+ }
1848
2071
  return options;
1849
2072
  };
1850
2073
  var checkModel = async (doc, file, flags = {}, registry = BUILTIN) => {
@@ -2269,7 +2492,7 @@ var RULE_KEY = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
2269
2492
 
2270
2493
  // src/plugins/runtime.ts
2271
2494
  var canImportTypeScript = (runtime = process) => typeof runtime.versions.bun === "string" || Boolean(runtime.features.typescript);
2272
- var TYPESCRIPT_HELP = "TypeScript cannot be imported by this runtime. Run with Bun (bunx --bun @jpeakai/mermaidjs-lint), use Node 22.18 or newer, or write a .js or .mjs file";
2495
+ var TYPESCRIPT_HELP = "TypeScript cannot be imported by this runtime. Run with Bun (bunx --bun @jpeakai/mermaidjs-lint), use Node 22.18 or newer (23.6 or newer on Node 23), or write a .js or .mjs file";
2273
2496
 
2274
2497
  // src/plugins/load.ts
2275
2498
  var isRecord3 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
@@ -2291,7 +2514,7 @@ var importModule = async (source, runtime) => {
2291
2514
  throw new PluginImportError(source.spec, message2(err));
2292
2515
  }
2293
2516
  };
2294
- var OPTION_TYPES = new Set(["number", "string", "boolean", "string[]"]);
2517
+ var OPTION_TYPES = new Set(["number", "string", "boolean", "string[]", "canvas"]);
2295
2518
  var RESERVED_OPTIONS = ["preset", "profile"];
2296
2519
  var checkRule = (spec, key, raw, own) => {
2297
2520
  const where = `rule "${key}"`;
@@ -2320,7 +2543,7 @@ var checkRule = (spec, key, raw, own) => {
2320
2543
  if (options !== undefined) {
2321
2544
  const valid = isRecord3(options) && Object.values(options).every((t) => OPTION_TYPES.has(t));
2322
2545
  if (!valid) {
2323
- throw new PluginContractError(spec, `${where} options must map names to number, string, boolean or string[]`);
2546
+ throw new PluginContractError(spec, `${where} options must map names to number, string, boolean, string[] or canvas`);
2324
2547
  }
2325
2548
  for (const name of RESERVED_OPTIONS) {
2326
2549
  if (name in options && options[name] !== "string") {
@@ -2587,7 +2810,7 @@ var setUp = async ({
2587
2810
 
2588
2811
  // src/cli.ts
2589
2812
  var SYNOPSIS = `usage: mermaidjs-lint <path...> [--fix] [--json] [--preset NAME] [--profile NAME] [--config FILE]
2590
- [--no-plugins | --lenient-plugins]
2813
+ [--canvas-light COLOUR] [--canvas-dark COLOUR] [--no-plugins | --lenient-plugins]
2591
2814
  mermaidjs-lint --list-rules
2592
2815
  mermaidjs-lint --validate-plugins [--json] [--config FILE]
2593
2816
  mermaidjs-lint --version | -h, --help`;
@@ -2613,6 +2836,10 @@ Examples of every rule, with fixes: https://jpeakai.github.io/jpai-mermaidjs-lin
2613
2836
  numeric threshold set in the config overrides both.
2614
2837
  --profile NAME sets how contrast is read: ${PROFILES.join(", ")}. It overrides a profile set in the config.
2615
2838
  auto reads mkdocs-material when any parent folder holds mkdocs.yml or mkdocs.yaml, else github.
2839
+ --canvas-light COLOUR and --canvas-dark COLOUR set the page a host draws in each theme, under the github
2840
+ profile only. A translucent fill, an edge label box and a sequence diagram's page-backed text are scored
2841
+ on it. Each is an opaque CSS colour such as #faf8f9, and a side not given keeps the config's, or GitHub's
2842
+ (#ffffff light, #0d1117 dark). A file scored under mkdocs-material refuses a canvas with a usage error.
2616
2843
  --json prints one JSON object instead of text lines.
2617
2844
  --fix applies every fix it can prove safe, then reports what remains.
2618
2845
  A fixer that is unsure leaves the text alone and the finding stays.
@@ -2636,6 +2863,8 @@ var parse2 = (argv) => parseArgs({
2636
2863
  help: { type: "boolean", short: "h", default: false },
2637
2864
  preset: { type: "string", short: "p" },
2638
2865
  profile: { type: "string" },
2866
+ "canvas-light": { type: "string" },
2867
+ "canvas-dark": { type: "string" },
2639
2868
  config: { type: "string" },
2640
2869
  "no-plugins": { type: "boolean", default: false },
2641
2870
  "lenient-plugins": { type: "boolean", default: false },
@@ -2741,7 +2970,16 @@ var run = async (argv, cwd) => {
2741
2970
  }
2742
2971
  const listing = values["list-rules"];
2743
2972
  const validating = values["validate-plugins"];
2744
- checkRunFlags({ preset: values.preset, profile: values.profile }, "--");
2973
+ const canvas = {
2974
+ ...values["canvas-light"] !== undefined && { light: values["canvas-light"] },
2975
+ ...values["canvas-dark"] !== undefined && { dark: values["canvas-dark"] }
2976
+ };
2977
+ const flags = {
2978
+ preset: values.preset,
2979
+ profile: values.profile,
2980
+ ...Object.keys(canvas).length > 0 && { canvas }
2981
+ };
2982
+ checkRunFlags(flags, "--");
2745
2983
  if (values["no-plugins"] && values["lenient-plugins"]) {
2746
2984
  throw new FlagError("--lenient-plugins", "--no-plugins and --lenient-plugins contradict each other");
2747
2985
  }
@@ -2787,7 +3025,6 @@ var run = async (argv, cwd) => {
2787
3025
  throw new FlagError("files", "no matching files");
2788
3026
  if (values.fix)
2789
3027
  await refuseOutsideLinks(files, root, cwd);
2790
- const flags = { preset: values.preset, profile: values.profile };
2791
3028
  const perFile = await Promise.all(files.map(async (file) => {
2792
3029
  let src = await readFile3(file, "utf8");
2793
3030
  if (values.fix) {