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