@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/src/check.ts CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  import { FlagError } from "./errors.ts";
8
8
  import { buildDocModel, type DocModel, documentKind } from "./model.ts";
9
- import { PROFILES } from "./parse/contrast.ts";
9
+ import { type CanvasSetting, canvasColourProblem, PROFILES, THEMES, type Theme } from "./parse/contrast.ts";
10
10
  import { canonicalPreset, PRESET_NAMES } from "./parse/metrics.ts";
11
11
  import { BUILTIN, HELPERS, type Registry } from "./rules/registry.ts";
12
12
  import type { Finding, Rule, RuleOptions } from "./rules/types.ts";
@@ -16,13 +16,36 @@ import type { Finding, Rule, RuleOptions } from "./rules/types.ts";
16
16
  export interface RunFlags {
17
17
  preset?: string;
18
18
  profile?: string;
19
+ // The page canvas under the github profile, either side or both. A side
20
+ // left out keeps the configured canvas, or GitHub's.
21
+ canvas?: CanvasSetting;
19
22
  }
20
23
 
21
- // A preset or profile that no rule can take fails as a usage error, with the
22
- // code the command gives a bad flag, so a library caller can branch on it. The
23
- // command passes "--" as the prefix, so the message names its own flag.
24
+ // The name a canvas side goes by: the command's flag, or the RunFlags field.
25
+ const canvasName = (side: Theme, prefix: string): string => (prefix ? `${prefix}canvas-${side}` : `canvas.${side}`);
26
+
27
+ // A preset, profile or canvas that no rule can take fails as a usage error,
28
+ // with the code the command gives a bad flag, so a library caller can branch on
29
+ // it. The command passes "--" as the prefix, so the message names its own flag.
24
30
  export const checkRunFlags = (flags: RunFlags, prefix = ""): void => {
25
- const { preset, profile } = flags;
31
+ const { preset, profile, canvas } = flags;
32
+ for (const side of THEMES) {
33
+ if (canvas?.[side] === undefined) continue;
34
+ const problem = canvasColourProblem(canvas[side]);
35
+ if (problem) {
36
+ const name = canvasName(side, prefix);
37
+ throw new FlagError(name, `${name} ${problem}: "${canvas[side]}"; give an opaque CSS colour, such as #ffffff`);
38
+ }
39
+ }
40
+ const sides = THEMES.filter((side) => canvas?.[side] !== undefined);
41
+ const first = sides[0];
42
+ if (profile === "mkdocs-material" && first !== undefined) {
43
+ const name = canvasName(first, prefix);
44
+ throw new FlagError(
45
+ name,
46
+ `${name} applies under the github profile only; mkdocs-material scores Material's own page colours`,
47
+ );
48
+ }
26
49
  if (preset !== undefined && canonicalPreset(preset) === null) {
27
50
  throw new FlagError(
28
51
  `${prefix}preset`,
@@ -44,6 +67,18 @@ export const optionsFor = (rule: Rule, configured: RuleOptions | undefined, flag
44
67
  if (value !== undefined && rule.options?.[name]) options[name] = value;
45
68
  }
46
69
  if (typeof options.preset === "string") options.preset = canonicalPreset(options.preset) ?? options.preset;
70
+ // A canvas flag sets one side over the configured canvas, so --canvas-dark
71
+ // alone keeps a configured light side.
72
+ const canvas = flags.canvas;
73
+ if (canvas && THEMES.some((side) => canvas[side] !== undefined) && rule.options?.canvas === "canvas") {
74
+ const configured = typeof options.canvas === "object" && options.canvas !== null ? options.canvas : {};
75
+ const merged: Record<string, string> = { ...(configured as Record<string, string>) };
76
+ for (const side of THEMES) {
77
+ const value = canvas[side];
78
+ if (value !== undefined) merged[side] = value;
79
+ }
80
+ options.canvas = merged;
81
+ }
47
82
  return options;
48
83
  };
49
84
 
package/src/cli.ts CHANGED
@@ -21,7 +21,7 @@ import { CATEGORIES, type Finding, type Rule } from "./rules/types.ts";
21
21
  // The invocation forms, printed alone after a usage error so the error is not
22
22
  // buried, and first in --help.
23
23
  const SYNOPSIS = `usage: mermaidjs-lint <path...> [--fix] [--json] [--preset NAME] [--profile NAME] [--config FILE]
24
- [--no-plugins | --lenient-plugins]
24
+ [--canvas-light COLOUR] [--canvas-dark COLOUR] [--no-plugins | --lenient-plugins]
25
25
  mermaidjs-lint --list-rules
26
26
  mermaidjs-lint --validate-plugins [--json] [--config FILE]
27
27
  mermaidjs-lint --version | -h, --help`;
@@ -52,6 +52,10 @@ Examples of every rule, with fixes: https://jpeakai.github.io/jpai-mermaidjs-lin
52
52
  numeric threshold set in the config overrides both.
53
53
  --profile NAME sets how contrast is read: ${PROFILES.join(", ")}. It overrides a profile set in the config.
54
54
  auto reads mkdocs-material when any parent folder holds mkdocs.yml or mkdocs.yaml, else github.
55
+ --canvas-light COLOUR and --canvas-dark COLOUR set the page a host draws in each theme, under the github
56
+ profile only. A translucent fill, an edge label box and a sequence diagram's page-backed text are scored
57
+ on it. Each is an opaque CSS colour such as #faf8f9, and a side not given keeps the config's, or GitHub's
58
+ (#ffffff light, #0d1117 dark). A file scored under mkdocs-material refuses a canvas with a usage error.
55
59
  --json prints one JSON object instead of text lines.
56
60
  --fix applies every fix it can prove safe, then reports what remains.
57
61
  A fixer that is unsure leaves the text alone and the finding stays.
@@ -75,6 +79,8 @@ interface Parsed {
75
79
  help?: boolean;
76
80
  preset?: string;
77
81
  profile?: string;
82
+ "canvas-light"?: string;
83
+ "canvas-dark"?: string;
78
84
  config?: string;
79
85
  "no-plugins"?: boolean;
80
86
  "lenient-plugins"?: boolean;
@@ -94,6 +100,8 @@ const parse = (argv: string[]): Parsed =>
94
100
  help: { type: "boolean", short: "h", default: false },
95
101
  preset: { type: "string", short: "p" },
96
102
  profile: { type: "string" },
103
+ "canvas-light": { type: "string" },
104
+ "canvas-dark": { type: "string" },
97
105
  config: { type: "string" },
98
106
  "no-plugins": { type: "boolean", default: false },
99
107
  "lenient-plugins": { type: "boolean", default: false },
@@ -221,7 +229,16 @@ const run = async (argv: string[], cwd: string): Promise<number> => {
221
229
  }
222
230
  const listing = values["list-rules"];
223
231
  const validating = values["validate-plugins"];
224
- checkRunFlags({ preset: values.preset, profile: values.profile }, "--");
232
+ const canvas = {
233
+ ...(values["canvas-light"] !== undefined && { light: values["canvas-light"] }),
234
+ ...(values["canvas-dark"] !== undefined && { dark: values["canvas-dark"] }),
235
+ };
236
+ const flags: RunFlags = {
237
+ preset: values.preset,
238
+ profile: values.profile,
239
+ ...(Object.keys(canvas).length > 0 && { canvas }),
240
+ };
241
+ checkRunFlags(flags, "--");
225
242
  if (values["no-plugins"] && values["lenient-plugins"]) {
226
243
  throw new FlagError("--lenient-plugins", "--no-plugins and --lenient-plugins contradict each other");
227
244
  }
@@ -268,7 +285,6 @@ const run = async (argv: string[], cwd: string): Promise<number> => {
268
285
  // rule that reads the path sees one form, and the auto profile is found
269
286
  // from the file's own folder whatever the process's working directory. The
270
287
  // report shows the path relative to cwd.
271
- const flags: RunFlags = { preset: values.preset, profile: values.profile };
272
288
  const perFile = await Promise.all(
273
289
  files.map(async (file) => {
274
290
  let src = await readFile(file, "utf8");
package/src/errors.ts CHANGED
@@ -130,6 +130,21 @@ export class RuleOptionError extends UsageError {
130
130
  }
131
131
  }
132
132
 
133
+ // A canvas was set for a file scored under mkdocs-material, whose page is
134
+ // Material's own, so the canvas would change nothing. Ignoring it would report
135
+ // a pass the caller did not ask for, so the run stops instead.
136
+ export class CanvasProfileError extends UsageError {
137
+ readonly file: string;
138
+
139
+ constructor(file: string) {
140
+ super(
141
+ `${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`,
142
+ "canvas-profile",
143
+ );
144
+ this.file = file;
145
+ }
146
+ }
147
+
133
148
  // --fix would write through a symbolic link whose target sits outside the
134
149
  // project root, so the run stops before any file is written.
135
150
  export class FixOutsideRootError extends UsageError {
package/src/index.ts CHANGED
@@ -8,6 +8,7 @@ export { type Collected, collectFiles } from "./files.ts";
8
8
  export { type FixReport, fixSource, fixSourceReport } from "./fix/engine.ts";
9
9
  export { buildDocModel, DIAGRAM_EXTS, type Diagram, type DocModel, documentKind } from "./model.ts";
10
10
  export type { ContrastAssessment, StyleDirective, ValueSpan } from "./parse/color.ts";
11
+ export { type Canvas, type CanvasSetting, GITHUB_CANVAS } from "./parse/contrast.ts";
11
12
  export { PRESETS, type Preset, type ThresholdConfig, thresholdsFrom } from "./parse/metrics.ts";
12
13
  export { type DiagramStats, detectKeyword, extractStats } from "./parse/stats.ts";
13
14
  export type { Plugin, PluginEdit, PluginFinding, PluginMeta, PluginRule } from "./plugins/contract.ts";
@@ -8,7 +8,9 @@
8
8
  // color: is edge label text, scored on Mermaid's own label box under github.
9
9
  //
10
10
  // Profiles, by who controls the label text:
11
- // github the author does, so the declared pairs are scored as written
11
+ // github the author does, so the declared pairs are scored as written,
12
+ // and anything page-backed is scored on GitHub's canvas or
13
+ // on the canvas the caller sets for its own host
12
14
  // mkdocs-material the host theme forces the text and ignores color:, so each
13
15
  // fill is composited over both theme backgrounds and the
14
16
  // host text is scored in light and dark
@@ -16,6 +18,7 @@
16
18
  import { existsSync } from "node:fs";
17
19
  import { dirname, join, resolve } from "node:path";
18
20
  import { alphaOf, type ContrastAssessment, compositeOver, type StyleDirective, wcagAssess } from "./color.ts";
21
+ import { getMermaidCore, type MermaidCore } from "./stats.ts";
19
22
 
20
23
  export type Profile = "github" | "mkdocs-material";
21
24
  export type Theme = "light" | "dark";
@@ -30,12 +33,38 @@ const MKDOCS_MATERIAL = {
30
33
  dark: { bg: "#1e2129", text: "hsl(225 18% 86% / 0.82)" },
31
34
  } as const;
32
35
 
33
- // GitHub's page canvas, which a translucent fill composites against. The dark
34
- // value is measured from a rendered diagram: the Mermaid container paints no
35
- // surface of its own, so the canvas is the true backdrop.
36
- const GITHUB = { light: { bg: "#ffffff" }, dark: { bg: "#0d1117" } } as const;
36
+ // The page canvas in each theme: the colour behind everything the diagram does
37
+ // not box. A translucent fill composites against it, and page-backed text, such
38
+ // as an edge label's box or a sequence message, is scored on it.
39
+ export type Canvas = Readonly<Record<Theme, string>>;
37
40
 
38
- const THEMES: Theme[] = ["light", "dark"];
41
+ // A canvas as a caller sets it: either side, or both. A side left out keeps
42
+ // GitHub's.
43
+ export type CanvasSetting = Readonly<Partial<Record<Theme, string>>>;
44
+
45
+ // GitHub's page canvas, the default under the github profile. The dark value is
46
+ // measured from a rendered diagram: the Mermaid container paints no surface of
47
+ // its own, so the canvas is the true backdrop. A host that draws its own page,
48
+ // such as a documentation viewer with its own theme, sets its own canvas.
49
+ export const GITHUB_CANVAS: Canvas = { light: "#ffffff", dark: "#0d1117" };
50
+
51
+ export const THEMES: readonly Theme[] = ["light", "dark"];
52
+
53
+ // Why a canvas colour cannot be used, or null when it can. A canvas is a page,
54
+ // so it must be an opaque colour: nothing shows through it to composite onto.
55
+ export const canvasColourProblem = (value: unknown): string | null => {
56
+ if (typeof value !== "string") return "is not a colour string";
57
+ let alpha: number;
58
+ try {
59
+ alpha = alphaOf(value);
60
+ } catch {
61
+ return "is not a CSS colour";
62
+ }
63
+ return alpha < 1 ? "is translucent, and a page canvas must be opaque" : null;
64
+ };
65
+
66
+ // The canvas a setting resolves to, with GitHub's for any side it leaves out.
67
+ export const resolveCanvas = (setting: CanvasSetting | undefined): Canvas => ({ ...GITHUB_CANVAS, ...setting });
39
68
 
40
69
  // The box an edge label is drawn on, read off Mermaid 11's theme sources. A
41
70
  // linkStyle color: becomes an inline `color: ... !important` on the label's
@@ -46,7 +75,7 @@ const THEMES: Theme[] = ["light", "dark"];
46
75
  // and lighten(#181818, 25) = #585858 in dark, opaque. GitHub draws the default
47
76
  // theme in light mode and the dark theme in dark mode, the same assumption the
48
77
  // erDiagram rows make, so the stack is composited over the matching canvas:
49
- // #e8e8e8 in light and #585858 in dark. No one colour clears 4.5:1 on both, so
78
+ // #e8e8e8 in light and #585858 in dark on GitHub's. No one colour clears 4.5:1 on both, so
50
79
  // a fixed edge label colour always fails one theme. Under mkdocs-material the
51
80
  // host sets `.edgeLabel p { color: var(--md-mermaid-edge-color) }` on the p
52
81
  // itself, which beats the colour the p would inherit, so color: is ignored
@@ -56,9 +85,9 @@ const MERMAID_EDGE_LABEL = {
56
85
  dark: { box: "#585858", backing: "rgba(88,88,88,0.5)" },
57
86
  } as const;
58
87
 
59
- export const edgeLabelBackground = (theme: Theme): string => {
88
+ export const edgeLabelBackground = (theme: Theme, canvas: Canvas = GITHUB_CANVAS): string => {
60
89
  const { box, backing } = MERMAID_EDGE_LABEL[theme];
61
- return compositeOver(box, compositeOver(box, compositeOver(backing, GITHUB[theme].bg)));
90
+ return compositeOver(box, compositeOver(box, compositeOver(backing, canvas[theme])));
62
91
  };
63
92
 
64
93
  // Mermaid's own erDiagram row surfaces under its default and dark themes. A
@@ -73,10 +102,14 @@ const MERMAID_ER = {
73
102
  const AA_NORMAL = 4.5;
74
103
  const AA_NON_TEXT = 3.0;
75
104
 
105
+ // What a pair was scored from: a style directive, or, in a sequence diagram,
106
+ // a theme variable, which has no directive and no value span to rewrite.
107
+ export type ContrastSource = StyleDirective | (Omit<StyleDirective, "kind"> & { kind: "themeVariables" });
108
+
76
109
  export interface ContrastPair {
77
110
  kind: PairKind;
78
111
  selector: string;
79
- directive: StyleDirective;
112
+ directive: ContrastSource;
80
113
  foreground: string;
81
114
  background: string;
82
115
  line: number; // line within the diagram text
@@ -105,7 +138,7 @@ export interface ContrastResult {
105
138
  skipped: SkippedDirective[];
106
139
  }
107
140
 
108
- const scorePair = (kind: PairKind, d: StyleDirective, fg: string, bg: string): ContrastPair => {
141
+ const scorePair = (kind: PairKind, d: ContrastSource, fg: string, bg: string): ContrastPair => {
109
142
  const assessment = wcagAssess(fg, bg);
110
143
  return {
111
144
  kind,
@@ -123,11 +156,16 @@ const message = (err: unknown): string => (err instanceof Error ? err.message :
123
156
 
124
157
  // A linkStyle color: is edge label text, scored on the label box in both themes.
125
158
  // A linkStyle without one styles only the line, which has no label pair.
126
- const scoreEdgeLabel = (d: StyleDirective, pairs: ContrastPair[], skipped: SkippedDirective[]): void => {
159
+ const scoreEdgeLabel = (
160
+ d: StyleDirective,
161
+ canvas: Canvas,
162
+ pairs: ContrastPair[],
163
+ skipped: SkippedDirective[],
164
+ ): void => {
127
165
  const { color } = d.properties;
128
166
  if (!color) return;
129
167
  try {
130
- pairs.push(...THEMES.map((t) => scoreHostPair("text", d, t, color, edgeLabelBackground(t))));
168
+ pairs.push(...THEMES.map((t) => scoreHostPair("text", d, t, color, edgeLabelBackground(t, canvas))));
131
169
  } catch (err) {
132
170
  skipped.push({
133
171
  selector: d.selector,
@@ -138,11 +176,11 @@ const scoreEdgeLabel = (d: StyleDirective, pairs: ContrastPair[], skipped: Skipp
138
176
  }
139
177
  };
140
178
 
141
- export const scoreGithub = (directives: StyleDirective[]): ContrastResult => {
179
+ export const scoreGithub = (directives: StyleDirective[], canvas: Canvas = GITHUB_CANVAS): ContrastResult => {
142
180
  const pairs: ContrastPair[] = [];
143
181
  const skipped: SkippedDirective[] = [];
144
182
  for (const d of directives) {
145
- if (d.kind === "linkStyle") scoreEdgeLabel(d, pairs, skipped);
183
+ if (d.kind === "linkStyle") scoreEdgeLabel(d, canvas, pairs, skipped);
146
184
  if (d.kind !== "classDef" && d.kind !== "style") continue;
147
185
  const { fill, color, stroke } = d.properties;
148
186
  const note = (reason: string, blocking?: boolean): void => {
@@ -165,10 +203,10 @@ export const scoreGithub = (directives: StyleDirective[]): ContrastResult => {
165
203
  note("fill is fully transparent, so there is no box to anchor contrast");
166
204
  continue;
167
205
  }
168
- // A translucent fill is scored on both GitHub canvases and the worse result
169
- // gates. An opaque fill resolves the same on both and keeps one pair.
206
+ // A translucent fill is scored on both canvases and the worse result gates.
207
+ // An opaque fill resolves the same on both and keeps one pair.
170
208
  const boxes: Array<[string, Theme | undefined]> =
171
- alpha < 1 ? THEMES.map((t): [string, Theme] => [compositeOver(fill, GITHUB[t].bg), t]) : [[fill, undefined]];
209
+ alpha < 1 ? THEMES.map((t): [string, Theme] => [compositeOver(fill, canvas[t]), t]) : [[fill, undefined]];
172
210
  if (!color) {
173
211
  note(
174
212
  "no color: declared beside fill:, and the github profile requires one " +
@@ -196,7 +234,7 @@ export const scoreGithub = (directives: StyleDirective[]): ContrastResult => {
196
234
  return { pairs, skipped };
197
235
  };
198
236
 
199
- const scoreHostPair = (kind: PairKind, d: StyleDirective, theme: Theme, fgRaw: string, box: string): ContrastPair => {
237
+ const scoreHostPair = (kind: PairKind, d: ContrastSource, theme: Theme, fgRaw: string, box: string): ContrastPair => {
200
238
  // The foreground may itself be translucent, so it composites onto the box.
201
239
  const fg = compositeOver(fgRaw, box);
202
240
  const assessment = wcagAssess(fg, box);
@@ -311,15 +349,246 @@ export const scoreEr = (directives: StyleDirective[]): ContrastResult => {
311
349
  return { pairs, skipped };
312
350
  };
313
351
 
352
+ // The canvas applies under github only. Material draws its own page, so the
353
+ // rules refuse a canvas under mkdocs-material before scoring (see shared.ts).
354
+ // An erDiagram's rows are Mermaid's own opaque surfaces, so no canvas shows.
314
355
  export const scoreForProfile = (
315
356
  directives: StyleDirective[],
316
357
  profile: Profile,
317
358
  keyword: string | null,
359
+ canvas: Canvas = GITHUB_CANVAS,
318
360
  ): ContrastResult => {
319
361
  if (profile === "mkdocs-material") return scoreMkdocs(directives);
320
- return keyword === "erDiagram" ? scoreEr(directives) : scoreGithub(directives);
362
+ return keyword === "erDiagram" ? scoreEr(directives) : scoreGithub(directives, canvas);
321
363
  };
322
364
 
365
+ // ─── Sequence diagrams ───────────────────────────────────────────────────────
366
+ //
367
+ // A sequence diagram has no style directives. Its colours are theme variables:
368
+ // some set in YAML frontmatter or an %%{init}%% directive, the rest derived by
369
+ // Mermaid's own theme calculation. So the config is read with mermaid.parse,
370
+ // which merges both the way the renderer does, and every variable is resolved
371
+ // by Mermaid's theme engine, never by a copy of its palettes. The features a
372
+ // diagram uses (human actors, notes, loops, activations, numbering, rect
373
+ // backgrounds) are read from the parsed sequence DB. The variables changed
374
+ // between Mermaid releases, so package.json pins the exact version.
375
+ //
376
+ // The SVG is transparent: message text, arrows, lifelines and loop text sit on
377
+ // the host page, and themeVariables.background paints nothing behind them.
378
+ //
379
+ // github: the default theme in light and the dark theme in dark, as GitHub
380
+ // draws them, unless the config pins a theme, which then applies to both.
381
+ //
382
+ // mkdocs-material: Material renders with Mermaid's default theme in both of its
383
+ // schemes and appends its own themeCSS, which wins over Mermaid's styles. That
384
+ // CSS sets every sequence label, box, actor, message line, note, loop frame,
385
+ // rect and number from its own --md-mermaid-* variables, so no text pair is
386
+ // the author's. Three surfaces escape it and keep the diagram's variables, as
387
+ // measured in a browser on Material 9.7 with Mermaid 11.17: lifelines
388
+ // (.actor-line beats Material's bare `line`), arrowheads (Material targets
389
+ // #arrowhead, but Mermaid 11 names it <id>-arrowhead) and activation bars.
390
+ // Each is a border, so under this profile, as for every other diagram, it is
391
+ // scored but advisory.
392
+
393
+ // The themes Mermaid resolves without per-actor colour arrays or a look of its own.
394
+ const SEQUENCE_THEMES = ["base", "default", "dark", "forest", "neutral"];
395
+
396
+ // The sequence DB's message types, from Mermaid's LINETYPE table.
397
+ const LINE = { NOTE: 2, ACTIVE_START: 17, RECT_START: 22, AUTONUMBER: 26 } as const;
398
+ // loop, alt, opt, par, critical, break and par over each open a frame.
399
+ const FRAME_STARTS = new Set([10, 12, 15, 19, 27, 30, 32]);
400
+
401
+ interface SequenceDb {
402
+ getActors: () => Map<string, { type: string }>;
403
+ getMessages: () => Array<{ type: number; message: unknown }>;
404
+ }
405
+
406
+ // What a sequence diagram draws that brings its own colour pairs.
407
+ export interface SequenceFeatures {
408
+ humanActor: boolean;
409
+ note: boolean;
410
+ frame: boolean;
411
+ activation: boolean;
412
+ numbered: boolean;
413
+ rects: string[];
414
+ }
415
+
416
+ export const sequenceFeatures = (db: SequenceDb): SequenceFeatures => {
417
+ const messages = db.getMessages();
418
+ const has = (type: number): boolean => messages.some((m) => m.type === type);
419
+ return {
420
+ humanActor: [...db.getActors().values()].some((a) => a.type === "actor"),
421
+ note: has(LINE.NOTE),
422
+ frame: messages.some((m) => FRAME_STARTS.has(m.type)),
423
+ activation: has(LINE.ACTIVE_START),
424
+ numbered: messages.some(
425
+ (m) => m.type === LINE.AUTONUMBER && (m.message as { visible?: boolean } | null)?.visible !== false,
426
+ ),
427
+ rects: messages.filter((m) => m.type === LINE.RECT_START).map((m) => String(m.message)),
428
+ };
429
+ };
430
+
431
+ type Variables = Record<string, unknown>;
432
+
433
+ // Mermaid's theme variables for a config in each theme, as its own engine
434
+ // resolves them. The site config is restored inside the same synchronous
435
+ // block, so a parse elsewhere never sees it changed. The site config is read
436
+ // rather than getConfig(), which mixes in the last parsed diagram's directives.
437
+ const resolveVariables = (
438
+ mermaid: MermaidCore,
439
+ config: Variables,
440
+ themes: Record<Theme, string>,
441
+ ): Record<Theme, Variables> => {
442
+ const prior = mermaid.mermaidAPI.getSiteConfig();
443
+ try {
444
+ const out = {} as Record<Theme, Variables>;
445
+ for (const t of THEMES) {
446
+ mermaid.initialize({ ...structuredClone(config), theme: themes[t] });
447
+ out[t] = { ...(mermaid.mermaidAPI.getSiteConfig().themeVariables as Variables) };
448
+ }
449
+ return out;
450
+ } finally {
451
+ mermaid.initialize(prior);
452
+ }
453
+ };
454
+
455
+ const themeVariable = (selector: string, line: number): ContrastSource => ({
456
+ kind: "themeVariables",
457
+ selector,
458
+ properties: {},
459
+ spans: {},
460
+ line,
461
+ });
462
+
463
+ export const scoreSequence = async (
464
+ source: string,
465
+ profile: Profile,
466
+ canvas: Canvas = GITHUB_CANVAS,
467
+ ): Promise<ContrastResult> => {
468
+ const pairs: ContrastPair[] = [];
469
+ const skipped: SkippedDirective[] = [];
470
+ const lines = source.split("\n");
471
+ const keywordLine = lines.findIndex((l) => /^\s*sequenceDiagram\b/.test(l)) + 1;
472
+ // The line that sets a variable, or the keyword line when it is inherited.
473
+ const lineOf = (key: string): number => {
474
+ const found = lines.findLastIndex((l) => new RegExp(`\\b${key}["']?\\s*:`).test(l));
475
+ return found < 0 ? keywordLine : found + 1;
476
+ };
477
+ const rectLines = lines.flatMap((l, i) => (/^\s*rect\s/.test(l) ? [i + 1] : []));
478
+
479
+ const mermaid = await getMermaidCore();
480
+ let config: Variables;
481
+ let db: SequenceDb;
482
+ try {
483
+ config = (await mermaid.parse(source)).config;
484
+ db = (await mermaid.mermaidAPI.getDiagramFromText(source)).db as SequenceDb;
485
+ } catch {
486
+ // parse-diagram-unparseable reports it. As for complexity, a diagram the
487
+ // parser cannot read has no colours to trust.
488
+ return { pairs, skipped };
489
+ }
490
+
491
+ const block = (selector: string, reason: string): ContrastResult => {
492
+ skipped.push({ selector, line: lineOf(selector), reason, blocking: true });
493
+ return { pairs, skipped };
494
+ };
495
+ // Under mkdocs-material a diagram's themeCSS replaces Material's own, so
496
+ // neither profile can say what it draws.
497
+ if (config.themeCSS !== undefined)
498
+ return block("themeCSS", "themeCSS restyles the diagram with CSS the audit cannot model");
499
+ const pinned = config.theme;
500
+ if (pinned !== undefined && !SEQUENCE_THEMES.includes(String(pinned))) {
501
+ return block("theme", `the ${String(pinned)} theme is not modelled, only ${SEQUENCE_THEMES.join(", ")}`);
502
+ }
503
+
504
+ const features = sequenceFeatures(db);
505
+ const host = profile === "mkdocs-material";
506
+ const theme = (fallback: string): string => (pinned === undefined ? fallback : String(pinned));
507
+ let variables: Record<Theme, Variables>;
508
+ try {
509
+ variables = resolveVariables(mermaid, config, { light: theme("default"), dark: theme(host ? "default" : "dark") });
510
+ } catch (err) {
511
+ // A colour the theme derives others from, such as actorBorder, fails here.
512
+ return block("themeVariables", `Mermaid could not resolve the theme: ${message(err)}`);
513
+ }
514
+
515
+ for (const t of THEMES) {
516
+ const page = host ? MKDOCS_MATERIAL[t].bg : canvas[t];
517
+ const vars = variables[t];
518
+ const color = (key: string): string => {
519
+ const value = vars[key];
520
+ if (typeof value !== "string") throw new Error(`${key} is not a resolved colour`);
521
+ return value;
522
+ };
523
+ // fgKey drawn on a background: a variable, or a colour such as the page.
524
+ const pair = (
525
+ kind: PairKind,
526
+ fgKey: string,
527
+ bg: { key: string } | { colour: string },
528
+ suffix = "",
529
+ at?: number,
530
+ ) => {
531
+ const selector = fgKey + suffix;
532
+ const line = at ?? lineOf(fgKey);
533
+ try {
534
+ const box = compositeOver("key" in bg ? color(bg.key) : bg.colour, page);
535
+ pairs.push({ ...scoreHostPair(kind, themeVariable(selector, line), t, color(fgKey), box), advisory: host });
536
+ } catch (err) {
537
+ skipped.push({ selector, line, reason: `${t} sequence pair unparseable: ${message(err)}`, blocking: true });
538
+ }
539
+ };
540
+ const onPage = { colour: page };
541
+
542
+ if (host) {
543
+ pair("border", "actorLineColor", onPage);
544
+ pair("border", "signalColor", onPage, " (arrowhead)");
545
+ if (features.activation) pair("border", "activationBorderColor", { key: "activationBkgColor" });
546
+ continue;
547
+ }
548
+
549
+ pair("text", "actorTextColor", { key: "actorBkg" });
550
+ pair("border", "actorBorder", { key: "actorBkg" });
551
+ // A human actor's name and stick figure sit outside the box fill.
552
+ if (features.humanActor) {
553
+ pair("text", "actorTextColor", onPage, " (human actor label)");
554
+ pair("border", "actorBorder", onPage, " (human actor glyph)");
555
+ }
556
+ pair("border", "actorLineColor", onPage);
557
+ pair("text", "signalTextColor", onPage);
558
+ pair("border", "signalColor", onPage);
559
+ if (features.note) pair("text", "noteTextColor", { key: "noteBkgColor" });
560
+ if (features.frame) {
561
+ pair("text", "labelTextColor", { key: "labelBoxBkgColor" });
562
+ pair("text", "loopTextColor", onPage);
563
+ pair("border", "labelBoxBorderColor", onPage, " (loop frame)");
564
+ }
565
+ if (features.activation) pair("border", "activationBorderColor", { key: "activationBkgColor" });
566
+ if (features.numbered) pair("text", "sequenceNumberColor", { key: "signalColor" });
567
+ // Messages and frame text inside a rect sit on its colour, not the page.
568
+ for (const [i, rect] of features.rects.entries()) {
569
+ const fill = { colour: rect };
570
+ const at = rectLines[i];
571
+ pair("text", "signalTextColor", fill, " (rect)", at);
572
+ pair("border", "signalColor", fill, " (rect)", at);
573
+ if (features.frame) pair("text", "loopTextColor", fill, " (rect)", at);
574
+ }
575
+ }
576
+ return { pairs, skipped };
577
+ };
578
+
579
+ // Scores a diagram by its keyword: a sequence diagram by its theme variables,
580
+ // any other by its style directives.
581
+ export const scoreDiagram = (
582
+ source: string,
583
+ directives: StyleDirective[],
584
+ profile: Profile,
585
+ keyword: string | null,
586
+ canvas: Canvas = GITHUB_CANVAS,
587
+ ): Promise<ContrastResult> =>
588
+ keyword === "sequenceDiagram"
589
+ ? scoreSequence(source, profile, canvas)
590
+ : Promise.resolve(scoreForProfile(directives, profile, keyword, canvas));
591
+
323
592
  // An ancestor mkdocs.yml or mkdocs.yaml, searched up to 20 levels, means the
324
593
  // diagram is read through Material.
325
594
  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;
@@ -47,7 +47,7 @@ const importModule = async (source: PluginSource, runtime?: RuntimeInfo): Promis
47
47
  }
48
48
  };
49
49
 
50
- const OPTION_TYPES = new Set<OptionType>(["number", "string", "boolean", "string[]"]);
50
+ const OPTION_TYPES = new Set<OptionType>(["number", "string", "boolean", "string[]", "canvas"]);
51
51
 
52
52
  // Option names every rule shares with the built-ins. See checkRule.
53
53
  const RESERVED_OPTIONS = ["preset", "profile"] as const;
@@ -81,7 +81,10 @@ const checkRule = (spec: string, key: string, raw: unknown, own: ReadonlySet<str
81
81
  if (options !== undefined) {
82
82
  const valid = isRecord(options) && Object.values(options).every((t) => OPTION_TYPES.has(t as OptionType));
83
83
  if (!valid) {
84
- throw new PluginContractError(spec, `${where} options must map names to number, string, boolean or string[]`);
84
+ throw new PluginContractError(
85
+ spec,
86
+ `${where} options must map names to number, string, boolean, string[] or canvas`,
87
+ );
85
88
  }
86
89
  // preset and profile keep their built-in meaning for every rule: the
87
90
  // config checks the value against the built-in names, and --preset and
@@ -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";