@maple-kit/lint 0.1.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/LICENSE +202 -0
- package/NOTICE +9 -0
- package/README.md +50 -0
- package/dist/color.d.ts +35 -0
- package/dist/color.js +190 -0
- package/dist/comment.d.ts +35 -0
- package/dist/comment.js +49 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +8 -0
- package/dist/named-colors.d.ts +11 -0
- package/dist/named-colors.js +745 -0
- package/dist/page/page.iife.js +425 -0
- package/dist/rendered/audit.d.ts +79 -0
- package/dist/rendered/audit.js +141 -0
- package/dist/rendered/collect.d.ts +45 -0
- package/dist/rendered/collect.js +101 -0
- package/dist/rendered/rules.d.ts +30 -0
- package/dist/rendered/rules.js +131 -0
- package/dist/tokens.d.ts +39 -0
- package/dist/tokens.js +49 -0
- package/dist/types.d.ts +26 -0
- package/package.json +43 -0
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
(function() {
|
|
2
|
+
//#region ../core/src/anchor/selector.ts
|
|
3
|
+
const ID = /^[A-Za-z][\w-]*$/;
|
|
4
|
+
function cssPathTo(element, root = document) {
|
|
5
|
+
if (!contains(root, element)) return void 0;
|
|
6
|
+
const identifier = uniqueId(element, root);
|
|
7
|
+
if (identifier) return identifier;
|
|
8
|
+
const steps = [];
|
|
9
|
+
for (let current = element; current; current = current.parentElement) {
|
|
10
|
+
steps.unshift(step(current));
|
|
11
|
+
const selector = steps.join(" > ");
|
|
12
|
+
if (root.querySelectorAll(selector).length === 1) return selector;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
function uniqueId(element, root) {
|
|
16
|
+
const { id } = element;
|
|
17
|
+
if (!id || !ID.test(id)) return void 0;
|
|
18
|
+
const selector = `#${id}`;
|
|
19
|
+
return root.querySelectorAll(selector).length === 1 ? selector : void 0;
|
|
20
|
+
}
|
|
21
|
+
function step(element) {
|
|
22
|
+
const tag = element.tagName.toLowerCase();
|
|
23
|
+
const parent = element.parentElement;
|
|
24
|
+
if (!parent) return tag;
|
|
25
|
+
const siblings = [...parent.children].filter((child) => child.tagName === element.tagName);
|
|
26
|
+
if (siblings.length === 1) return tag;
|
|
27
|
+
return `${tag}:nth-of-type(${siblings.indexOf(element) + 1})`;
|
|
28
|
+
}
|
|
29
|
+
function contains(root, element) {
|
|
30
|
+
return root === element.ownerDocument || root.contains(element);
|
|
31
|
+
}
|
|
32
|
+
//#endregion
|
|
33
|
+
//#region ../core/src/anchor/text-position.ts
|
|
34
|
+
const SKIPPED = /* @__PURE__ */ new Set([
|
|
35
|
+
"SCRIPT",
|
|
36
|
+
"STYLE",
|
|
37
|
+
"NOSCRIPT",
|
|
38
|
+
"TEMPLATE",
|
|
39
|
+
"IFRAME"
|
|
40
|
+
]);
|
|
41
|
+
function indexText(root) {
|
|
42
|
+
const segments = [];
|
|
43
|
+
let text = "";
|
|
44
|
+
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, { acceptNode: (node) => isVisible(node.parentElement) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT });
|
|
45
|
+
let current = walker.nextNode();
|
|
46
|
+
while (current) {
|
|
47
|
+
const node = current;
|
|
48
|
+
const { length } = node.data;
|
|
49
|
+
if (length > 0) {
|
|
50
|
+
segments.push({
|
|
51
|
+
node,
|
|
52
|
+
start: text.length,
|
|
53
|
+
end: text.length + length
|
|
54
|
+
});
|
|
55
|
+
text += node.data;
|
|
56
|
+
}
|
|
57
|
+
current = walker.nextNode();
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
text,
|
|
61
|
+
segments
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
function isVisible(element) {
|
|
65
|
+
for (let current = element; current; current = current.parentElement) {
|
|
66
|
+
if (SKIPPED.has(current.tagName)) return false;
|
|
67
|
+
if (current.hasAttribute("data-maple-overlay")) return false;
|
|
68
|
+
if (current.hasAttribute("hidden") || current.getAttribute("aria-hidden") === "true") return false;
|
|
69
|
+
}
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
//#endregion
|
|
73
|
+
//#region ../core/src/anchor/describe.ts
|
|
74
|
+
const CONTEXT_LENGTH = 32;
|
|
75
|
+
const MAXIMUM_QUOTE = 300;
|
|
76
|
+
function describeElement(element, options = {}) {
|
|
77
|
+
const root = options.root ?? element.ownerDocument;
|
|
78
|
+
const index = indexText(root);
|
|
79
|
+
const span = spanOf(index, element);
|
|
80
|
+
return {
|
|
81
|
+
...attributesOf(element),
|
|
82
|
+
...span ? { quote: quoteAt(index, span.start, span.end, options) } : {},
|
|
83
|
+
...withSelector(element, root)
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
function attributesOf(element) {
|
|
87
|
+
const key = closestAttribute(element, "data-maple-key");
|
|
88
|
+
const source = closestAttribute(element, "data-maple-src");
|
|
89
|
+
const component = closestAttribute(element, "data-maple-name");
|
|
90
|
+
return {
|
|
91
|
+
...key === void 0 ? {} : { key },
|
|
92
|
+
...source === void 0 ? {} : { source },
|
|
93
|
+
...component === void 0 ? {} : { component }
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
function withSelector(element, root) {
|
|
97
|
+
const selector = cssPathTo(element, root);
|
|
98
|
+
return selector === void 0 ? {} : { selector };
|
|
99
|
+
}
|
|
100
|
+
function closestAttribute(element, name) {
|
|
101
|
+
return element.closest(`[${name}]`)?.getAttribute(name) ?? void 0;
|
|
102
|
+
}
|
|
103
|
+
function quoteAt(index, start, end, options) {
|
|
104
|
+
const context = options.contextLength ?? CONTEXT_LENGTH;
|
|
105
|
+
const limit = options.maximumQuote ?? MAXIMUM_QUOTE;
|
|
106
|
+
const stop = Math.min(end, start + limit);
|
|
107
|
+
const prefix = index.text.slice(Math.max(0, start - context), start);
|
|
108
|
+
const suffix = index.text.slice(stop, stop + context);
|
|
109
|
+
return {
|
|
110
|
+
exact: index.text.slice(start, stop),
|
|
111
|
+
...prefix ? { prefix } : {},
|
|
112
|
+
...suffix ? { suffix } : {},
|
|
113
|
+
offset: start
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
function spanOf(index, element) {
|
|
117
|
+
const inside = index.segments.filter((segment) => element.contains(segment.node));
|
|
118
|
+
const first = inside[0];
|
|
119
|
+
const last = inside[inside.length - 1];
|
|
120
|
+
return first && last ? {
|
|
121
|
+
start: first.start,
|
|
122
|
+
end: last.end
|
|
123
|
+
} : void 0;
|
|
124
|
+
}
|
|
125
|
+
const MOCK_STATES = [
|
|
126
|
+
"empty",
|
|
127
|
+
"error",
|
|
128
|
+
"forbidden",
|
|
129
|
+
"loading",
|
|
130
|
+
"one",
|
|
131
|
+
"many",
|
|
132
|
+
"long",
|
|
133
|
+
"sparse",
|
|
134
|
+
"mixed"
|
|
135
|
+
];
|
|
136
|
+
var InvalidRecipeError = class extends Error {
|
|
137
|
+
issues;
|
|
138
|
+
name = "InvalidRecipeError";
|
|
139
|
+
constructor(issues) {
|
|
140
|
+
super(["Invalid mock recipe:", ...issues].join("\n "));
|
|
141
|
+
this.issues = issues;
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
const KEY = /^[a-z]+:\S/;
|
|
145
|
+
const STATES = new Set(MOCK_STATES);
|
|
146
|
+
function parseRecipe(input) {
|
|
147
|
+
if (!isRecord(input)) throw new InvalidRecipeError(["a recipe is an object"]);
|
|
148
|
+
const issues = [...versionIssues(input["version"])];
|
|
149
|
+
const calls = parseCalls(input["calls"], issues);
|
|
150
|
+
const flags = parseFlags(input["flags"], issues);
|
|
151
|
+
const as = parseIdentity(input["as"], issues);
|
|
152
|
+
const { request, route } = input;
|
|
153
|
+
if (request !== void 0 && typeof request !== "string") issues.push("request: must be a string when present");
|
|
154
|
+
if (route !== void 0 && !(typeof route === "string" && route.startsWith("/"))) issues.push("route: must be a path pattern starting with \"/\" when present");
|
|
155
|
+
if (issues.length > 0) throw new InvalidRecipeError(issues);
|
|
156
|
+
return {
|
|
157
|
+
version: 2,
|
|
158
|
+
calls,
|
|
159
|
+
...flags === void 0 ? {} : { flags },
|
|
160
|
+
...as === void 0 ? {} : { as },
|
|
161
|
+
...typeof route === "string" ? { route } : {},
|
|
162
|
+
...typeof request === "string" ? { request } : {}
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
function versionIssues(version) {
|
|
166
|
+
if (version === 1 || version === 2) return [];
|
|
167
|
+
if (typeof version === "number" && version > 2) return [`version: ${version} is newer than this build reads (2)`];
|
|
168
|
+
return [`version: must be 1 or 2`];
|
|
169
|
+
}
|
|
170
|
+
function parseFlags(value, issues) {
|
|
171
|
+
if (value === void 0) return void 0;
|
|
172
|
+
if (!isRecord(value)) {
|
|
173
|
+
issues.push("flags: must be an object of flag keys when present");
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
const flags = {};
|
|
177
|
+
for (const [key, flag] of Object.entries(value)) if (key.trim() === "") issues.push("flags: a flag key must not be blank");
|
|
178
|
+
else if (isFlagValue(flag)) flags[key] = structuredClone(flag);
|
|
179
|
+
else issues.push(`flags.${key}: must be a JSON value`);
|
|
180
|
+
return flags;
|
|
181
|
+
}
|
|
182
|
+
function isFlagValue(value) {
|
|
183
|
+
if (value === null || typeof value === "boolean" || typeof value === "string") return true;
|
|
184
|
+
if (typeof value === "number") return Number.isFinite(value);
|
|
185
|
+
if (Array.isArray(value)) return value.every(isFlagValue);
|
|
186
|
+
return isRecord(value) && Object.values(value).every(isFlagValue);
|
|
187
|
+
}
|
|
188
|
+
function parseIdentity(value, issues) {
|
|
189
|
+
if (value === void 0) return void 0;
|
|
190
|
+
if (!isRecord(value)) {
|
|
191
|
+
issues.push("as: must be an object when present");
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
const { role, permissions } = value;
|
|
195
|
+
if (role === void 0 && permissions === void 0) issues.push("as: must name a role, permissions, or both");
|
|
196
|
+
const validRole = typeof role === "string" && role.trim() !== "";
|
|
197
|
+
if (role !== void 0 && !validRole) issues.push("as.role: must be a non-blank string");
|
|
198
|
+
const granted = parsePermissions(permissions, issues);
|
|
199
|
+
return {
|
|
200
|
+
...validRole ? { role } : {},
|
|
201
|
+
...granted === void 0 ? {} : { permissions: granted }
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
function parsePermissions(value, issues) {
|
|
205
|
+
if (value === void 0) return void 0;
|
|
206
|
+
if (!isRecord(value)) {
|
|
207
|
+
issues.push("as.permissions: must map each permission to true or false");
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
const permissions = {};
|
|
211
|
+
for (const [key, granted] of Object.entries(value)) if (key.trim() === "") issues.push("as.permissions: a permission must not be blank");
|
|
212
|
+
else if (typeof granted === "boolean") permissions[key] = granted;
|
|
213
|
+
else issues.push(`as.permissions.${key}: must be true or false`);
|
|
214
|
+
return permissions;
|
|
215
|
+
}
|
|
216
|
+
function parseCalls(value, issues) {
|
|
217
|
+
if (!Array.isArray(value)) {
|
|
218
|
+
issues.push("calls: must be an array");
|
|
219
|
+
return [];
|
|
220
|
+
}
|
|
221
|
+
const seen = /* @__PURE__ */ new Set();
|
|
222
|
+
const calls = [];
|
|
223
|
+
value.forEach((entry, index) => {
|
|
224
|
+
const call = parseCall(entry, `calls.${index}`, issues);
|
|
225
|
+
if (call === void 0) return;
|
|
226
|
+
if (seen.has(call.key)) issues.push(`calls.${index}.key: "${call.key}" appears twice`);
|
|
227
|
+
seen.add(call.key);
|
|
228
|
+
calls.push(call);
|
|
229
|
+
});
|
|
230
|
+
return calls;
|
|
231
|
+
}
|
|
232
|
+
function parseCall(entry, path, issues) {
|
|
233
|
+
if (!isRecord(entry)) {
|
|
234
|
+
issues.push(`${path}: must be an object`);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
const { key, state } = entry;
|
|
238
|
+
const validKey = typeof key === "string" && KEY.test(key);
|
|
239
|
+
const validState = typeof state === "string" && STATES.has(state);
|
|
240
|
+
if (!validKey) issues.push(`${path}.key: must look like "codec:name"`);
|
|
241
|
+
if (!validState) issues.push(`${path}.state: must be one of ${MOCK_STATES.join(", ")}`);
|
|
242
|
+
return validKey && validState ? {
|
|
243
|
+
key,
|
|
244
|
+
state
|
|
245
|
+
} : void 0;
|
|
246
|
+
}
|
|
247
|
+
function isRecord(value) {
|
|
248
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
249
|
+
}
|
|
250
|
+
//#endregion
|
|
251
|
+
//#region ../core/src/mock/active.ts
|
|
252
|
+
const MOCK_HANDLE_KEY = Symbol.for("@maple-kit/mock.installed");
|
|
253
|
+
function activeRecipe() {
|
|
254
|
+
const handle = globalThis[MOCK_HANDLE_KEY];
|
|
255
|
+
if (typeof handle !== "object" || handle === null) return void 0;
|
|
256
|
+
const current = handle.current;
|
|
257
|
+
if (typeof current !== "function") return void 0;
|
|
258
|
+
try {
|
|
259
|
+
const recipe = current.call(handle);
|
|
260
|
+
return recipe === void 0 ? void 0 : parseRecipe(recipe);
|
|
261
|
+
} catch {
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
//#endregion
|
|
266
|
+
//#region ../core/src/overlay/context.ts
|
|
267
|
+
const REGION_SELECTOR = "[role=\"dialog\"], [role=\"complementary\"], [role=\"navigation\"], dialog[open], details[open], [aria-expanded=\"true\"], [data-state=\"open\"]";
|
|
268
|
+
const MINIMUM_REGION_WIDTH = 24;
|
|
269
|
+
function captureContext(options = {}) {
|
|
270
|
+
const breakpoint = firstMatching(options.breakpoints);
|
|
271
|
+
const layout = options.layout?.();
|
|
272
|
+
const mock = activeRecipe();
|
|
273
|
+
return {
|
|
274
|
+
url: location.href,
|
|
275
|
+
viewport: viewport(),
|
|
276
|
+
scheme: matches("(prefers-color-scheme: dark)") ? "dark" : "light",
|
|
277
|
+
...breakpoint === void 0 ? {} : { breakpoint },
|
|
278
|
+
locale: navigator.language,
|
|
279
|
+
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
280
|
+
reducedMotion: matches("(prefers-reduced-motion: reduce)"),
|
|
281
|
+
regions: regions(),
|
|
282
|
+
...layout === void 0 ? {} : { layout },
|
|
283
|
+
...mock === void 0 ? {} : { mock },
|
|
284
|
+
capturedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
function viewport() {
|
|
288
|
+
const visual = window.visualViewport;
|
|
289
|
+
return {
|
|
290
|
+
width: window.innerWidth,
|
|
291
|
+
height: window.innerHeight,
|
|
292
|
+
contentWidth: document.documentElement.clientWidth,
|
|
293
|
+
dpr: window.devicePixelRatio,
|
|
294
|
+
...visual ? { scale: visual.scale } : {}
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
function firstMatching(breakpoints) {
|
|
298
|
+
return breakpoints?.find(([, query]) => matches(query))?.[0];
|
|
299
|
+
}
|
|
300
|
+
function matches(query) {
|
|
301
|
+
return window.matchMedia(query).matches;
|
|
302
|
+
}
|
|
303
|
+
function regions() {
|
|
304
|
+
const found = [];
|
|
305
|
+
for (const element of document.querySelectorAll(REGION_SELECTOR)) {
|
|
306
|
+
const width = element.getBoundingClientRect().width;
|
|
307
|
+
if (width < MINIMUM_REGION_WIDTH) continue;
|
|
308
|
+
const label = accessibleName(element);
|
|
309
|
+
found.push({
|
|
310
|
+
role: element.getAttribute("role") ?? element.tagName.toLowerCase(),
|
|
311
|
+
...label === void 0 ? {} : { label },
|
|
312
|
+
width: Math.round(width)
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
return found;
|
|
316
|
+
}
|
|
317
|
+
function accessibleName(element) {
|
|
318
|
+
const label = element.getAttribute("aria-label")?.trim();
|
|
319
|
+
if (label) return label;
|
|
320
|
+
const id = element.getAttribute("aria-labelledby");
|
|
321
|
+
return (id ? document.getElementById(id)?.textContent?.trim() : void 0) || void 0;
|
|
322
|
+
}
|
|
323
|
+
//#endregion
|
|
324
|
+
//#region src/rendered/collect.ts
|
|
325
|
+
const INTERACTIVE_TAGS = [
|
|
326
|
+
"a",
|
|
327
|
+
"button",
|
|
328
|
+
"input",
|
|
329
|
+
"select",
|
|
330
|
+
"summary",
|
|
331
|
+
"textarea"
|
|
332
|
+
];
|
|
333
|
+
const INTERACTIVE_ROLES = [
|
|
334
|
+
"button",
|
|
335
|
+
"checkbox",
|
|
336
|
+
"link",
|
|
337
|
+
"menuitem",
|
|
338
|
+
"switch",
|
|
339
|
+
"tab"
|
|
340
|
+
];
|
|
341
|
+
const TEXT_CAP = 80;
|
|
342
|
+
function isInteractive(element) {
|
|
343
|
+
const tag = element.tagName.toLowerCase();
|
|
344
|
+
if (INTERACTIVE_TAGS.includes(tag)) return true;
|
|
345
|
+
const role = element.getAttribute("role");
|
|
346
|
+
if (role !== null && INTERACTIVE_ROLES.includes(role)) return true;
|
|
347
|
+
const tabIndex = element.getAttribute("tabindex");
|
|
348
|
+
return tabIndex !== null && Number.parseInt(tabIndex, 10) >= 0;
|
|
349
|
+
}
|
|
350
|
+
function isOpaque(color) {
|
|
351
|
+
if (color === "transparent") return false;
|
|
352
|
+
const alpha = /rgba?\([^)]*[,/]\s*([\d.]+)\s*\)/.exec(color);
|
|
353
|
+
return alpha === null || Number.parseFloat(alpha[1]) > 0;
|
|
354
|
+
}
|
|
355
|
+
function backdropOf(element) {
|
|
356
|
+
let node = element;
|
|
357
|
+
while (node !== null) {
|
|
358
|
+
const background = getComputedStyle(node).backgroundColor;
|
|
359
|
+
if (isOpaque(background)) return background;
|
|
360
|
+
node = node.parentElement;
|
|
361
|
+
}
|
|
362
|
+
return "rgb(255, 255, 255)";
|
|
363
|
+
}
|
|
364
|
+
function keyframeProperties(names) {
|
|
365
|
+
const wanted = names.split(",").map((name) => name.trim());
|
|
366
|
+
if (wanted.every((name) => name === "none" || name === "")) return [];
|
|
367
|
+
const found = /* @__PURE__ */ new Set();
|
|
368
|
+
for (const sheet of [...document.styleSheets]) {
|
|
369
|
+
let rules;
|
|
370
|
+
try {
|
|
371
|
+
rules = sheet.cssRules;
|
|
372
|
+
} catch {
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
collectKeyframes(rules, wanted, found);
|
|
376
|
+
}
|
|
377
|
+
return [...found];
|
|
378
|
+
}
|
|
379
|
+
function collectKeyframes(rules, wanted, found) {
|
|
380
|
+
for (const rule of [...rules]) {
|
|
381
|
+
if (!(rule instanceof CSSKeyframesRule) || !wanted.includes(rule.name)) continue;
|
|
382
|
+
for (const frame of [...rule.cssRules]) {
|
|
383
|
+
if (!(frame instanceof CSSKeyframeRule)) continue;
|
|
384
|
+
for (const property of [...frame.style]) found.add(property);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
function paintsOwnText(element) {
|
|
389
|
+
return [...element.childNodes].some((node) => node.nodeType === Node.TEXT_NODE && (node.textContent ?? "").trim() !== "");
|
|
390
|
+
}
|
|
391
|
+
function recordOf(element) {
|
|
392
|
+
const style = getComputedStyle(element);
|
|
393
|
+
const box = element.getBoundingClientRect();
|
|
394
|
+
return {
|
|
395
|
+
anchor: describeElement(element),
|
|
396
|
+
tag: element.tagName.toLowerCase(),
|
|
397
|
+
text: (element.textContent ?? "").trim().slice(0, TEXT_CAP),
|
|
398
|
+
paintsText: paintsOwnText(element),
|
|
399
|
+
interactive: isInteractive(element),
|
|
400
|
+
color: style.color,
|
|
401
|
+
backgroundColor: style.backgroundColor,
|
|
402
|
+
backdrop: backdropOf(element),
|
|
403
|
+
fontSize: Number.parseFloat(style.fontSize),
|
|
404
|
+
fontWeight: Number.parseFloat(style.fontWeight),
|
|
405
|
+
width: box.width,
|
|
406
|
+
height: box.height,
|
|
407
|
+
transitionProperty: style.transitionProperty,
|
|
408
|
+
transitionDuration: style.transitionDuration,
|
|
409
|
+
animationName: style.animationName,
|
|
410
|
+
animationDuration: style.animationDuration,
|
|
411
|
+
animationProperties: keyframeProperties(style.animationName)
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
function readPage() {
|
|
415
|
+
const elements = [...document.querySelectorAll("[data-maple-src]")];
|
|
416
|
+
return {
|
|
417
|
+
context: captureContext(),
|
|
418
|
+
records: elements.map(recordOf)
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
//#endregion
|
|
422
|
+
//#region src/rendered/page.ts
|
|
423
|
+
window.__mapleLintRead = readPage;
|
|
424
|
+
//#endregion
|
|
425
|
+
})();
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { Finding } from "../types.js";
|
|
2
|
+
import { Logger } from "@maple-kit/core/logger";
|
|
3
|
+
import { CommentContext } from "@maple-kit/core";
|
|
4
|
+
import { Browser } from "playwright";
|
|
5
|
+
//#region src/rendered/audit.d.ts
|
|
6
|
+
/** One size the page is judged at. */
|
|
7
|
+
export interface Viewport {
|
|
8
|
+
readonly width: number;
|
|
9
|
+
readonly height: number;
|
|
10
|
+
}
|
|
11
|
+
/** The viewports used when a host configures none: a phone, a tablet, a laptop. */
|
|
12
|
+
export declare const DEFAULT_VIEWPORTS: readonly Viewport[];
|
|
13
|
+
/** What a rendered run needs to know. */
|
|
14
|
+
export interface RenderedLintOptions {
|
|
15
|
+
/** The preview's URL. */
|
|
16
|
+
readonly url: string;
|
|
17
|
+
/** CSS files the token set is read from, the same ones the static tier uses. */
|
|
18
|
+
readonly tokenFiles: readonly string[];
|
|
19
|
+
/**
|
|
20
|
+
* The sizes to judge at. Any list, any number: a wall display and no phone
|
|
21
|
+
* is as valid as the default. Omitted, `DEFAULT_VIEWPORTS` is used.
|
|
22
|
+
*/
|
|
23
|
+
readonly viewports?: readonly Viewport[];
|
|
24
|
+
/**
|
|
25
|
+
* The preview platform's bypass, sent as request headers. Reviewer cookies
|
|
26
|
+
* are never used: a lint run is CI's and does not borrow a person's session.
|
|
27
|
+
*/
|
|
28
|
+
readonly bypassHeaders?: Readonly<Record<string, string>>;
|
|
29
|
+
/** How long one page is given to load, in ms. Default 30000. */
|
|
30
|
+
readonly timeout?: number;
|
|
31
|
+
/**
|
|
32
|
+
* How long to wait after load before reading, in ms. Default 0. An app that
|
|
33
|
+
* paints after hydration needs a little; nothing else does.
|
|
34
|
+
*/
|
|
35
|
+
readonly settleMs?: number;
|
|
36
|
+
/** An already-launched browser, which a test supplies and a run does not. */
|
|
37
|
+
readonly browser?: Browser;
|
|
38
|
+
/**
|
|
39
|
+
* Where a run says what it could not check. Defaults to warning through
|
|
40
|
+
* Maple's own logger; a host that captures its own passes one in.
|
|
41
|
+
*/
|
|
42
|
+
readonly logger?: Logger;
|
|
43
|
+
}
|
|
44
|
+
/** A finished run: what was found, and the page it was found on. */
|
|
45
|
+
export interface RenderedRun {
|
|
46
|
+
readonly findings: readonly Finding[];
|
|
47
|
+
/**
|
|
48
|
+
* The environment each finding was seen in, aligned with `findings`. A
|
|
49
|
+
* finding seen only at 375px carries 375px, not the run's widest viewport.
|
|
50
|
+
*/
|
|
51
|
+
readonly contexts: readonly CommentContext[];
|
|
52
|
+
/** The widest viewport read, for a caller that wants one context. */
|
|
53
|
+
readonly context: CommentContext;
|
|
54
|
+
}
|
|
55
|
+
/** A finding and the environment it was first seen in. */
|
|
56
|
+
export interface Seen {
|
|
57
|
+
readonly finding: Finding;
|
|
58
|
+
readonly context: CommentContext;
|
|
59
|
+
}
|
|
60
|
+
/** Findings from one viewport, kept apart so a dedupe can say where they were. */
|
|
61
|
+
export interface Pass {
|
|
62
|
+
readonly viewport: Viewport;
|
|
63
|
+
readonly findings: readonly Finding[];
|
|
64
|
+
readonly context: CommentContext;
|
|
65
|
+
/** Colours this viewport painted that no rule could read. */
|
|
66
|
+
readonly unreadable: readonly string[];
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* One finding per place per rule. A finding at every viewport reads as it was
|
|
70
|
+
* written; one at only some names them, because "only on the phone" is most of
|
|
71
|
+
* what the reader needs to know.
|
|
72
|
+
*/
|
|
73
|
+
export declare function dedupe(passes: readonly Pass[]): readonly Seen[];
|
|
74
|
+
/**
|
|
75
|
+
* Lints a live preview. Launches Chromium unless a browser is supplied, and
|
|
76
|
+
* closes only what it opened.
|
|
77
|
+
*/
|
|
78
|
+
export declare function lintRendered(options: RenderedLintOptions): Promise<RenderedRun>;
|
|
79
|
+
//#endregion
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { readTokenFiles } from "../tokens.js";
|
|
2
|
+
import { reducedMotionFindings, renderedFindings, unreadableColors } from "./rules.js";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { createLogger } from "@maple-kit/core/logger";
|
|
6
|
+
import { toCommentContext } from "@maple-kit/core/overlay";
|
|
7
|
+
//#region src/rendered/audit.ts
|
|
8
|
+
const DEFAULT_VIEWPORTS = [
|
|
9
|
+
{
|
|
10
|
+
width: 375,
|
|
11
|
+
height: 812
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
width: 768,
|
|
15
|
+
height: 1024
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
width: 1440,
|
|
19
|
+
height: 900
|
|
20
|
+
}
|
|
21
|
+
];
|
|
22
|
+
function warnGaps(log, tokens, seen) {
|
|
23
|
+
for (const [name, value] of tokens.unreadable) log.warn("Token could not be read, so nothing is checked against it", {
|
|
24
|
+
token: name,
|
|
25
|
+
value
|
|
26
|
+
});
|
|
27
|
+
if (seen.length > 0) log.warn("Colours on the page could not be read, so they were not judged", { values: seen });
|
|
28
|
+
if (tokens.colors.size === 0) log.warn("No colour token was found, so the colour rule checked nothing", {});
|
|
29
|
+
if (tokens.fontSizes.size === 0) log.warn("No type token was found, so the type-scale rule checked nothing", { hint: "a type token is named --…-text-…, --…-font-… or --…-type-…" });
|
|
30
|
+
}
|
|
31
|
+
function label(viewport) {
|
|
32
|
+
return `${viewport.width}×${viewport.height}`;
|
|
33
|
+
}
|
|
34
|
+
function pageBundle() {
|
|
35
|
+
const path = fileURLToPath(new URL("../page/page.iife.js", import.meta.url));
|
|
36
|
+
if (existsSync(path)) return path;
|
|
37
|
+
throw new Error(`@maple-kit/lint: the page bundle is missing at ${path}. Run \`pnpm --filter @maple-kit/lint build\` before linting from source.`);
|
|
38
|
+
}
|
|
39
|
+
async function read(page) {
|
|
40
|
+
await page.addScriptTag({ path: pageBundle() });
|
|
41
|
+
return await page.evaluate(() => {
|
|
42
|
+
const run = window.__mapleLintRead;
|
|
43
|
+
if (!run) throw new Error("@maple-kit/lint: the page bundle did not install its reader.");
|
|
44
|
+
return run();
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
async function collect(context, visit) {
|
|
48
|
+
const { url, timeout, settleMs } = visit;
|
|
49
|
+
const page = await context.newPage();
|
|
50
|
+
try {
|
|
51
|
+
await page.goto(url, {
|
|
52
|
+
waitUntil: "load",
|
|
53
|
+
timeout
|
|
54
|
+
});
|
|
55
|
+
if (settleMs > 0) await page.waitForTimeout(settleMs);
|
|
56
|
+
return await read(page);
|
|
57
|
+
} finally {
|
|
58
|
+
await context.close();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
async function auditViewport(browser, options, viewport, tokens) {
|
|
62
|
+
const shared = {
|
|
63
|
+
viewport,
|
|
64
|
+
bypassCSP: true,
|
|
65
|
+
...options.bypassHeaders === void 0 ? {} : { extraHTTPHeaders: options.bypassHeaders }
|
|
66
|
+
};
|
|
67
|
+
const visit = {
|
|
68
|
+
url: options.url,
|
|
69
|
+
timeout: options.timeout ?? 3e4,
|
|
70
|
+
settleMs: options.settleMs ?? 0
|
|
71
|
+
};
|
|
72
|
+
const seen = await collect(await browser.newContext(shared), visit);
|
|
73
|
+
const reduced = await collect(await browser.newContext({
|
|
74
|
+
...shared,
|
|
75
|
+
reducedMotion: "reduce"
|
|
76
|
+
}), visit);
|
|
77
|
+
return {
|
|
78
|
+
viewport,
|
|
79
|
+
context: toCommentContext(seen.context),
|
|
80
|
+
unreadable: unreadableColors(seen.records),
|
|
81
|
+
findings: [...renderedFindings(seen.records, tokens), ...reducedMotionFindings(reduced.records)]
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
function keyOf(found) {
|
|
85
|
+
const { key, source, component, selector } = found.anchor;
|
|
86
|
+
const place = [
|
|
87
|
+
key,
|
|
88
|
+
source,
|
|
89
|
+
component,
|
|
90
|
+
selector
|
|
91
|
+
].map((rung) => rung ?? "").join("\0");
|
|
92
|
+
return `${found.rule}\u0000${place}\u0000${found.message}`;
|
|
93
|
+
}
|
|
94
|
+
function dedupe(passes) {
|
|
95
|
+
const groups = /* @__PURE__ */ new Map();
|
|
96
|
+
for (const pass of passes) for (const found of pass.findings) {
|
|
97
|
+
const group = groups.get(keyOf(found)) ?? {
|
|
98
|
+
found,
|
|
99
|
+
at: [],
|
|
100
|
+
context: pass.context
|
|
101
|
+
};
|
|
102
|
+
group.at.push(label(pass.viewport));
|
|
103
|
+
groups.set(keyOf(found), group);
|
|
104
|
+
}
|
|
105
|
+
return [...groups.values()].map(({ found, at, context }) => ({
|
|
106
|
+
finding: at.length === passes.length ? found : {
|
|
107
|
+
...found,
|
|
108
|
+
message: `${found.message} At ${at.join(", ")}.`
|
|
109
|
+
},
|
|
110
|
+
context
|
|
111
|
+
}));
|
|
112
|
+
}
|
|
113
|
+
async function launch() {
|
|
114
|
+
const { chromium } = await import("playwright");
|
|
115
|
+
return chromium.launch();
|
|
116
|
+
}
|
|
117
|
+
function widest(passes) {
|
|
118
|
+
const found = [...passes].sort((a, b) => b.viewport.width - a.viewport.width)[0];
|
|
119
|
+
if (!found) throw new Error("@maple-kit/lint: a run needs at least one viewport.");
|
|
120
|
+
return found.context;
|
|
121
|
+
}
|
|
122
|
+
async function lintRendered(options) {
|
|
123
|
+
const tokens = await readTokenFiles(options.tokenFiles);
|
|
124
|
+
const viewports = options.viewports ?? DEFAULT_VIEWPORTS;
|
|
125
|
+
const browser = options.browser ?? await launch();
|
|
126
|
+
try {
|
|
127
|
+
const passes = [];
|
|
128
|
+
for (const viewport of viewports) passes.push(await auditViewport(browser, options, viewport, tokens));
|
|
129
|
+
warnGaps(options.logger ?? createLogger({ level: "warn" }), tokens, [...new Set(passes.flatMap((pass) => pass.unreadable))]);
|
|
130
|
+
const seen = dedupe(passes);
|
|
131
|
+
return {
|
|
132
|
+
findings: seen.map((one) => one.finding),
|
|
133
|
+
contexts: seen.map((one) => one.context),
|
|
134
|
+
context: widest(passes)
|
|
135
|
+
};
|
|
136
|
+
} finally {
|
|
137
|
+
if (options.browser === void 0) await browser.close();
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
//#endregion
|
|
141
|
+
export { DEFAULT_VIEWPORTS, dedupe, lintRendered };
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { PageContext } from "@maple-kit/core/overlay";
|
|
2
|
+
import { Anchor } from "@maple-kit/core/anchor";
|
|
3
|
+
//#region src/rendered/collect.d.ts
|
|
4
|
+
/** One element's rendered state, as a rule needs to see it. */
|
|
5
|
+
export interface StyleRecord {
|
|
6
|
+
/** Every rung the page could supply, from the cascade itself. */
|
|
7
|
+
readonly anchor: Anchor;
|
|
8
|
+
readonly tag: string;
|
|
9
|
+
/** Trimmed text, capped, for a message that can quote what it is about. */
|
|
10
|
+
readonly text: string;
|
|
11
|
+
/**
|
|
12
|
+
* Whether it paints text itself rather than only through a child, so an
|
|
13
|
+
* ancestor is not judged on a colour it never puts on screen.
|
|
14
|
+
*/
|
|
15
|
+
readonly paintsText: boolean;
|
|
16
|
+
/** Whether a pointer is meant to hit it, which is what a touch target is. */
|
|
17
|
+
readonly interactive: boolean;
|
|
18
|
+
readonly color: string;
|
|
19
|
+
/** The element's own background, which is the value a token rule judges. */
|
|
20
|
+
readonly backgroundColor: string;
|
|
21
|
+
/** The first opaque background at or above it, which is what contrast sees. */
|
|
22
|
+
readonly backdrop: string;
|
|
23
|
+
readonly fontSize: number;
|
|
24
|
+
readonly fontWeight: number;
|
|
25
|
+
readonly width: number;
|
|
26
|
+
readonly height: number;
|
|
27
|
+
readonly transitionProperty: string;
|
|
28
|
+
readonly transitionDuration: string;
|
|
29
|
+
readonly animationName: string;
|
|
30
|
+
readonly animationDuration: string;
|
|
31
|
+
/** Properties the running animation's keyframes declare, when readable. */
|
|
32
|
+
readonly animationProperties: readonly string[];
|
|
33
|
+
}
|
|
34
|
+
/** One pass over the page: what was on it, and what it was being shown in. */
|
|
35
|
+
export interface Reading {
|
|
36
|
+
readonly context: PageContext;
|
|
37
|
+
readonly records: readonly StyleRecord[];
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Reads every tagged element, and the page they are on. Returns plain data:
|
|
41
|
+
* only what survives structured cloning comes back out of the browser, and a
|
|
42
|
+
* rule should not hold a live node it might read a second, different value from.
|
|
43
|
+
*/
|
|
44
|
+
export declare function readPage(): Reading;
|
|
45
|
+
//#endregion
|