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