Answer stability round 1: 0 🔴, 7 🟡 (0 misread); range and or media syntax fail, an or operand guards nothing, the anchoring guard splits from position-try-fallbacks, the casts go, the filters and at-rules are tested
This commit is contained in:
+76
-34
@@ -1,14 +1,18 @@
|
||||
// README → "Two browser floors", checked against MDN's compat data: a feature one of FLOOR's engines
|
||||
// lacks passes only inside an `@supports` whose condition names it. Checked: at-rules, media features,
|
||||
// pseudo-classes and -elements, properties, and in a value its functions, its units and the keywords
|
||||
// MDN lists for that property. A partial implementation counts as support. 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, and syntax MDN has no entry for,
|
||||
// such as media range syntax.
|
||||
// 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 } from "@mdn/browser-compat-data";
|
||||
import type { CompatStatement, Identifier, SimpleSupportStatement, SupportBlock } from "@mdn/browser-compat-data";
|
||||
|
||||
const FLOOR = { chrome: "87", safari: "15", firefox: "115" };
|
||||
const FLOOR = [["chrome", "87"], ["safari", "15"], ["firefox", "115"]] as const;
|
||||
|
||||
interface Feature {
|
||||
label: string;
|
||||
@@ -25,10 +29,24 @@ interface Sheet {
|
||||
|
||||
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 {
|
||||
@@ -39,6 +57,8 @@ function blank(css: string): string {
|
||||
});
|
||||
}
|
||||
|
||||
// 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;
|
||||
@@ -46,7 +66,7 @@ function closing(text: string, open: number): number {
|
||||
if (text[i] === opener) depth++;
|
||||
if (text[i] === closer && --depth === 0) return i;
|
||||
}
|
||||
throw new Error(`unbalanced ${opener} at offset ${open}`);
|
||||
return text.length;
|
||||
}
|
||||
|
||||
function walk(sheet: Sheet, start: number, end: number, guards: string[]): void {
|
||||
@@ -70,11 +90,7 @@ function atRule(sheet: Sheet, prelude: string, offset: number, guards: string[],
|
||||
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") {
|
||||
for (const m of condition.matchAll(/\(\s*(?:min-|max-)?([a-z][\w-]*)\s*[:)]/gi)) {
|
||||
check(sheet, guards, { label: `(${m[1]})`, path: `css.at-rules.media.${m[1]}`, guard: never, offset });
|
||||
}
|
||||
}
|
||||
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 });
|
||||
}
|
||||
@@ -83,17 +99,43 @@ function atRule(sheet: Sheet, prelude: string, offset: number, guards: string[],
|
||||
}
|
||||
}
|
||||
|
||||
// The parts of an @supports condition that hold inside it: a `not (…)` group holds only outside.
|
||||
function positive(condition: string): string {
|
||||
let rest = condition;
|
||||
for (let at = rest.search(/(?<![\w-])not\s/i); at >= 0; at = rest.search(/(?<![\w-])not\s/i)) {
|
||||
const open = rest.indexOf("(", at);
|
||||
if (open < 0) return rest.slice(0, at);
|
||||
rest = rest.slice(0, at) + rest.slice(closing(rest, open) + 1);
|
||||
// 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 });
|
||||
}
|
||||
return rest;
|
||||
}
|
||||
|
||||
// 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();
|
||||
@@ -139,7 +181,7 @@ 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)).length === 0) ?? paths[0];
|
||||
return paths.find((path) => lacking(entry(path)?.support ?? {}).length === 0) ?? paths[0];
|
||||
}
|
||||
|
||||
const VIEWPORT: Record<string, string> = { d: "dynamic", s: "small", l: "large" };
|
||||
@@ -159,36 +201,36 @@ function unitPath(unit: string): string | undefined {
|
||||
|
||||
function entry(path: string | undefined): CompatStatement | undefined {
|
||||
if (!path) return undefined;
|
||||
let node: Identifier | undefined = bcd as unknown as Identifier;
|
||||
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; true when it is fine as written.
|
||||
// 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 = sheet.css.slice(0, feature.offset).split("\n").length;
|
||||
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);
|
||||
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 release that brought it, as "safari 15.4".
|
||||
function lacking(compat: CompatStatement | undefined): string[] {
|
||||
return Object.entries(FLOOR).flatMap(([browser, floor]) => {
|
||||
const support = compat?.support[browser as keyof typeof compat.support];
|
||||
const statements = [support ?? []].flat().filter((s: SimpleSupportStatement) => !s.prefix && !s.alternative_name && !s.flags);
|
||||
// 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) ?? []).sort((x, y) => (newer(x, y) ? 1 : -1));
|
||||
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)"}`];
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user