@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.
@@ -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