@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 +2 -2
- package/dist/bin.js +283 -46
- package/dist/index.js +285 -46
- package/dist/testing.js +262 -39
- package/dist/types/check.d.ts +2 -0
- package/dist/types/errors.d.ts +4 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/parse/contrast.d.ts +34 -4
- package/dist/types/parse/stats.d.ts +13 -0
- package/dist/types/plugins/runtime.d.ts +1 -1
- package/dist/types/rules/shared.d.ts +7 -2
- package/dist/types/rules/types.d.ts +1 -1
- package/package.json +2 -2
- package/src/check.ts +40 -5
- package/src/cli.ts +19 -3
- package/src/errors.ts +15 -0
- package/src/index.ts +1 -0
- package/src/parse/contrast.ts +289 -20
- package/src/parse/stats.ts +14 -0
- package/src/plugins/load.ts +5 -2
- package/src/plugins/runtime.ts +3 -2
- package/src/rules/contrast-border-below-ui-minimum.ts +8 -5
- package/src/rules/contrast-directive-unauditable.ts +8 -6
- package/src/rules/contrast-text-below-aa.ts +16 -13
- package/src/rules/registry.ts +39 -2
- package/src/rules/shared.ts +51 -11
- package/src/rules/types.ts +4 -1
- package/src/testing.ts +2 -1
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
|
-
//
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
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";
|
package/src/parse/contrast.ts
CHANGED
|
@@ -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
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
|
|
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
|
-
|
|
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,
|
|
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:
|
|
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:
|
|
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 = (
|
|
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
|
|
169
|
-
//
|
|
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,
|
|
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:
|
|
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 => {
|
package/src/parse/stats.ts
CHANGED
|
@@ -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;
|
package/src/plugins/load.ts
CHANGED
|
@@ -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(
|
|
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
|
package/src/plugins/runtime.ts
CHANGED
|
@@ -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
|
|
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";
|