Record reading cost for item 51's chunk

This commit is contained in:
2026-11-08 14:09:20 +01:00
parent 99d5928b1d
commit 5aa62e2742
6 changed files with 17 additions and 0 deletions
-184
View File
@@ -1,184 +0,0 @@
import assert from "node:assert/strict";
import { globSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { test } from "node:test";
import { fileURLToPath } from "node:url";
import type { SupportStatement } from "@mdn/browser-compat-data";
import { floorViolations, lacking } from "./stylesheet-floor.ts";
const root = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
const cssDir = join(root, "public", "css");
test("floorViolations flags a feature the floor lacks, with its line and the first version that has it", () => {
assert.deepEqual(floorViolations(".a { color: red; }\n.b:has(.c) { color: red; }"), [
"line 2: :has needs chrome 105, safari 15.4, firefox 121 (css.selectors.has)",
]);
assert.deepEqual(floorViolations(".a { min-height: 100dvh; }"), [
"line 1: dvh needs chrome 108, safari 15.4 (css.types.length.viewport_percentage_units_dynamic)",
]);
assert.deepEqual(floorViolations(".a { accent-color: red; }"), [
"line 1: accent-color needs chrome 93, safari 15.4 (css.properties.accent-color)",
]);
assert.deepEqual(floorViolations(".a { overflow-wrap: anywhere; }"), [
"line 1: overflow-wrap: anywhere needs safari 15.4 (css.properties.overflow-wrap.anywhere)",
]);
assert.equal(floorViolations(".a { & .b { color: red; } }").length, 1);
assert.deepEqual(floorViolations("@layer base { .a { color: red; } }"), [
"line 1: @layer needs chrome 99, safari 15.4 (css.at-rules.layer)",
]);
});
test("floorViolations flags media range and or syntax, which a floor engine drops with the whole query", () => {
assert.deepEqual(floorViolations("@media (width >= 600px) { .a { color: red; } }"), [
"line 1: media range syntax needs chrome 104, safari 16.4 (css.at-rules.media.range_syntax)",
]);
assert.deepEqual(floorViolations("@media (hover: hover) or (pointer: fine) { .a { color: red; } }"), [
"line 1: media or syntax needs chrome 104, safari 16.4 (css.at-rules.media.or_syntax)",
]);
});
test("floorViolations passes a media feature the floor lacks, since an engine without it reads the query as false", () => {
assert.deepEqual(floorViolations("@media (forced-colors: active) { .a { color: red; } }"), []);
});
test("floorViolations reports a bracket nothing closes, with its line", () => {
assert.deepEqual(floorViolations(".a { color: red; }\n.b { color: red;"), ["line 2: { is never closed"]);
assert.deepEqual(floorViolations(".a {\n width: calc(1px;\n}"), ["line 2: ( is never closed"]);
});
test("floorViolations checks a custom property's value, since the var() reading it fails unseen", () => {
assert.deepEqual(floorViolations(":root { --accent: oklch(0.5 0.1 250); }"), [
"line 1: oklch() needs chrome 111, safari 15.4 (css.types.color.oklch)",
]);
});
test("floorViolations allows a feature inside an @supports that tests it, and only then", () => {
assert.deepEqual(floorViolations("@supports selector(:has(a)) { .b:has(.c) { color: red; } }"), []);
assert.deepEqual(floorViolations("@media (min-width: 1px) { @supports (min-height: 100dvh) { .a { min-height: 100dvh; } } }"), []);
assert.equal(floorViolations("@supports not selector(:has(a)) { .b:has(.c) { color: red; } }").length, 1);
assert.equal(floorViolations("@supports (display: grid) and (not selector(:has(a))) { .b:has(.c) { color: red; } }").length, 1);
assert.equal(floorViolations("@supports (min-height: 100dvh) { .b:has(.c) { color: red; } }").length, 1);
assert.equal(floorViolations("@supports (position-anchor: auto) { .a { top: anchor(bottom); } }").length, 1);
assert.equal(floorViolations("@supports at-rule(@layer) { @layer base { .a { color: red; } } }").length, 1);
});
test("floorViolations takes as a guard only a part of the @supports condition that must hold", () => {
const has = ["line 1: :has needs chrome 105, safari 15.4, firefox 121 (css.selectors.has)"];
assert.deepEqual(floorViolations("@supports (display: grid) or selector(:has(a)) { .b:has(.c) { color: red; } }"), has);
assert.deepEqual(floorViolations("@supports selector(:has(a)) and ((display: grid) or (display: flex)) { .b:has(.c) { color: red; } }"), []);
assert.deepEqual(floorViolations("@supports (display: grid) and ((display: flex) or selector(:has(a))) { .b:has(.c) { color: red; } }"), has);
});
test("floorViolations passes what the floor has, vendor prefixes, font-family names, and syntax inside comments, strings and url()", () => {
const css = `/* .x:has(y) { min-height: 100dvh } */
@media (prefers-color-scheme: dark) and (orientation: portrait) { :root { color-scheme: dark; } }
.a::before, .a:focus-within { content: "{ :has( }"; display: grid; gap: 4px; inset: 0;
grid-template-columns: repeat(3, minmax(0, 1fr)); width: min(86vw, 20rem); transition: .15s ease;
background: rgba(0,0,0,.4) url("data:image/svg+xml;utf8,<svg/>"); transform: rotate(45deg); }
.b { -webkit-font-smoothing: antialiased; -webkit-user-select: none; font-family: Avenir, ui-sans-serif, sans-serif; }
summary::-webkit-details-marker { display: none; }
@-webkit-keyframes spin { to { opacity: 0; } }`;
assert.deepEqual(floorViolations(css), []);
});
test("floorViolations refuses what MDN has no entry for, so a typo or a new feature can't pass unchecked", () => {
assert.deepEqual(floorViolations(".a { colr: red; }"), ["line 1: colr has no MDN compat entry"]);
assert.equal(floorViolations(".a { width: 3zz; }").length, 1);
assert.equal(floorViolations(".a:hovr { color: red; }").length, 1);
assert.deepEqual(floorViolations("@media (hovr: hover) { .a { color: red; } }"), ["line 1: (hovr) has no MDN compat entry"]);
});
const safari = (support: SupportStatement): string[] =>
lacking({ chrome: { version_added: "1" }, firefox: { version_added: "1" }, safari: support });
test("lacking counts only an unprefixed, unflagged, unrenamed support statement the floor still has", () => {
assert.deepEqual(safari({ version_added: "15" }), []);
assert.deepEqual(safari({ version_added: "15.4" }), ["safari 15.4"]);
assert.deepEqual(safari({ version_added: "≤16" }), ["safari 16"]);
assert.deepEqual(safari({ version_added: "preview" }), ["safari (none yet)"]);
assert.deepEqual(safari({ version_added: "3", prefix: "-webkit-" }), ["safari (none yet)"]);
assert.deepEqual(safari({ version_added: "3", flags: [{ type: "preference", name: "x" }] }), ["safari (none yet)"]);
assert.deepEqual(safari({ version_added: "3", alternative_name: "x" }), ["safari (none yet)"]);
assert.deepEqual(safari({ version_added: "3", version_removed: "15" }), ["safari (none yet)"]);
assert.deepEqual(safari({ version_added: "3", version_removed: "15.4" }), []);
assert.deepEqual(safari([{ version_added: "3", version_removed: "14" }, { version_added: "16" }]), ["safari 16"]);
});
const stylesheets = globSync(["public/**/*.css", "examples/**/*.css", "plugins/**/*.css"], { cwd: root, exclude: ["**/node_modules/**"] });
test("the floor check finds the stylesheets", () => {
assert.ok(stylesheets.includes("public/css/styles.css"));
assert.ok(stylesheets.includes("examples/plugins/scheduling/public/scheduling.css"));
});
for (const file of stylesheets) {
test(`${file} uses nothing the browser floor lacks outside an @supports guarding it`, () => {
assert.deepEqual(floorViolations(readFileSync(join(root, file), "utf8")), []);
});
}
const stylesheet = readFileSync(join(cssDir, "styles.css"), "utf8").replace(/\/\*[\s\S]*?\*\//g, "");
// The index of the `}` closing the block whose `{` is the first one at or after `start`.
function blockEnd(start: number): number {
let depth = 0;
for (let i = stylesheet.indexOf("{", start); i < stylesheet.length; i++) {
if (stylesheet[i] === "{") depth++;
if (stylesheet[i] === "}" && --depth === 0) return i;
}
throw new Error("unbalanced braces in styles.css");
}
// The text is all there is to check: Playwright's engines all support `:has()`, so no E2E sees the
// stylesheet an older engine gets.
// Chromium below 105 and Firefox below 121 have no `:has()`, and nothing else reveals a folded
// branch there, so the fold may only exist where `:has()` works.
test("styles.css folds .nav-children only inside @supports selector(:has(a))", () => {
const open = stylesheet.lastIndexOf("@supports selector(:has(a)) {", stylesheet.indexOf(".nav-node > .nav-children"));
assert.ok(open >= 0, "no @supports selector(:has(a)) block before the fold rule");
const close = blockEnd(open);
const outside = stylesheet.slice(0, open) + stylesheet.slice(close + 1);
assert.ok(!/\.nav-children\s*\{[^}]*display:\s*none/.test(outside), "a .nav-children rule hides it outside the guard");
assert.match(stylesheet.slice(open, close), /\.nav-children\s*\{\s*display:\s*none/, "the fold rule is gone");
});
// `@supports` adds no specificity, so the hiding rule has to come after the base `.theme-switch` rule.
test("styles.css hides the theme switch and the nav toggles where :has() is missing, after their base rules", () => {
const guard = stylesheet.indexOf("@supports not selector(:has(a)) {");
assert.ok(guard >= 0, "no @supports not selector(:has(a)) block");
const base = stylesheet.search(/^\.theme-switch\s*\{/m);
assert.ok(base >= 0 && base < guard, "the guard precedes the base .theme-switch rule");
const block = stylesheet.slice(guard, stylesheet.indexOf("\n}", guard));
assert.match(block, /\.theme-switch\s*\{\s*display:\s*none/);
assert.match(block, /\.nav-disc\s*\{\s*visibility:\s*hidden/);
});
test("styles.css shows the segmented and chip inputs where :has() is missing, after their base rules", () => {
const guard = stylesheet.indexOf("@supports not selector(:has(a)) {");
assert.ok(guard >= 0, "no @supports not selector(:has(a)) block");
const block = stylesheet.slice(guard, blockEnd(guard));
for (const input of [".segmented input", ".chip input"]) {
const base = stylesheet.indexOf(`${input} {`);
assert.ok(base >= 0 && base < guard, `the base ${input} rule is missing or comes after the guard`);
}
assert.match(block, /\.segmented input,\s*\.chip input\s*\{[^}]*position:\s*static[^}]*opacity:\s*1/);
assert.match(block, /\.chip \.chip-dot\s*\{\s*display:\s*none/);
});
// Without `:has()` nothing reads #nav-toggle, so an off-canvas sidebar there is unreachable and the
// hamburger does nothing: the sidebar has to stay in the flow, with the hamburger hidden.
test("styles.css moves the sidebar off-canvas at 860px only inside @supports selector(:has(a))", () => {
const open = stylesheet.indexOf("@media (max-width: 860px) {");
assert.ok(open >= 0, "no @media (max-width: 860px) block");
const close = blockEnd(open);
const media = stylesheet.slice(open, close);
const guard = media.indexOf("@supports selector(:has(a)) {");
assert.ok(guard >= 0, "no @supports selector(:has(a)) block inside the 860px media block");
const guardEnd = blockEnd(open + guard) - open;
const outside = media.slice(0, guard) + media.slice(guardEnd + 1);
const inside = media.slice(guard, guardEnd);
for (const offCanvas of [/translateX\(-100%\)/, /position:\s*fixed/, /#nav-toggle/, /\.hamburger/]) {
assert.doesNotMatch(outside, offCanvas, `${offCanvas} applies outside the guard`);
assert.match(inside, offCanvas, `${offCanvas} is gone from the guard`);
}
});
-249
View File
@@ -1,249 +0,0 @@
// floorViolations checks a stylesheet against FLOOR, the browser floor of README → "Two browser
// floors", using MDN's compat data. A feature one of its engines lacks passes only inside an
// `@supports` whose condition requires it; an at-rule or a nested rule the floor lacks fails even there.
// Checked: at-rules, media features, pseudo-classes and -elements, properties, and, in a property's
// value, its functions, its units and the keywords MDN lists for that property. A name MDN has no
// entry for fails. A media feature the floor lacks passes, since an engine without it reads the query
// as false; media range and `or` syntax fail. A partial implementation counts as support, since MDN
// marks edge gaps that way, such as `outline` ignoring `border-radius` before Chrome 94. A
// vendor-prefixed name is one engine's by design and is skipped, as are `font-family` names: an engine
// skips a family it lacks.
// Not checked: the body of an at-rule other than @media and @supports.
import bcd from "@mdn/browser-compat-data" with { type: "json" };
import type { CompatStatement, Identifier, SimpleSupportStatement, SupportBlock } from "@mdn/browser-compat-data";
const FLOOR = [["chrome", "87"], ["safari", "15"], ["firefox", "115"]] as const;
interface Feature {
label: string;
path: string | undefined;
// Matches the feature in an `@supports` condition that tests for it.
guard: RegExp;
offset: number;
}
interface Sheet {
css: string;
out: string[];
}
export function floorViolations(css: string): string[] {
const sheet = { css: blank(css), out: [] };
const open = unclosed(sheet.css);
if (open !== undefined) return [`line ${lineAt(sheet.css, open)}: ${sheet.css[open]} is never closed`];
walk(sheet, 0, sheet.css.length, []);
return [...new Set(sheet.out)];
}
// The innermost bracket nothing closes: a browser reads the rest of the sheet as inside it.
function unclosed(css: string): number | undefined {
const open: { at: number; closer: string }[] = [];
for (let i = 0; i < css.length; i++) {
if (css[i] === "{" || css[i] === "(") open.push({ at: i, closer: css[i] === "{" ? "}" : ")" });
else if (css[i] === open.at(-1)?.closer) open.pop();
}
return open.at(-1)?.at;
}
const lineAt = (css: string, offset: number): number => css.slice(0, offset).split("\n").length;
// Comments, string contents and url() contents become spaces, so nothing inside one reads as syntax
// and every offset keeps its line.
function blank(css: string): string {
return css.replace(/\/\*[\s\S]*?\*\/|"(?:[^"\\\n]|\\.)*"|'(?:[^'\\\n]|\\.)*'|url\([^)]*\)/g, (m) => {
if (m.startsWith("/*")) return m.replace(/[^\n]/g, " ");
const keep = m.startsWith("url(") ? 4 : 1;
return m.slice(0, keep) + " ".repeat(m.length - keep - 1) + m.slice(-1);
});
}
// The end of `text` when nothing closes the bracket. floorViolations refuses a sheet with one left
// open, so only an @supports condition cut at a `;` or `{` inside its parentheses gets there.
function closing(text: string, open: number): number {
const [opener, closer] = text[open] === "(" ? ["(", ")"] : ["{", "}"];
let depth = 0;
for (let i = open; i < text.length; i++) {
if (text[i] === opener) depth++;
if (text[i] === closer && --depth === 0) return i;
}
return text.length;
}
function walk(sheet: Sheet, start: number, end: number, guards: string[]): void {
let i = start;
while (i < end) {
const stop = sheet.css.slice(i, end).search(/[{;]/);
if (stop < 0) return;
const at = i + stop;
const raw = sheet.css.slice(i, at);
const prelude = raw.trim();
const offset = i + raw.length - raw.trimStart().length;
const blockEnd = sheet.css[at] === "{" ? closing(sheet.css, at) : at;
if (prelude.startsWith("@")) atRule(sheet, prelude, offset, guards, [at + 1, blockEnd]);
else styleRule(sheet, prelude, offset, guards, [at + 1, blockEnd]);
i = blockEnd + 1;
}
}
function atRule(sheet: Sheet, prelude: string, offset: number, guards: string[], [from, to]: [number, number]): void {
const name = /^@([\w-]+)/.exec(prelude)?.[1]?.toLowerCase() ?? "";
if (name.startsWith("-")) return;
check(sheet, guards, { label: `@${name}`, path: `css.at-rules.${name}`, guard: never, offset });
const condition = prelude.slice(name.length + 1);
if (name === "media") mediaQuery(sheet, guards, condition, offset);
if (name === "supports" && /(?<![\w-])selector\(/i.test(condition)) {
check(sheet, guards, { label: "selector()", path: "css.at-rules.supports.selector", guard: never, offset });
}
if (name === "media" || name === "supports") {
walk(sheet, from, to, name === "supports" ? [...guards, positive(condition)] : guards);
}
}
// Range and `or` syntax each have a floor spelling (`min-`/`max-`, a comma list), so a query using
// one was meant for every engine, and a floor engine drops it whole.
function mediaQuery(sheet: Sheet, guards: string[], condition: string, offset: number): void {
for (const m of condition.matchAll(/\(\s*(?:min-|max-)?([a-z][\w-]*)\s*[:)]/gi)) {
const path = `css.at-rules.media.${m[1]}`;
if (!entry(path)) check(sheet, guards, { label: `(${m[1]})`, path, guard: never, offset });
}
if (/[<>=]/.test(condition)) {
check(sheet, guards, { label: "media range syntax", path: "css.at-rules.media.range_syntax", guard: never, offset });
}
if (OR.test(condition)) {
check(sheet, guards, { label: "media or syntax", path: "css.at-rules.media.or_syntax", guard: never, offset });
}
}
// The parts of an @supports condition that must hold for its block to apply: neither a `not (…)`
// group nor an operand of an `or`, at any depth.
function positive(condition: string): string {
let top = "";
let kept = "";
let from = 0;
for (let open = condition.indexOf("("); open >= 0; open = condition.indexOf("(", from)) {
const close = closing(condition, open);
const head = condition.slice(from, open);
const inner = condition.slice(open + 1, close);
top += head;
if (NEGATED.test(head)) kept += head.replace(NEGATED, "");
else if (/[\w-]$/.test(head)) kept += `${head}(${inner})`; // a function such as selector()
else kept += `${head}(${positive(inner)})`;
from = close + 1;
}
return OR.test(top + condition.slice(from)) ? "" : kept + condition.slice(from);
}
const OR = /(?<![\w-])or\s/i;
const NEGATED = /(?<![\w-])not\s+[\w-]*$/i;
function styleRule(sheet: Sheet, selector: string, offset: number, guards: string[], [from, to]: [number, number]): void {
for (const m of selector.matchAll(/::?([a-z][\w-]*)/gi)) {
const name = (m[1] ?? "").toLowerCase();
check(sheet, guards, { label: `:${name}`, path: `css.selectors.${name}`, guard: new RegExp(`:${name}(?![\\w-])`, "i"), offset: offset + (m.index ?? 0) });
}
const body = sheet.css.slice(from, to);
if (body.includes("{")) {
check(sheet, guards, { label: "a nested rule", path: "css.selectors.nesting", guard: never, offset: from });
return;
}
let at = from;
for (const declaration of body.split(";")) {
const colon = declaration.indexOf(":");
if (colon > 0) property(sheet, guards, declaration.slice(0, colon).trim().toLowerCase(), declaration.slice(colon + 1), at + declaration.search(/\S/));
at += declaration.length + 1;
}
}
function property(sheet: Sheet, guards: string[], name: string, value: string, offset: number): void {
if (name.startsWith("-") && !name.startsWith("--")) return;
const custom = name.startsWith("--");
const known = custom || check(sheet, guards, { label: name, path: `css.properties.${name}`, guard: word(name), offset });
for (const m of value.matchAll(/(?<![\w-])([a-z][\w-]*)\(/gi)) {
const fn = m[1] ?? "";
check(sheet, guards, { label: `${fn}()`, path: functionPath(name, fn), guard: new RegExp(`(?<![\\w-])${fn}\\(`, "i"), offset });
}
for (const m of value.replace(/#[\da-f]+/gi, "").matchAll(/(?<![\w.])\d*\.?\d+([a-z]+|%)/gi)) {
const unit = (m[1] ?? "").toLowerCase();
check(sheet, guards, { label: unit, path: unitPath(unit), guard: new RegExp(`\\d${unit}(?![\\w-])`, "i"), offset });
}
if (custom || !known || name === "font-family") return;
for (const m of value.matchAll(/(?<![\w-])([a-z][\w-]*)(?![\w(-])/gi)) {
const path = `css.properties.${name}.${(m[1] ?? "").toLowerCase()}`;
if (entry(path)) check(sheet, guards, { label: `${name}: ${m[1]}`, path, guard: word(m[1] ?? ""), offset });
}
}
const never = /(?!)/;
const word = (name: string): RegExp => new RegExp(`(?<![\\w-])${name}(?![\\w-])`, "i");
// A name two types share, like rect() in <shape> and <basic-shape>, passes where the floor has either.
function functionPath(property: string, fn: string): string | undefined {
const name = fn === "rgba" ? "rgb" : fn; // MDN files rgba() under rgb(), its alias
const types = Object.keys(bcd.css.types ?? {}).map((type) => `css.types.${type}.${name}`);
const paths = [`css.properties.${property}.${name}`, `css.types.${name}`, ...types].filter((path) => entry(path));
return paths.find((path) => lacking(entry(path)?.support ?? {}).length === 0) ?? paths[0];
}
const VIEWPORT: Record<string, string> = { d: "dynamic", s: "small", l: "large" };
const UNIT_TYPES: Record<string, string> = {
px: "css.types.length",
"%": "css.types.percentage",
fr: "css.types.flex",
s: "css.types.time",
ms: "css.types.time",
};
function unitPath(unit: string): string | undefined {
const viewport = /^([dsl])v(?:h|w|i|b|min|max)$/.exec(unit)?.[1];
if (viewport) return `css.types.length.viewport_percentage_units_${VIEWPORT[viewport]}`;
return UNIT_TYPES[unit] ?? ["length", "angle", "resolution"].map((type) => `css.types.${type}.${unit}`).find((path) => entry(path));
}
function entry(path: string | undefined): CompatStatement | undefined {
if (!path) return undefined;
let node: Identifier | undefined = { css: bcd.css };
for (const key of path.split(".")) node = node && Object.hasOwn(node, key) ? node[key] : undefined;
return node?.__compat;
}
// Reports the feature unless a guard names it or the floor has it; returns true when either holds.
function check(sheet: Sheet, guards: string[], feature: Feature): boolean {
if (guards.some((condition) => feature.guard.test(condition))) return true;
const line = lineAt(sheet.css, feature.offset);
const compat = entry(feature.path);
if (!compat) {
sheet.out.push(`line ${line}: ${feature.label} has no MDN compat entry`);
return false;
}
const missing = lacking(compat.support);
if (missing.length > 0) sheet.out.push(`line ${line}: ${feature.label} needs ${missing.join(", ")} (${feature.path})`);
return missing.length === 0;
}
// Each floor engine without the feature, with the first later release that has it, as "safari 15.4".
export function lacking(support: SupportBlock): string[] {
return FLOOR.flatMap(([browser, floor]) => {
const statements = [support[browser] ?? []].flat().filter((s) => !s.prefix && !s.alternative_name && !s.flags);
const has = (s: SimpleSupportStatement): boolean => {
const added = release(s.version_added);
return added !== undefined && !newer(added, floor) && !(s.version_removed && !newer(s.version_removed, floor));
};
if (statements.some(has)) return [];
const releases = statements.flatMap((s) => release(s.version_added) ?? []).filter((v) => newer(v, floor));
releases.sort((x, y) => (newer(x, y) ? 1 : -1));
return [`${browser} ${releases[0] ?? "(none yet)"}`];
});
}
// A shipped release number; "≤79" counts as 79, "preview" and false as none.
function release(version: string | false): string | undefined {
return typeof version === "string" && /^≤?\d/.test(version) ? version.replace("≤", "") : undefined;
}
function newer(version: string, than: string): boolean {
const [a, b] = [version, than].map((v) => v.split(".").map(Number));
for (let i = 0; i < Math.max(a?.length ?? 0, b?.length ?? 0); i++) {
if ((a?.[i] ?? 0) !== (b?.[i] ?? 0)) return (a?.[i] ?? 0) > (b?.[i] ?? 0);
}
return false;
}