@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
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { CONTRAST_OPTIONS, contrastOf, fileLine,
|
|
1
|
+
import { CONTRAST_OPTIONS, contrastOf, fileLine, settingsOf } from "./shared.ts";
|
|
2
2
|
import { type Finding, RULE, type Rule } from "./types.ts";
|
|
3
3
|
|
|
4
4
|
const AA_NON_TEXT = 3;
|
|
@@ -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) => {
|
|
16
|
-
const
|
|
15
|
+
check: async (ctx) => {
|
|
16
|
+
const settings = settingsOf(ctx);
|
|
17
17
|
const found: Finding[] = [];
|
|
18
18
|
for (const diagram of ctx.doc.diagrams) {
|
|
19
|
-
for (const p of contrastOf(diagram,
|
|
19
|
+
for (const p of (await contrastOf(diagram, settings)).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
|
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { CONTRAST_OPTIONS, contrastOf, fileLine,
|
|
1
|
+
import { CONTRAST_OPTIONS, contrastOf, fileLine, settingsOf } from "./shared.ts";
|
|
2
2
|
import { type Finding, RULE, type Rule } from "./types.ts";
|
|
3
3
|
|
|
4
4
|
// A directive the profile requires to be scoreable but cannot be: a github fill
|
|
5
|
-
// with no color:,
|
|
5
|
+
// with no color:, a colour that will not parse, or a sequence diagram config
|
|
6
|
+
// the audit does not model, such as themeCSS or a theme outside the five. Reporting it as an
|
|
6
7
|
// informational skip is how a gate exits clean on a diagram it never checked,
|
|
7
8
|
// so it is a failure to audit.
|
|
8
9
|
export const contrastDirectiveUnauditable: Rule = {
|
|
@@ -10,11 +11,11 @@ export const contrastDirectiveUnauditable: Rule = {
|
|
|
10
11
|
category: "contrast",
|
|
11
12
|
summary: "a style directive whose contrast cannot be audited",
|
|
12
13
|
options: CONTRAST_OPTIONS,
|
|
13
|
-
check: (ctx) => {
|
|
14
|
-
const
|
|
14
|
+
check: async (ctx) => {
|
|
15
|
+
const settings = settingsOf(ctx);
|
|
15
16
|
const found: Finding[] = [];
|
|
16
17
|
for (const diagram of ctx.doc.diagrams) {
|
|
17
|
-
for (const s of contrastOf(diagram,
|
|
18
|
+
for (const s of (await contrastOf(diagram, settings)).skipped) {
|
|
18
19
|
if (!s.blocking) continue;
|
|
19
20
|
const line = fileLine(diagram, s.line);
|
|
20
21
|
found.push({
|
|
@@ -23,7 +24,8 @@ export const contrastDirectiveUnauditable: Rule = {
|
|
|
23
24
|
endLine: line,
|
|
24
25
|
rule: RULE.UNAUDITABLE,
|
|
25
26
|
message: `${s.selector}: unaudited, ${s.reason}`,
|
|
26
|
-
remediation:
|
|
27
|
+
remediation:
|
|
28
|
+
"Declare the missing property, fix the colour, or remove the config the audit cannot model, so the pair can be scored.",
|
|
27
29
|
});
|
|
28
30
|
}
|
|
29
31
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { compositeOver, wcagRatio } from "../parse/color.ts";
|
|
2
|
-
import { CONTRAST_OPTIONS, contrastOf, fileLine,
|
|
2
|
+
import { CONTRAST_OPTIONS, contrastOf, fileLine, settingsOf } from "./shared.ts";
|
|
3
3
|
import { type Edit, type Finding, RULE, type Rule } from "./types.ts";
|
|
4
4
|
|
|
5
5
|
const AA_NORMAL = 4.5;
|
|
@@ -12,18 +12,19 @@ const ratioOn = (fg: string, bg: string): number => wcagRatio(compositeOver(fg,
|
|
|
12
12
|
// reads better, and only when that clears 4.5:1 on every background the
|
|
13
13
|
// directive is scored against. Choosing a brand colour is a design decision, so
|
|
14
14
|
// anything else stays reported. A linkStyle color: takes the same path, and it
|
|
15
|
-
// never clears: its label box is #e8e8e8 in light and
|
|
15
|
+
// never clears on GitHub's canvas: its label box is #e8e8e8 in light and
|
|
16
|
+
// #585858 in dark, where
|
|
16
17
|
// black and white each fail one theme, so edge label findings stay reported.
|
|
17
18
|
export const contrastTextBelowAa: Rule = {
|
|
18
19
|
id: RULE.TEXT,
|
|
19
20
|
category: "contrast",
|
|
20
21
|
summary: "label text below 4.5:1 against its fill",
|
|
21
22
|
options: CONTRAST_OPTIONS,
|
|
22
|
-
check: (ctx) => {
|
|
23
|
-
const
|
|
23
|
+
check: async (ctx) => {
|
|
24
|
+
const settings = settingsOf(ctx);
|
|
24
25
|
const found: Finding[] = [];
|
|
25
26
|
for (const diagram of ctx.doc.diagrams) {
|
|
26
|
-
for (const p of contrastOf(diagram,
|
|
27
|
+
for (const p of (await contrastOf(diagram, settings)).pairs) {
|
|
27
28
|
if (p.kind !== "text" || p.passes) continue;
|
|
28
29
|
const where = [p.theme, p.row].filter(Boolean).join("/");
|
|
29
30
|
const line = fileLine(diagram, p.line);
|
|
@@ -36,23 +37,25 @@ export const contrastTextBelowAa: Rule = {
|
|
|
36
37
|
`${p.directive.kind} ${p.selector}: text ${p.assessment.ratio.toFixed(2)}:1 < ${AA_NORMAL} ` +
|
|
37
38
|
`(${p.foreground} on ${p.background}${where ? `, ${where}` : ""})`,
|
|
38
39
|
remediation:
|
|
39
|
-
profile === "mkdocs-material"
|
|
40
|
+
settings.profile === "mkdocs-material"
|
|
40
41
|
? "The host theme forces the label text, so change the fill until the host text reads on it in both themes."
|
|
41
|
-
: p.directive.kind === "
|
|
42
|
-
? "
|
|
43
|
-
:
|
|
42
|
+
: p.directive.kind === "themeVariables"
|
|
43
|
+
? "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."
|
|
44
|
+
: p.directive.kind === "linkStyle"
|
|
45
|
+
? "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."
|
|
46
|
+
: "Darken or lighten the fill, or change color:, until the ratio reaches 4.5:1 in every theme.",
|
|
44
47
|
});
|
|
45
48
|
}
|
|
46
49
|
}
|
|
47
50
|
return found;
|
|
48
51
|
},
|
|
49
|
-
fix: (ctx) => {
|
|
50
|
-
const
|
|
52
|
+
fix: async (ctx) => {
|
|
53
|
+
const settings = settingsOf(ctx);
|
|
51
54
|
// Under a host profile color: is ignored, so there is nothing to rewrite.
|
|
52
|
-
if (profile === "mkdocs-material") return [];
|
|
55
|
+
if (settings.profile === "mkdocs-material") return [];
|
|
53
56
|
const edits: Edit[] = [];
|
|
54
57
|
for (const diagram of ctx.doc.diagrams) {
|
|
55
|
-
const { pairs } = contrastOf(diagram,
|
|
58
|
+
const { pairs } = await contrastOf(diagram, settings);
|
|
56
59
|
const failing = new Set(pairs.filter((p) => p.kind === "text" && !p.passes).map((p) => p.directive));
|
|
57
60
|
for (const directive of failing) {
|
|
58
61
|
const span = directive.spans.color;
|
package/src/rules/registry.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import type { Config } from "../config.ts";
|
|
8
8
|
import { RuleOptionError, UnknownRuleError } from "../errors.ts";
|
|
9
9
|
import { compositeOver, wcagRatio } from "../parse/color.ts";
|
|
10
|
-
import { PROFILES } from "../parse/contrast.ts";
|
|
10
|
+
import { canvasColourProblem, PROFILES, THEMES, type Theme } from "../parse/contrast.ts";
|
|
11
11
|
import { canonicalPreset } from "../parse/metrics.ts";
|
|
12
12
|
import { detectKeyword } from "../parse/stats.ts";
|
|
13
13
|
import { FIX_ORDER, RULES } from "./index.ts";
|
|
@@ -56,9 +56,36 @@ const typeOf = (value: unknown): OptionType | null => {
|
|
|
56
56
|
if (typeof value === "string") return "string";
|
|
57
57
|
if (typeof value === "boolean") return "boolean";
|
|
58
58
|
if (Array.isArray(value) && value.every((v) => typeof v === "string")) return "string[]";
|
|
59
|
+
if (isCanvasShape(value)) return "canvas";
|
|
59
60
|
return null;
|
|
60
61
|
};
|
|
61
62
|
|
|
63
|
+
// An object naming light, dark or both, and nothing else. Its colours are
|
|
64
|
+
// checked after the shape, so the message can say which side is wrong.
|
|
65
|
+
const isCanvasShape = (value: unknown): value is Record<Theme, unknown> => {
|
|
66
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
67
|
+
const keys = Object.keys(value);
|
|
68
|
+
return keys.length > 0 && keys.every((k) => THEMES.includes(k as Theme));
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const describeType = (want: OptionType): string =>
|
|
72
|
+
want === "canvas" ? 'an object such as { "light": "#ffffff", "dark": "#0d1117" }' : `a ${want}`;
|
|
73
|
+
|
|
74
|
+
const checkCanvas = (where: string, rule: Rule, name: string, value: Record<Theme, unknown>): void => {
|
|
75
|
+
for (const side of THEMES) {
|
|
76
|
+
if (!(side in value)) continue;
|
|
77
|
+
const problem = canvasColourProblem(value[side]);
|
|
78
|
+
if (problem) {
|
|
79
|
+
throw new RuleOptionError(
|
|
80
|
+
where,
|
|
81
|
+
rule.id,
|
|
82
|
+
name,
|
|
83
|
+
`option "${name}.${side}" of "${rule.id}" ${problem}: ${JSON.stringify(value[side])}`,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
|
|
62
89
|
const checkOptions = (where: string, rule: Rule, options: RuleOptions): void => {
|
|
63
90
|
const accepted = Object.keys(rule.options ?? {});
|
|
64
91
|
for (const [name, value] of Object.entries(options)) {
|
|
@@ -68,8 +95,18 @@ const checkOptions = (where: string, rule: Rule, options: RuleOptions): void =>
|
|
|
68
95
|
throw new RuleOptionError(where, rule.id, name, `rule "${rule.id}" has no option "${name}"; ${list}`);
|
|
69
96
|
}
|
|
70
97
|
if (typeOf(value) !== want) {
|
|
71
|
-
throw new RuleOptionError(where, rule.id, name, `option "${name}" of "${rule.id}" must be
|
|
98
|
+
throw new RuleOptionError(where, rule.id, name, `option "${name}" of "${rule.id}" must be ${describeType(want)}`);
|
|
72
99
|
}
|
|
100
|
+
if (want === "canvas") checkCanvas(where, rule, name, value as Record<Theme, unknown>);
|
|
101
|
+
}
|
|
102
|
+
// Material draws its own page, so a canvas has nothing to set there.
|
|
103
|
+
if (options.profile === "mkdocs-material" && options.canvas !== undefined && rule.options?.canvas === "canvas") {
|
|
104
|
+
throw new RuleOptionError(
|
|
105
|
+
where,
|
|
106
|
+
rule.id,
|
|
107
|
+
"canvas",
|
|
108
|
+
`option "canvas" of "${rule.id}" applies under the github profile only, and this rule sets profile mkdocs-material, whose page is Material's own`,
|
|
109
|
+
);
|
|
73
110
|
}
|
|
74
111
|
if (typeof options.preset === "string" && canonicalPreset(options.preset) === null) {
|
|
75
112
|
throw new RuleOptionError(where, rule.id, "preset", `option "preset" of "${rule.id}" is not a known preset`);
|
package/src/rules/shared.ts
CHANGED
|
@@ -2,8 +2,21 @@
|
|
|
2
2
|
// contrast pairs, derived once per diagram and shared, so two rules can never
|
|
3
3
|
// disagree about what the same diagram holds.
|
|
4
4
|
|
|
5
|
+
import { CanvasProfileError } from "../errors.ts";
|
|
5
6
|
import type { Diagram } from "../model.ts";
|
|
6
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
type Canvas,
|
|
9
|
+
type CanvasSetting,
|
|
10
|
+
type ContrastResult,
|
|
11
|
+
canvasColourProblem,
|
|
12
|
+
detectProfile,
|
|
13
|
+
GITHUB_CANVAS,
|
|
14
|
+
PROFILES,
|
|
15
|
+
type Profile,
|
|
16
|
+
resolveCanvas,
|
|
17
|
+
scoreDiagram,
|
|
18
|
+
THEMES,
|
|
19
|
+
} from "../parse/contrast.ts";
|
|
7
20
|
import { type ComplexityMetrics, calculateComplexity, parseFailed, type ThresholdConfig } from "../parse/metrics.ts";
|
|
8
21
|
import type { DiagramStats } from "../parse/stats.ts";
|
|
9
22
|
import type { Finding, OptionSpec, RuleContext, RuleId } from "./types.ts";
|
|
@@ -26,7 +39,7 @@ export const COMPLEXITY_OPTIONS: OptionSpec = {
|
|
|
26
39
|
depthWeight: "number",
|
|
27
40
|
};
|
|
28
41
|
|
|
29
|
-
export const CONTRAST_OPTIONS: OptionSpec = { profile: "string" };
|
|
42
|
+
export const CONTRAST_OPTIONS: OptionSpec = { profile: "string", canvas: "canvas" };
|
|
30
43
|
|
|
31
44
|
export interface Measured {
|
|
32
45
|
stats: DiagramStats;
|
|
@@ -77,18 +90,45 @@ export const profileOf = (ctx: RuleContext): Profile => {
|
|
|
77
90
|
return wanted === "auto" ? detectProfile(ctx.file) : (wanted as Profile);
|
|
78
91
|
};
|
|
79
92
|
|
|
80
|
-
|
|
93
|
+
// How a contrast rule scores a file: the profile, and the page canvas behind
|
|
94
|
+
// anything page-backed.
|
|
95
|
+
export interface ContrastSettings {
|
|
96
|
+
profile: Profile;
|
|
97
|
+
canvas: Canvas;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// The profile and canvas a contrast rule scores under. A canvas is GitHub's
|
|
101
|
+
// unless the canvas option sets a side. Under mkdocs-material the page is
|
|
102
|
+
// Material's own, so a canvas there is refused rather than ignored.
|
|
103
|
+
export const settingsOf = (ctx: RuleContext): ContrastSettings => {
|
|
104
|
+
const profile = profileOf(ctx);
|
|
105
|
+
const setting = ctx.options.canvas as CanvasSetting | undefined;
|
|
106
|
+
if (setting === undefined) return { profile, canvas: GITHUB_CANVAS };
|
|
107
|
+
for (const side of THEMES) {
|
|
108
|
+
if (!(side in setting)) continue;
|
|
109
|
+
const problem = canvasColourProblem(setting[side]);
|
|
110
|
+
if (problem) throw new Error(`canvas.${side} ${problem}: ${JSON.stringify(setting[side])}`);
|
|
111
|
+
}
|
|
112
|
+
if (profile === "mkdocs-material") throw new CanvasProfileError(ctx.file);
|
|
113
|
+
return { profile, canvas: resolveCanvas(setting) };
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const scored = new WeakMap<Diagram, Map<string, Promise<ContrastResult>>>();
|
|
81
117
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
118
|
+
// Scored once per diagram, profile and canvas. A sequence diagram is scored by
|
|
119
|
+
// its theme variables, which Mermaid resolves asynchronously.
|
|
120
|
+
export const contrastOf = (diagram: Diagram, settings: ContrastSettings): Promise<ContrastResult> => {
|
|
121
|
+
const { profile, canvas } = settings;
|
|
122
|
+
const key = `${profile}\u0000${canvas.light}\u0000${canvas.dark}`;
|
|
123
|
+
let bySettings = scored.get(diagram);
|
|
124
|
+
if (!bySettings) {
|
|
125
|
+
bySettings = new Map();
|
|
126
|
+
scored.set(diagram, bySettings);
|
|
87
127
|
}
|
|
88
|
-
let result =
|
|
128
|
+
let result = bySettings.get(key);
|
|
89
129
|
if (!result) {
|
|
90
|
-
result =
|
|
91
|
-
|
|
130
|
+
result = scoreDiagram(diagram.source, diagram.directives, profile, diagram.keyword, canvas);
|
|
131
|
+
bySettings.set(key, result);
|
|
92
132
|
}
|
|
93
133
|
return result;
|
|
94
134
|
};
|
package/src/rules/types.ts
CHANGED
|
@@ -44,7 +44,10 @@ export interface Finding {
|
|
|
44
44
|
remediation?: string;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
|
|
47
|
+
// "canvas" is an object { light?, dark? } of opaque CSS colours: the page a
|
|
48
|
+
// diagram is drawn on. A rule that declares it receives --canvas-light and
|
|
49
|
+
// --canvas-dark, as the contrast rules do.
|
|
50
|
+
export type OptionType = "number" | "string" | "boolean" | "string[]" | "canvas";
|
|
48
51
|
|
|
49
52
|
export type OptionSpec = Readonly<Record<string, OptionType>>;
|
|
50
53
|
|
package/src/testing.ts
CHANGED
|
@@ -14,7 +14,8 @@ import type { Finding, RuleOptions } from "./rules/types.ts";
|
|
|
14
14
|
export interface RunOptions {
|
|
15
15
|
// The rule's own options, as a config would give them.
|
|
16
16
|
options?: RuleOptions;
|
|
17
|
-
// The run flags, as --preset
|
|
17
|
+
// The run flags, as --preset, --profile, --canvas-light and --canvas-dark
|
|
18
|
+
// would give them.
|
|
18
19
|
flags?: RunFlags;
|
|
19
20
|
// The file name, which decides markdown or a standalone diagram. Default test.md.
|
|
20
21
|
file?: string;
|