@jpeakai/mermaidjs-lint 0.4.2 → 0.5.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/testing.js CHANGED
@@ -215,6 +215,7 @@ var getMermaid = async () => {
215
215
  mermaidCore = (await import("mermaid")).default;
216
216
  return mermaidCore;
217
217
  };
218
+ var getMermaidCore = async () => await getMermaid();
218
219
  var LANGIUM_TYPES = {
219
220
  "architecture-beta": "architecture",
220
221
  architecture: "architecture",
@@ -1070,6 +1071,139 @@ var scoreForProfile = (directives, profile, keyword) => {
1070
1071
  return scoreMkdocs(directives);
1071
1072
  return keyword === "erDiagram" ? scoreEr(directives) : scoreGithub(directives);
1072
1073
  };
1074
+ var SEQUENCE_THEMES = ["base", "default", "dark", "forest", "neutral"];
1075
+ var LINE = { NOTE: 2, ACTIVE_START: 17, RECT_START: 22, AUTONUMBER: 26 };
1076
+ var FRAME_STARTS = new Set([10, 12, 15, 19, 27, 30, 32]);
1077
+ var sequenceFeatures = (db) => {
1078
+ const messages = db.getMessages();
1079
+ const has = (type) => messages.some((m) => m.type === type);
1080
+ return {
1081
+ humanActor: [...db.getActors().values()].some((a) => a.type === "actor"),
1082
+ note: has(LINE.NOTE),
1083
+ frame: messages.some((m) => FRAME_STARTS.has(m.type)),
1084
+ activation: has(LINE.ACTIVE_START),
1085
+ numbered: messages.some((m) => m.type === LINE.AUTONUMBER && m.message?.visible !== false),
1086
+ rects: messages.filter((m) => m.type === LINE.RECT_START).map((m) => String(m.message))
1087
+ };
1088
+ };
1089
+ var resolveVariables = (mermaid, config, themes) => {
1090
+ const prior = mermaid.mermaidAPI.getSiteConfig();
1091
+ try {
1092
+ const out = {};
1093
+ for (const t of THEMES) {
1094
+ mermaid.initialize({ ...structuredClone(config), theme: themes[t] });
1095
+ out[t] = { ...mermaid.mermaidAPI.getSiteConfig().themeVariables };
1096
+ }
1097
+ return out;
1098
+ } finally {
1099
+ mermaid.initialize(prior);
1100
+ }
1101
+ };
1102
+ var themeVariable = (selector, line) => ({
1103
+ kind: "themeVariables",
1104
+ selector,
1105
+ properties: {},
1106
+ spans: {},
1107
+ line
1108
+ });
1109
+ var scoreSequence = async (source, profile) => {
1110
+ const pairs = [];
1111
+ const skipped = [];
1112
+ const lines = source.split(`
1113
+ `);
1114
+ const keywordLine = lines.findIndex((l) => /^\s*sequenceDiagram\b/.test(l)) + 1;
1115
+ const lineOf = (key) => {
1116
+ const found = lines.findLastIndex((l) => new RegExp(`\\b${key}["']?\\s*:`).test(l));
1117
+ return found < 0 ? keywordLine : found + 1;
1118
+ };
1119
+ const rectLines = lines.flatMap((l, i) => /^\s*rect\s/.test(l) ? [i + 1] : []);
1120
+ const mermaid = await getMermaidCore();
1121
+ let config;
1122
+ let db;
1123
+ try {
1124
+ config = (await mermaid.parse(source)).config;
1125
+ db = (await mermaid.mermaidAPI.getDiagramFromText(source)).db;
1126
+ } catch {
1127
+ return { pairs, skipped };
1128
+ }
1129
+ const block = (selector, reason) => {
1130
+ skipped.push({ selector, line: lineOf(selector), reason, blocking: true });
1131
+ return { pairs, skipped };
1132
+ };
1133
+ if (config.themeCSS !== undefined)
1134
+ return block("themeCSS", "themeCSS restyles the diagram with CSS the audit cannot model");
1135
+ const pinned = config.theme;
1136
+ if (pinned !== undefined && !SEQUENCE_THEMES.includes(String(pinned))) {
1137
+ return block("theme", `the ${String(pinned)} theme is not modelled, only ${SEQUENCE_THEMES.join(", ")}`);
1138
+ }
1139
+ const features = sequenceFeatures(db);
1140
+ const host = profile === "mkdocs-material";
1141
+ const theme = (fallback) => pinned === undefined ? fallback : String(pinned);
1142
+ let variables;
1143
+ try {
1144
+ variables = resolveVariables(mermaid, config, { light: theme("default"), dark: theme(host ? "default" : "dark") });
1145
+ } catch (err) {
1146
+ return block("themeVariables", `Mermaid could not resolve the theme: ${message(err)}`);
1147
+ }
1148
+ for (const t of THEMES) {
1149
+ const page = (host ? MKDOCS_MATERIAL : GITHUB)[t].bg;
1150
+ const vars = variables[t];
1151
+ const color = (key) => {
1152
+ const value = vars[key];
1153
+ if (typeof value !== "string")
1154
+ throw new Error(`${key} is not a resolved colour`);
1155
+ return value;
1156
+ };
1157
+ const pair = (kind, fgKey, bg, suffix = "", at) => {
1158
+ const selector = fgKey + suffix;
1159
+ const line = at ?? lineOf(fgKey);
1160
+ try {
1161
+ const box = compositeOver("key" in bg ? color(bg.key) : bg.colour, page);
1162
+ pairs.push({ ...scoreHostPair(kind, themeVariable(selector, line), t, color(fgKey), box), advisory: host });
1163
+ } catch (err) {
1164
+ skipped.push({ selector, line, reason: `${t} sequence pair unparseable: ${message(err)}`, blocking: true });
1165
+ }
1166
+ };
1167
+ const onPage = { colour: page };
1168
+ if (host) {
1169
+ pair("border", "actorLineColor", onPage);
1170
+ pair("border", "signalColor", onPage, " (arrowhead)");
1171
+ if (features.activation)
1172
+ pair("border", "activationBorderColor", { key: "activationBkgColor" });
1173
+ continue;
1174
+ }
1175
+ pair("text", "actorTextColor", { key: "actorBkg" });
1176
+ pair("border", "actorBorder", { key: "actorBkg" });
1177
+ if (features.humanActor) {
1178
+ pair("text", "actorTextColor", onPage, " (human actor label)");
1179
+ pair("border", "actorBorder", onPage, " (human actor glyph)");
1180
+ }
1181
+ pair("border", "actorLineColor", onPage);
1182
+ pair("text", "signalTextColor", onPage);
1183
+ pair("border", "signalColor", onPage);
1184
+ if (features.note)
1185
+ pair("text", "noteTextColor", { key: "noteBkgColor" });
1186
+ if (features.frame) {
1187
+ pair("text", "labelTextColor", { key: "labelBoxBkgColor" });
1188
+ pair("text", "loopTextColor", onPage);
1189
+ pair("border", "labelBoxBorderColor", onPage, " (loop frame)");
1190
+ }
1191
+ if (features.activation)
1192
+ pair("border", "activationBorderColor", { key: "activationBkgColor" });
1193
+ if (features.numbered)
1194
+ pair("text", "sequenceNumberColor", { key: "signalColor" });
1195
+ for (const [i, rect] of features.rects.entries()) {
1196
+ const fill = { colour: rect };
1197
+ const at = rectLines[i];
1198
+ pair("text", "signalTextColor", fill, " (rect)", at);
1199
+ pair("border", "signalColor", fill, " (rect)", at);
1200
+ if (features.frame)
1201
+ pair("text", "loopTextColor", fill, " (rect)", at);
1202
+ }
1203
+ }
1204
+ return { pairs, skipped };
1205
+ };
1206
+ var scoreDiagram = (source, directives, profile, keyword) => keyword === "sequenceDiagram" ? scoreSequence(source, profile) : Promise.resolve(scoreForProfile(directives, profile, keyword));
1073
1207
  var detectProfile = (filePath) => {
1074
1208
  let dir = dirname(resolve(filePath));
1075
1209
  for (let i = 0;i < 20; i++) {
@@ -1272,7 +1406,7 @@ var contrastOf = (diagram, profile) => {
1272
1406
  }
1273
1407
  let result = byProfile.get(profile);
1274
1408
  if (!result) {
1275
- result = scoreForProfile(diagram.directives, profile, diagram.keyword);
1409
+ result = scoreDiagram(diagram.source, diagram.directives, profile, diagram.keyword);
1276
1410
  byProfile.set(profile, result);
1277
1411
  }
1278
1412
  return result;
@@ -1384,11 +1518,11 @@ var contrastBorderBelowUiMinimum = {
1384
1518
  category: "contrast",
1385
1519
  summary: "a border below 3:1 against its fill",
1386
1520
  options: CONTRAST_OPTIONS,
1387
- check: (ctx) => {
1521
+ check: async (ctx) => {
1388
1522
  const profile = profileOf(ctx);
1389
1523
  const found = [];
1390
1524
  for (const diagram of ctx.doc.diagrams) {
1391
- for (const p of contrastOf(diagram, profile).pairs) {
1525
+ for (const p of (await contrastOf(diagram, profile)).pairs) {
1392
1526
  if (p.kind !== "border" || p.passes || p.advisory)
1393
1527
  continue;
1394
1528
  const line = fileLine(diagram, p.line);
@@ -1398,7 +1532,7 @@ var contrastBorderBelowUiMinimum = {
1398
1532
  endLine: line,
1399
1533
  rule: RULE.BORDER,
1400
1534
  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}` : ""})`,
1401
- remediation: "Darken the stroke or lighten the fill until the ratio reaches 3:1."
1535
+ 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."
1402
1536
  });
1403
1537
  }
1404
1538
  }
@@ -1412,11 +1546,11 @@ var contrastDirectiveUnauditable = {
1412
1546
  category: "contrast",
1413
1547
  summary: "a style directive whose contrast cannot be audited",
1414
1548
  options: CONTRAST_OPTIONS,
1415
- check: (ctx) => {
1549
+ check: async (ctx) => {
1416
1550
  const profile = profileOf(ctx);
1417
1551
  const found = [];
1418
1552
  for (const diagram of ctx.doc.diagrams) {
1419
- for (const s of contrastOf(diagram, profile).skipped) {
1553
+ for (const s of (await contrastOf(diagram, profile)).skipped) {
1420
1554
  if (!s.blocking)
1421
1555
  continue;
1422
1556
  const line = fileLine(diagram, s.line);
@@ -1426,7 +1560,7 @@ var contrastDirectiveUnauditable = {
1426
1560
  endLine: line,
1427
1561
  rule: RULE.UNAUDITABLE,
1428
1562
  message: `${s.selector}: unaudited, ${s.reason}`,
1429
- remediation: "Declare the missing property or fix the colour so the pair can be scored."
1563
+ remediation: "Declare the missing property, fix the colour, or remove the config the audit cannot model, so the pair can be scored."
1430
1564
  });
1431
1565
  }
1432
1566
  }
@@ -1443,11 +1577,11 @@ var contrastTextBelowAa = {
1443
1577
  category: "contrast",
1444
1578
  summary: "label text below 4.5:1 against its fill",
1445
1579
  options: CONTRAST_OPTIONS,
1446
- check: (ctx) => {
1580
+ check: async (ctx) => {
1447
1581
  const profile = profileOf(ctx);
1448
1582
  const found = [];
1449
1583
  for (const diagram of ctx.doc.diagrams) {
1450
- for (const p of contrastOf(diagram, profile).pairs) {
1584
+ for (const p of (await contrastOf(diagram, profile)).pairs) {
1451
1585
  if (p.kind !== "text" || p.passes)
1452
1586
  continue;
1453
1587
  const where = [p.theme, p.row].filter(Boolean).join("/");
@@ -1458,19 +1592,19 @@ var contrastTextBelowAa = {
1458
1592
  endLine: line,
1459
1593
  rule: RULE.TEXT,
1460
1594
  message: `${p.directive.kind} ${p.selector}: text ${p.assessment.ratio.toFixed(2)}:1 < ${AA_NORMAL2} ` + `(${p.foreground} on ${p.background}${where ? `, ${where}` : ""})`,
1461
- 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."
1595
+ 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 === "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."
1462
1596
  });
1463
1597
  }
1464
1598
  }
1465
1599
  return found;
1466
1600
  },
1467
- fix: (ctx) => {
1601
+ fix: async (ctx) => {
1468
1602
  const profile = profileOf(ctx);
1469
1603
  if (profile === "mkdocs-material")
1470
1604
  return [];
1471
1605
  const edits = [];
1472
1606
  for (const diagram of ctx.doc.diagrams) {
1473
- const { pairs } = contrastOf(diagram, profile);
1607
+ const { pairs } = await contrastOf(diagram, profile);
1474
1608
  const failing = new Set(pairs.filter((p) => p.kind === "text" && !p.passes).map((p) => p.directive));
1475
1609
  for (const directive of failing) {
1476
1610
  const span = directive.spans.color;
@@ -5,10 +5,13 @@ export type PairKind = "text" | "border";
5
5
  export type ErRow = "odd" | "even";
6
6
  export declare const PROFILES: readonly ["auto", "github", "mkdocs-material"];
7
7
  export declare const edgeLabelBackground: (theme: Theme) => string;
8
+ export type ContrastSource = StyleDirective | (Omit<StyleDirective, "kind"> & {
9
+ kind: "themeVariables";
10
+ });
8
11
  export interface ContrastPair {
9
12
  kind: PairKind;
10
13
  selector: string;
11
- directive: StyleDirective;
14
+ directive: ContrastSource;
12
15
  foreground: string;
13
16
  background: string;
14
17
  line: number;
@@ -32,4 +35,25 @@ export declare const scoreGithub: (directives: StyleDirective[]) => ContrastResu
32
35
  export declare const scoreMkdocs: (directives: StyleDirective[]) => ContrastResult;
33
36
  export declare const scoreEr: (directives: StyleDirective[]) => ContrastResult;
34
37
  export declare const scoreForProfile: (directives: StyleDirective[], profile: Profile, keyword: string | null) => ContrastResult;
38
+ interface SequenceDb {
39
+ getActors: () => Map<string, {
40
+ type: string;
41
+ }>;
42
+ getMessages: () => Array<{
43
+ type: number;
44
+ message: unknown;
45
+ }>;
46
+ }
47
+ export interface SequenceFeatures {
48
+ humanActor: boolean;
49
+ note: boolean;
50
+ frame: boolean;
51
+ activation: boolean;
52
+ numbered: boolean;
53
+ rects: string[];
54
+ }
55
+ export declare const sequenceFeatures: (db: SequenceDb) => SequenceFeatures;
56
+ export declare const scoreSequence: (source: string, profile: Profile) => Promise<ContrastResult>;
57
+ export declare const scoreDiagram: (source: string, directives: StyleDirective[], profile: Profile, keyword: string | null) => Promise<ContrastResult>;
35
58
  export declare const detectProfile: (filePath: string) => Profile;
59
+ export {};
@@ -1,3 +1,16 @@
1
+ export interface MermaidCore {
2
+ parse: (text: string) => Promise<{
3
+ config: Record<string, unknown>;
4
+ }>;
5
+ initialize: (config: Record<string, unknown>) => void;
6
+ mermaidAPI: {
7
+ getSiteConfig: () => Record<string, unknown>;
8
+ getDiagramFromText: (text: string) => Promise<{
9
+ db: unknown;
10
+ }>;
11
+ };
12
+ }
13
+ export declare const getMermaidCore: () => Promise<MermaidCore>;
1
14
  export interface DiagramStats {
2
15
  nodes: number;
3
16
  edges: number;
@@ -3,4 +3,4 @@ export interface RuntimeInfo {
3
3
  features: Record<string, unknown>;
4
4
  }
5
5
  export declare const canImportTypeScript: (runtime?: RuntimeInfo) => boolean;
6
- export declare const 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";
6
+ export declare const 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";
@@ -14,5 +14,5 @@ export declare const finding: (ctx: RuleContext, diagram: Diagram, rule: RuleId,
14
14
  export declare const boundaryHint: (boundaries: string[]) => string;
15
15
  export declare const splitTarget: (c: ThresholdConfig) => string;
16
16
  export declare const profileOf: (ctx: RuleContext) => Profile;
17
- export declare const contrastOf: (diagram: Diagram, profile: Profile) => ContrastResult;
17
+ export declare const contrastOf: (diagram: Diagram, profile: Profile) => Promise<ContrastResult>;
18
18
  export declare const fileLine: (diagram: Diagram, line: number) => number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jpeakai/mermaidjs-lint",
3
- "version": "0.4.2",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "description": "Deterministic lint gates and verified fixers for Mermaid diagrams in markdown",
6
6
  "license": "MIT",
@@ -58,7 +58,7 @@
58
58
  "mdast-util-from-markdown": "^2.0.3",
59
59
  "mdast-util-frontmatter": "^2.0.1",
60
60
  "mdast-util-gfm": "^3.1.0",
61
- "mermaid": "^11.14.0",
61
+ "mermaid": "11.17.2",
62
62
  "micromark-extension-frontmatter": "^2.0.0",
63
63
  "micromark-extension-gfm": "^3.0.0"
64
64
  },
@@ -16,6 +16,7 @@
16
16
  import { existsSync } from "node:fs";
17
17
  import { dirname, join, resolve } from "node:path";
18
18
  import { alphaOf, type ContrastAssessment, compositeOver, type StyleDirective, wcagAssess } from "./color.ts";
19
+ import { getMermaidCore, type MermaidCore } from "./stats.ts";
19
20
 
20
21
  export type Profile = "github" | "mkdocs-material";
21
22
  export type Theme = "light" | "dark";
@@ -73,10 +74,14 @@ const MERMAID_ER = {
73
74
  const AA_NORMAL = 4.5;
74
75
  const AA_NON_TEXT = 3.0;
75
76
 
77
+ // What a pair was scored from: a style directive, or, in a sequence diagram,
78
+ // a theme variable, which has no directive and no value span to rewrite.
79
+ export type ContrastSource = StyleDirective | (Omit<StyleDirective, "kind"> & { kind: "themeVariables" });
80
+
76
81
  export interface ContrastPair {
77
82
  kind: PairKind;
78
83
  selector: string;
79
- directive: StyleDirective;
84
+ directive: ContrastSource;
80
85
  foreground: string;
81
86
  background: string;
82
87
  line: number; // line within the diagram text
@@ -105,7 +110,7 @@ export interface ContrastResult {
105
110
  skipped: SkippedDirective[];
106
111
  }
107
112
 
108
- const scorePair = (kind: PairKind, d: StyleDirective, fg: string, bg: string): ContrastPair => {
113
+ const scorePair = (kind: PairKind, d: ContrastSource, fg: string, bg: string): ContrastPair => {
109
114
  const assessment = wcagAssess(fg, bg);
110
115
  return {
111
116
  kind,
@@ -196,7 +201,7 @@ export const scoreGithub = (directives: StyleDirective[]): ContrastResult => {
196
201
  return { pairs, skipped };
197
202
  };
198
203
 
199
- const scoreHostPair = (kind: PairKind, d: StyleDirective, theme: Theme, fgRaw: string, box: string): ContrastPair => {
204
+ const scoreHostPair = (kind: PairKind, d: ContrastSource, theme: Theme, fgRaw: string, box: string): ContrastPair => {
200
205
  // The foreground may itself be translucent, so it composites onto the box.
201
206
  const fg = compositeOver(fgRaw, box);
202
207
  const assessment = wcagAssess(fg, box);
@@ -320,6 +325,228 @@ export const scoreForProfile = (
320
325
  return keyword === "erDiagram" ? scoreEr(directives) : scoreGithub(directives);
321
326
  };
322
327
 
328
+ // ─── Sequence diagrams ───────────────────────────────────────────────────────
329
+ //
330
+ // A sequence diagram has no style directives. Its colours are theme variables:
331
+ // some set in YAML frontmatter or an %%{init}%% directive, the rest derived by
332
+ // Mermaid's own theme calculation. So the config is read with mermaid.parse,
333
+ // which merges both the way the renderer does, and every variable is resolved
334
+ // by Mermaid's theme engine, never by a copy of its palettes. The features a
335
+ // diagram uses (human actors, notes, loops, activations, numbering, rect
336
+ // backgrounds) are read from the parsed sequence DB. The variables changed
337
+ // between Mermaid releases, so package.json pins the exact version.
338
+ //
339
+ // The SVG is transparent: message text, arrows, lifelines and loop text sit on
340
+ // the host page, and themeVariables.background paints nothing behind them.
341
+ //
342
+ // github: the default theme in light and the dark theme in dark, as GitHub
343
+ // draws them, unless the config pins a theme, which then applies to both.
344
+ //
345
+ // mkdocs-material: Material renders with Mermaid's default theme in both of its
346
+ // schemes and appends its own themeCSS, which wins over Mermaid's styles. That
347
+ // CSS sets every sequence label, box, actor, message line, note, loop frame,
348
+ // rect and number from its own --md-mermaid-* variables, so no text pair is
349
+ // the author's. Three surfaces escape it and keep the diagram's variables, as
350
+ // measured in a browser on Material 9.7 with Mermaid 11.17: lifelines
351
+ // (.actor-line beats Material's bare `line`), arrowheads (Material targets
352
+ // #arrowhead, but Mermaid 11 names it <id>-arrowhead) and activation bars.
353
+ // Each is a border, so under this profile, as for every other diagram, it is
354
+ // scored but advisory.
355
+
356
+ // The themes Mermaid resolves without per-actor colour arrays or a look of its own.
357
+ const SEQUENCE_THEMES = ["base", "default", "dark", "forest", "neutral"];
358
+
359
+ // The sequence DB's message types, from Mermaid's LINETYPE table.
360
+ const LINE = { NOTE: 2, ACTIVE_START: 17, RECT_START: 22, AUTONUMBER: 26 } as const;
361
+ // loop, alt, opt, par, critical, break and par over each open a frame.
362
+ const FRAME_STARTS = new Set([10, 12, 15, 19, 27, 30, 32]);
363
+
364
+ interface SequenceDb {
365
+ getActors: () => Map<string, { type: string }>;
366
+ getMessages: () => Array<{ type: number; message: unknown }>;
367
+ }
368
+
369
+ // What a sequence diagram draws that brings its own colour pairs.
370
+ export interface SequenceFeatures {
371
+ humanActor: boolean;
372
+ note: boolean;
373
+ frame: boolean;
374
+ activation: boolean;
375
+ numbered: boolean;
376
+ rects: string[];
377
+ }
378
+
379
+ export const sequenceFeatures = (db: SequenceDb): SequenceFeatures => {
380
+ const messages = db.getMessages();
381
+ const has = (type: number): boolean => messages.some((m) => m.type === type);
382
+ return {
383
+ humanActor: [...db.getActors().values()].some((a) => a.type === "actor"),
384
+ note: has(LINE.NOTE),
385
+ frame: messages.some((m) => FRAME_STARTS.has(m.type)),
386
+ activation: has(LINE.ACTIVE_START),
387
+ numbered: messages.some(
388
+ (m) => m.type === LINE.AUTONUMBER && (m.message as { visible?: boolean } | null)?.visible !== false,
389
+ ),
390
+ rects: messages.filter((m) => m.type === LINE.RECT_START).map((m) => String(m.message)),
391
+ };
392
+ };
393
+
394
+ type Variables = Record<string, unknown>;
395
+
396
+ // Mermaid's theme variables for a config in each theme, as its own engine
397
+ // resolves them. The site config is restored inside the same synchronous
398
+ // block, so a parse elsewhere never sees it changed. The site config is read
399
+ // rather than getConfig(), which mixes in the last parsed diagram's directives.
400
+ const resolveVariables = (
401
+ mermaid: MermaidCore,
402
+ config: Variables,
403
+ themes: Record<Theme, string>,
404
+ ): Record<Theme, Variables> => {
405
+ const prior = mermaid.mermaidAPI.getSiteConfig();
406
+ try {
407
+ const out = {} as Record<Theme, Variables>;
408
+ for (const t of THEMES) {
409
+ mermaid.initialize({ ...structuredClone(config), theme: themes[t] });
410
+ out[t] = { ...(mermaid.mermaidAPI.getSiteConfig().themeVariables as Variables) };
411
+ }
412
+ return out;
413
+ } finally {
414
+ mermaid.initialize(prior);
415
+ }
416
+ };
417
+
418
+ const themeVariable = (selector: string, line: number): ContrastSource => ({
419
+ kind: "themeVariables",
420
+ selector,
421
+ properties: {},
422
+ spans: {},
423
+ line,
424
+ });
425
+
426
+ export const scoreSequence = async (source: string, profile: Profile): Promise<ContrastResult> => {
427
+ const pairs: ContrastPair[] = [];
428
+ const skipped: SkippedDirective[] = [];
429
+ const lines = source.split("\n");
430
+ const keywordLine = lines.findIndex((l) => /^\s*sequenceDiagram\b/.test(l)) + 1;
431
+ // The line that sets a variable, or the keyword line when it is inherited.
432
+ const lineOf = (key: string): number => {
433
+ const found = lines.findLastIndex((l) => new RegExp(`\\b${key}["']?\\s*:`).test(l));
434
+ return found < 0 ? keywordLine : found + 1;
435
+ };
436
+ const rectLines = lines.flatMap((l, i) => (/^\s*rect\s/.test(l) ? [i + 1] : []));
437
+
438
+ const mermaid = await getMermaidCore();
439
+ let config: Variables;
440
+ let db: SequenceDb;
441
+ try {
442
+ config = (await mermaid.parse(source)).config;
443
+ db = (await mermaid.mermaidAPI.getDiagramFromText(source)).db as SequenceDb;
444
+ } catch {
445
+ // parse-diagram-unparseable reports it. As for complexity, a diagram the
446
+ // parser cannot read has no colours to trust.
447
+ return { pairs, skipped };
448
+ }
449
+
450
+ const block = (selector: string, reason: string): ContrastResult => {
451
+ skipped.push({ selector, line: lineOf(selector), reason, blocking: true });
452
+ return { pairs, skipped };
453
+ };
454
+ // Under mkdocs-material a diagram's themeCSS replaces Material's own, so
455
+ // neither profile can say what it draws.
456
+ if (config.themeCSS !== undefined)
457
+ return block("themeCSS", "themeCSS restyles the diagram with CSS the audit cannot model");
458
+ const pinned = config.theme;
459
+ if (pinned !== undefined && !SEQUENCE_THEMES.includes(String(pinned))) {
460
+ return block("theme", `the ${String(pinned)} theme is not modelled, only ${SEQUENCE_THEMES.join(", ")}`);
461
+ }
462
+
463
+ const features = sequenceFeatures(db);
464
+ const host = profile === "mkdocs-material";
465
+ const theme = (fallback: string): string => (pinned === undefined ? fallback : String(pinned));
466
+ let variables: Record<Theme, Variables>;
467
+ try {
468
+ variables = resolveVariables(mermaid, config, { light: theme("default"), dark: theme(host ? "default" : "dark") });
469
+ } catch (err) {
470
+ // A colour the theme derives others from, such as actorBorder, fails here.
471
+ return block("themeVariables", `Mermaid could not resolve the theme: ${message(err)}`);
472
+ }
473
+
474
+ for (const t of THEMES) {
475
+ const page = (host ? MKDOCS_MATERIAL : GITHUB)[t].bg;
476
+ const vars = variables[t];
477
+ const color = (key: string): string => {
478
+ const value = vars[key];
479
+ if (typeof value !== "string") throw new Error(`${key} is not a resolved colour`);
480
+ return value;
481
+ };
482
+ // fgKey drawn on a background: a variable, or a colour such as the page.
483
+ const pair = (
484
+ kind: PairKind,
485
+ fgKey: string,
486
+ bg: { key: string } | { colour: string },
487
+ suffix = "",
488
+ at?: number,
489
+ ) => {
490
+ const selector = fgKey + suffix;
491
+ const line = at ?? lineOf(fgKey);
492
+ try {
493
+ const box = compositeOver("key" in bg ? color(bg.key) : bg.colour, page);
494
+ pairs.push({ ...scoreHostPair(kind, themeVariable(selector, line), t, color(fgKey), box), advisory: host });
495
+ } catch (err) {
496
+ skipped.push({ selector, line, reason: `${t} sequence pair unparseable: ${message(err)}`, blocking: true });
497
+ }
498
+ };
499
+ const onPage = { colour: page };
500
+
501
+ if (host) {
502
+ pair("border", "actorLineColor", onPage);
503
+ pair("border", "signalColor", onPage, " (arrowhead)");
504
+ if (features.activation) pair("border", "activationBorderColor", { key: "activationBkgColor" });
505
+ continue;
506
+ }
507
+
508
+ pair("text", "actorTextColor", { key: "actorBkg" });
509
+ pair("border", "actorBorder", { key: "actorBkg" });
510
+ // A human actor's name and stick figure sit outside the box fill.
511
+ if (features.humanActor) {
512
+ pair("text", "actorTextColor", onPage, " (human actor label)");
513
+ pair("border", "actorBorder", onPage, " (human actor glyph)");
514
+ }
515
+ pair("border", "actorLineColor", onPage);
516
+ pair("text", "signalTextColor", onPage);
517
+ pair("border", "signalColor", onPage);
518
+ if (features.note) pair("text", "noteTextColor", { key: "noteBkgColor" });
519
+ if (features.frame) {
520
+ pair("text", "labelTextColor", { key: "labelBoxBkgColor" });
521
+ pair("text", "loopTextColor", onPage);
522
+ pair("border", "labelBoxBorderColor", onPage, " (loop frame)");
523
+ }
524
+ if (features.activation) pair("border", "activationBorderColor", { key: "activationBkgColor" });
525
+ if (features.numbered) pair("text", "sequenceNumberColor", { key: "signalColor" });
526
+ // Messages and frame text inside a rect sit on its colour, not the page.
527
+ for (const [i, rect] of features.rects.entries()) {
528
+ const fill = { colour: rect };
529
+ const at = rectLines[i];
530
+ pair("text", "signalTextColor", fill, " (rect)", at);
531
+ pair("border", "signalColor", fill, " (rect)", at);
532
+ if (features.frame) pair("text", "loopTextColor", fill, " (rect)", at);
533
+ }
534
+ }
535
+ return { pairs, skipped };
536
+ };
537
+
538
+ // Scores a diagram by its keyword: a sequence diagram by its theme variables,
539
+ // any other by its style directives.
540
+ export const scoreDiagram = (
541
+ source: string,
542
+ directives: StyleDirective[],
543
+ profile: Profile,
544
+ keyword: string | null,
545
+ ): Promise<ContrastResult> =>
546
+ keyword === "sequenceDiagram"
547
+ ? scoreSequence(source, profile)
548
+ : Promise.resolve(scoreForProfile(directives, profile, keyword));
549
+
323
550
  // An ancestor mkdocs.yml or mkdocs.yaml, searched up to 20 levels, means the
324
551
  // diagram is read through Material.
325
552
  export const detectProfile = (filePath: string): Profile => {
@@ -32,6 +32,20 @@ const getMermaid = async (): Promise<typeof import("mermaid").default> => {
32
32
  return mermaidCore;
33
33
  };
34
34
 
35
+ // The slice of mermaid core the contrast scorer reads: the config a diagram
36
+ // declares, its parsed DB, and the theme engine. Typed here without mermaid's
37
+ // own types, so the published declarations never need them.
38
+ export interface MermaidCore {
39
+ parse: (text: string) => Promise<{ config: Record<string, unknown> }>;
40
+ initialize: (config: Record<string, unknown>) => void;
41
+ mermaidAPI: {
42
+ getSiteConfig: () => Record<string, unknown>;
43
+ getDiagramFromText: (text: string) => Promise<{ db: unknown }>;
44
+ };
45
+ }
46
+
47
+ export const getMermaidCore = async (): Promise<MermaidCore> => (await getMermaid()) as unknown as MermaidCore;
48
+
35
49
  export interface DiagramStats {
36
50
  nodes: number;
37
51
  edges: number;
@@ -1,5 +1,6 @@
1
1
  // What the running JavaScript can import. Bun runs TypeScript directly, and Node
2
- // strips types from 22.18 (earlier with a flag, and not at all before 22.6). A
2
+ // strips types from 22.18 and from 23.6 (earlier with a flag, and not at all
3
+ // before 22.6). A
3
4
  // local rule written in TypeScript therefore works under `bunx --bun` or a recent
4
5
  // Node, and anywhere else it fails with a message that says how to fix it.
5
6
 
@@ -12,4 +13,4 @@ export const canImportTypeScript = (runtime: RuntimeInfo = process as unknown as
12
13
  typeof runtime.versions.bun === "string" || Boolean(runtime.features.typescript);
13
14
 
14
15
  export const TYPESCRIPT_HELP =
15
- "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";
16
+ "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";
@@ -12,11 +12,11 @@ export const contrastBorderBelowUiMinimum: Rule = {
12
12
  category: "contrast",
13
13
  summary: "a border below 3:1 against its fill",
14
14
  options: CONTRAST_OPTIONS,
15
- check: (ctx) => {
15
+ check: async (ctx) => {
16
16
  const profile = profileOf(ctx);
17
17
  const found: Finding[] = [];
18
18
  for (const diagram of ctx.doc.diagrams) {
19
- for (const p of contrastOf(diagram, profile).pairs) {
19
+ for (const p of (await contrastOf(diagram, profile)).pairs) {
20
20
  if (p.kind !== "border" || p.passes || p.advisory) continue;
21
21
  const line = fileLine(diagram, p.line);
22
22
  found.push({
@@ -27,7 +27,10 @@ export const contrastBorderBelowUiMinimum: Rule = {
27
27
  message:
28
28
  `${p.directive.kind} ${p.selector}: border ${p.assessment.ratio.toFixed(2)}:1 < ${AA_NON_TEXT} ` +
29
29
  `(${p.foreground} on ${p.background}${p.theme ? `, ${p.theme}` : ""})`,
30
- remediation: "Darken the stroke or lighten the fill until the ratio reaches 3:1.",
30
+ remediation:
31
+ p.directive.kind === "themeVariables"
32
+ ? "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."
33
+ : "Darken the stroke or lighten the fill until the ratio reaches 3:1.",
31
34
  });
32
35
  }
33
36
  }