refactor(stylesheet): move Semantic CSS to the browser (#3329)

This commit is contained in:
Amruth Pillai
2026-08-16 16:50:27 +02:00
committed by GitHub
parent f848e57436
commit 9509b5bc2e
203 changed files with 11838 additions and 14842 deletions
+13 -4
View File
@@ -280,7 +280,7 @@ describe("Semantic CSS cascade and structural resolution", () => {
});
if (!compiled.program) throw new Error(compiled.diagnostics.map(({ code }) => code).join(","));
const cycled = resolveStylesheet(compiled.program, tree, context);
expect(cycled.nodes).toEqual({});
expect(cycled.nodes["heading-experience"]?.style.color).toBe("black");
expect(cycled.diagnostics).toContainEqual(expect.objectContaining({ code: "VARIABLE_CYCLE", severity: "error" }));
});
@@ -356,6 +356,15 @@ describe("Semantic CSS cascade and structural resolution", () => {
);
});
it("keeps valid resolved declarations when a neighboring value is invalid", () => {
const result = resolve("section-heading { color: red; opacity: var(--missing); }");
expect(result.nodes["heading-experience"]?.style.color).toBe("red");
expect(result.diagnostics).toContainEqual(
expect.objectContaining({ code: "UNRESOLVED_VARIABLE", severity: "error" }),
);
});
it("warns after variable expansion when a value is extreme but technically renderable", () => {
const result = resolve(":root { --tiny: 3pt; } section-heading { font-size: var(--tiny); }");
@@ -379,7 +388,7 @@ describe("Semantic CSS cascade and structural resolution", () => {
languageVersion: 1,
text: "@version 1; @media (width: 400pt) { page { size: A4; } }",
});
expect(invalid.program).toBeNull();
expect(invalid.program).not.toBeNull();
expect(invalid.diagnostics).toContainEqual(expect.objectContaining({ code: "MEDIA_PAGE_SIZE", severity: "error" }));
});
@@ -513,14 +522,14 @@ describe("Semantic CSS cascade and structural resolution", () => {
languageVersion: 1,
text: `@version 1;section-heading{${declaration}}`,
});
expect(compiled.program, declaration).toBeNull();
expect(compiled.program, declaration).not.toBeNull();
expect(compiled.diagnostics, declaration).toContainEqual(
expect.objectContaining({ code: "INVALID_VALUE", severity: "error" }),
);
}
const variable = resolve(":root { --bad: 1.0001; } section-heading { opacity: var(--bad); }");
expect(variable.nodes).toEqual({});
expect(variable.nodes["heading-experience"]?.style.color).toBe("black");
expect(variable.diagnostics).toContainEqual(expect.objectContaining({ code: "INVALID_VALUE", severity: "error" }));
});
+2 -2
View File
@@ -14,7 +14,7 @@ import type {
StructuralPresentation,
StyleProgram,
} from "./types";
import { createDiagnostic } from "./diagnostics";
import { createDiagnostic, isFatalStylesheetDiagnostic } from "./diagnostics";
import { SEMANTIC_CSS_LIMITS_V1 } from "./limits";
import { PROPERTY_REGISTRY_V1 } from "./registry/properties";
import { createSystemVariables } from "./registry/system-variables";
@@ -927,7 +927,7 @@ export function resolveStylesheet(
};
}
if (diagnostics.some(({ severity }) => severity === "error")) {
if (diagnostics.some(isFatalStylesheetDiagnostic)) {
return { nodes: {}, renderTree: tree, diagnostics };
}
return { nodes: resolved, renderTree: createRenderTree(tree, flatNodes, resolved), diagnostics };
+3 -3
View File
@@ -1,7 +1,7 @@
import type { StylesheetSource } from "@reactive-resume/schema/resume/stylesheet";
import type { CompileStylesheetResult } from "./types";
import { stylesheetCacheKey, stylesheetCompilationCache } from "./cache";
import { createDiagnostic } from "./diagnostics";
import { createDiagnostic, isFatalStylesheetDiagnostic } from "./diagnostics";
import { SEMANTIC_CSS_LIMITS_V1 } from "./limits";
import { parseStylesheet } from "./parse";
import { PROPERTY_REGISTRY_V1 } from "./registry/properties";
@@ -98,13 +98,13 @@ export function compileStylesheet(source: StylesheetSource): CompileStylesheetRe
);
}
if (!compiler || diagnostics.some((diagnostic) => diagnostic.severity === "error")) {
if (!compiler || diagnostics.some(isFatalStylesheetDiagnostic)) {
return { program: null, diagnostics };
}
const compiled = compileProgram(stylesheet, source.languageVersion);
diagnostics.push(...compiled.diagnostics);
if (!compiled.program || diagnostics.some(({ severity }) => severity === "error")) {
if (!compiled.program || diagnostics.some(isFatalStylesheetDiagnostic)) {
return { program: null, diagnostics };
}
@@ -122,6 +122,18 @@ export const SEMANTIC_CSS_DIAGNOSTIC_CATALOG_V1 = {
export type SemanticCssCompilerDiagnosticCode = keyof typeof SEMANTIC_CSS_DIAGNOSTIC_CATALOG_V1;
const FATAL_DIAGNOSTIC_CODES = new Set<string>([
"DUPLICATE_VERSION_DIRECTIVE",
"INVALID_VERSION",
"RESOURCE_LIMIT",
"UNSUPPORTED_VERSION",
"VERSION_MISMATCH",
]);
export function isFatalStylesheetDiagnostic({ code }: Pick<SemanticCssDiagnostic, "code">): boolean {
return FATAL_DIAGNOSTIC_CODES.has(code);
}
export const EMPTY_SOURCE_RANGE: SourceRange = {
start: { line: 1, column: 1, offset: 0 },
end: { line: 1, column: 1, offset: 0 },
+1 -5
View File
@@ -5,8 +5,6 @@ export type { SemanticCssCompilerDiagnosticCode } from "./diagnostics";
export type { PropertyDefinition, PropertyRegistry } from "./registry/properties";
export type { SemanticNodeDefinition, SemanticRegistry } from "./registry/semantic";
export type { SystemVariableDefinition, SystemVariableRegistry } from "./registry/system-variables";
export type { RenderDataProjection } from "./render-data";
export type { RenderDataHashInput } from "./render-hash";
export type { CompiledSelector, CompileSelectorResult, Specificity } from "./selector";
export type { GeneratedStylesheet, GeneratedStylesheetBlock } from "./serialize";
export type {
@@ -37,7 +35,7 @@ export type {
export { analyzeStylesheet } from "./analyze";
export { resolveStylesheet } from "./cascade";
export { compileStylesheet } from "./compile";
export { SEMANTIC_CSS_DIAGNOSTIC_CATALOG_V1 } from "./diagnostics";
export { isFatalStylesheetDiagnostic, SEMANTIC_CSS_DIAGNOSTIC_CATALOG_V1 } from "./diagnostics";
export { SEMANTIC_CSS_LIMITS_V1 } from "./limits";
export { parseStylesheet } from "./parse";
export { PROPERTY_REGISTRY_V1 } from "./registry/properties";
@@ -48,8 +46,6 @@ export {
TEMPLATE_PART_CHILD_KINDS_V1,
} from "./registry/semantic";
export { createSystemVariables, SYSTEM_VARIABLE_REGISTRY_V1 } from "./registry/system-variables";
export { projectPublicRenderData, projectRenderData } from "./render-data";
export { computeRenderDataHash } from "./render-hash";
export { compileSelector, createSelectorMatcher, getSpecificity, matchesSelector } from "./selector";
export { escapeCssComment, escapeCssString, serializeGeneratedStylesheet } from "./serialize";
export { SUPPORTED_SEMANTIC_CSS_VERSIONS } from "./version";
+12 -16
View File
@@ -49,8 +49,20 @@ export function parseStylesheet(source: string): ParsedStylesheet {
if (!ast) return { ast: null, atRules: [], rules: [], diagnostics };
const atRules: ParsedAtRule[] = [];
const rawRanges = new Set<number>();
csstree.walk(ast, function (this: { declaration?: { property?: string } | null }, node: CssNode) {
if (node.type === "Atrule" && node.name) {
const prelude = node.prelude?.loc
? source.slice(node.prelude.loc.start.offset, node.prelude.loc.end.offset).trim()
: "";
atRules.push({
name: csstree.ident.decode(node.name).toLowerCase(),
prelude,
hasBlock: node.block !== null,
range: rangeFromLocation(node.loc),
});
}
if (node.type !== "Raw") return;
if (this.declaration?.property?.startsWith("--")) return;
@@ -62,22 +74,6 @@ export function parseStylesheet(source: string): ParsedStylesheet {
});
const nodes = topLevelNodes(ast);
const atRules: ParsedAtRule[] = nodes.flatMap((node) => {
if (node.type !== "Atrule" || !node.name) return [];
const prelude = node.prelude?.loc
? source.slice(node.prelude.loc.start.offset, node.prelude.loc.end.offset).trim()
: "";
return [
{
name: csstree.ident.decode(node.name).toLowerCase(),
prelude,
hasBlock: node.block !== null,
range: rangeFromLocation(node.loc),
},
];
});
return {
ast,
@@ -1,96 +0,0 @@
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import { describe, expect, it } from "vitest";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { projectPublicRenderData, projectRenderData } from "./render-data";
import { computeRenderDataHash } from "./render-hash";
const legacyData: ResumeData = {
...defaultResumeData,
metadata: {
...defaultResumeData.metadata,
styleRules: [
{
id: "legacy",
label: "Legacy",
enabled: true,
target: { scope: "global" },
slots: { text: { color: "#123456" } },
},
],
},
};
const semanticData: ResumeData = {
...legacyData,
metadata: {
...legacyData.metadata,
stylesheet: {
mode: "semantic",
source: { languageVersion: 1, text: "@version 1;\nfield { color: red; }" },
applied: { languageVersion: 1, text: "@version 1;\nfield { color: blue; }" },
},
},
};
describe("render-data projection", () => {
it("separates resume render-data identity from stylesheet revision identity", () => {
const legacy = projectRenderData(legacyData);
const semanticPrivate = projectRenderData(semanticData);
const semanticPublic = projectPublicRenderData(semanticData);
expect(legacy.metadata.styleRules).toEqual(legacyData.metadata.styleRules);
expect(semanticPrivate.metadata.styleRules).toBeUndefined();
expect(semanticPrivate.metadata.stylesheet).toBeUndefined();
expect(semanticPublic.metadata.styleRules).toBeUndefined();
expect(semanticPublic.metadata.stylesheet).toBeUndefined();
expect(semanticPublic.metadata.notes).toBeUndefined();
});
it("excludes unknown and server-only data at every projection boundary", () => {
const looseData = {
...semanticData,
dashboard: { secret: true },
revisions: { stylesheetRevision: 4, renderDataVersion: 8 },
metadata: {
...semanticData.metadata,
notes: "private",
diagnostics: [{ message: "private" }],
serverMetadata: { secret: true },
},
picture: { ...semanticData.picture, unknownPictureField: true },
} as ResumeData;
const privateProjection = projectRenderData(looseData) as Record<string, unknown>;
const publicProjection = projectPublicRenderData(looseData) as Record<string, unknown>;
for (const projection of [privateProjection, publicProjection]) {
expect(projection).not.toHaveProperty("dashboard");
expect(projection).not.toHaveProperty("revisions");
expect(projection.metadata).not.toHaveProperty("notes");
expect(projection.metadata).not.toHaveProperty("diagnostics");
expect(projection.metadata).not.toHaveProperty("serverMetadata");
expect(projection.picture).not.toHaveProperty("unknownPictureField");
}
});
it("keeps public projection hashes invariant to owner-only metadata", async () => {
const baseline = projectPublicRenderData(semanticData);
const ownerOnlyMutation = {
...semanticData,
dashboard: { private: true },
revisions: { renderDataVersion: 9, stylesheetRevision: 11 },
metadata: {
...semanticData.metadata,
notes: "owner-only note",
diagnostics: [{ message: "owner-only diagnostic" }],
serverMetadata: { private: true },
},
} as ResumeData;
const mutated = projectPublicRenderData(ownerOnlyMutation);
expect(mutated).toEqual(baseline);
await expect(computeRenderDataHash({ domainVersion: 1, data: mutated })).resolves.toBe(
await computeRenderDataHash({ domainVersion: 1, data: baseline }),
);
});
});
@@ -1,49 +0,0 @@
import type { ResumeData, StyleRule } from "@reactive-resume/schema/resume/data";
import { resumeDataSchema } from "@reactive-resume/schema/resume/data";
export type RenderDataProjection = {
picture: ResumeData["picture"];
basics: ResumeData["basics"];
summary: ResumeData["summary"];
sections: ResumeData["sections"];
customSections: ResumeData["customSections"];
metadata: {
template: ResumeData["metadata"]["template"];
layout: ResumeData["metadata"]["layout"];
page: ResumeData["metadata"]["page"];
design: ResumeData["metadata"]["design"];
typography: ResumeData["metadata"]["typography"];
styleRules?: StyleRule[];
stylesheet?: never;
notes?: never;
};
};
function project(data: ResumeData): RenderDataProjection {
const parsed = resumeDataSchema.parse(data);
const includeLegacyRules = parsed.metadata.stylesheet?.mode !== "semantic";
return {
picture: parsed.picture,
basics: parsed.basics,
summary: parsed.summary,
sections: parsed.sections,
customSections: parsed.customSections,
metadata: {
template: parsed.metadata.template,
layout: parsed.metadata.layout,
page: parsed.metadata.page,
design: parsed.metadata.design,
typography: parsed.metadata.typography,
...(includeLegacyRules ? { styleRules: parsed.metadata.styleRules } : {}),
},
};
}
export function projectRenderData(data: ResumeData): RenderDataProjection {
return project(data);
}
export function projectPublicRenderData(data: ResumeData): RenderDataProjection {
return project(data);
}
@@ -1,90 +0,0 @@
import { describe, expect, it } from "vitest";
import { computeRenderDataHash } from "./render-hash";
describe("public render hashing", () => {
it("hashes logically equivalent public render inputs identically", async () => {
const first = await computeRenderDataHash({ domainVersion: 1, data: { b: 2, a: 1 } });
const second = await computeRenderDataHash({ domainVersion: 1, data: { a: 1, b: 2 } });
expect(first).toBe(second);
});
it("uses the domain-separated RFC 8785 SHA-256 vector", async () => {
await expect(computeRenderDataHash({ domainVersion: 1, data: { a: 1 } })).resolves.toBe(
"81d98262808eb01af7bb5cf35b721acf0454659330e1715c665e42efffc27e55",
);
});
it("includes source-free resolved nodes and fingerprints", async () => {
const base = { domainVersion: 1, data: { resume: { name: "Ada" } } };
const first = await computeRenderDataHash({
...base,
resolvedNodes: { name: { style: { color: "red" } } },
projectionFingerprints: { adapter: "v1", registry: "v1" },
});
const second = await computeRenderDataHash({
...base,
resolvedNodes: { name: { style: { color: "blue" } } },
projectionFingerprints: { adapter: "v1", registry: "v1" },
});
expect(first).not.toBe(second);
});
it.each([undefined, Number.NaN, Number.POSITIVE_INFINITY, 1n, new Date()])(
"rejects non-I-JSON values",
async (data) => {
await expect(computeRenderDataHash({ domainVersion: 1, data })).rejects.toThrow("I-JSON");
},
);
it("rejects unpaired surrogates before canonicalization", async () => {
const malformed = String.fromCharCode(0xd800);
await expect(computeRenderDataHash({ domainVersion: 1, data: malformed })).rejects.toThrow("I-JSON");
});
it("rejects hidden toJSON hooks before canonicalization", async () => {
const data = {};
Object.defineProperty(data, "toJSON", { value: () => ({ altered: true }) });
await expect(computeRenderDataHash({ domainVersion: 1, data })).rejects.toThrow("I-JSON");
});
it("rejects array accessors without invoking them", async () => {
const data: unknown[] = [];
let reads = 0;
Object.defineProperty(data, "0", {
enumerable: true,
get: () => {
reads++;
return "unsafe";
},
});
await expect(computeRenderDataHash({ domainVersion: 1, data })).rejects.toThrow("I-JSON");
expect(reads).toBe(0);
});
it.each([
() => {
const data: unknown[] = [];
Reflect.set(data, Symbol("private"), true);
return data;
},
() => {
const data: unknown[] = [];
Object.defineProperty(data, "private", { value: true });
return data;
},
() => {
const data: unknown[] = [];
Object.defineProperty(data, "toJSON", { value: () => ["altered"] });
return data;
},
])("rejects non-index array properties", async (createData) => {
await expect(computeRenderDataHash({ domainVersion: 1, data: createData() })).rejects.toThrow("I-JSON");
});
it("rejects unknown hash-domain versions", async () => {
await expect(computeRenderDataHash({ domainVersion: 2, data: {} })).rejects.toThrow("domain version");
});
});
@@ -1,120 +0,0 @@
import canonicalize from "canonicalize";
const HASH_DOMAIN_VERSION = 1;
const HASH_DOMAIN_PREFIX = "reactive-resume:public-style-projection:v1\0";
export type RenderDataHashInput = {
domainVersion: number;
data: unknown;
resolvedNodes?: unknown;
projectionFingerprints?: unknown;
};
function hasUnpairedSurrogate(value: string): boolean {
for (let index = 0; index < value.length; index++) {
const code = value.charCodeAt(index);
if (code >= 0xd800 && code <= 0xdbff) {
const next = value.charCodeAt(index + 1);
if (!(next >= 0xdc00 && next <= 0xdfff)) return true;
index++;
} else if (code >= 0xdc00 && code <= 0xdfff) {
return true;
}
}
return false;
}
function assertEnumerableDataProperty(
descriptor: PropertyDescriptor | undefined,
): asserts descriptor is PropertyDescriptor & {
value: unknown;
} {
if (!descriptor?.enumerable || "get" in descriptor || "set" in descriptor) {
throw new Error("I-JSON objects must contain only enumerable data properties");
}
}
function isArrayIndex(key: string, length: number): boolean {
if (!/^(0|[1-9]\d*)$/.test(key)) return false;
const index = Number(key);
return Number.isSafeInteger(index) && index < length && String(index) === key;
}
function assertIJsonValue(value: unknown, seen = new Set<object>()): void {
if (value === null || typeof value === "boolean") return;
if (typeof value === "string") {
if (hasUnpairedSurrogate(value)) throw new Error("I-JSON values must not contain unpaired surrogates");
return;
}
if (typeof value === "number") {
if (Number.isFinite(value)) return;
throw new Error("I-JSON numbers must be finite");
}
if (typeof value !== "object") throw new Error("I-JSON values must be JSON primitives, arrays, or plain objects");
if (seen.has(value)) throw new Error("I-JSON values must not be circular");
const prototype = Object.getPrototypeOf(value);
if (!Array.isArray(value) && prototype !== Object.prototype && prototype !== null) {
throw new Error("I-JSON objects must be plain objects");
}
seen.add(value);
try {
if (Array.isArray(value)) {
for (const symbol of Object.getOwnPropertySymbols(value)) {
throw new Error(`I-JSON arrays must not contain symbol keys: ${String(symbol)}`);
}
const descriptors = Object.getOwnPropertyDescriptors(value);
for (const [key, descriptor] of Object.entries(descriptors)) {
if (key === "length") continue;
if (!isArrayIndex(key, value.length)) throw new Error("I-JSON arrays must not contain named properties");
assertEnumerableDataProperty(descriptor);
}
for (let index = 0; index < value.length; index++) {
const descriptor = descriptors[index];
if (!descriptor) throw new Error("I-JSON arrays must not contain holes");
assertIJsonValue(descriptor.value, seen);
}
} else {
for (const symbol of Object.getOwnPropertySymbols(value)) {
throw new Error(`I-JSON objects must not contain symbol keys: ${String(symbol)}`);
}
for (const key of Object.getOwnPropertyNames(value)) {
if (hasUnpairedSurrogate(key)) throw new Error("I-JSON keys must not contain unpaired surrogates");
const descriptor = Object.getOwnPropertyDescriptor(value, key);
assertEnumerableDataProperty(descriptor);
assertIJsonValue(descriptor.value, seen);
}
}
} finally {
seen.delete(value);
}
}
export async function computeRenderDataHash(input: RenderDataHashInput): Promise<string> {
if (input.domainVersion !== HASH_DOMAIN_VERSION) {
throw new Error(`Unsupported public render hash domain version: ${input.domainVersion}`);
}
const payload =
input.resolvedNodes === undefined && input.projectionFingerprints === undefined
? input.data
: {
data: input.data,
...(input.resolvedNodes === undefined ? {} : { resolvedNodes: input.resolvedNodes }),
...(input.projectionFingerprints === undefined
? {}
: { projectionFingerprints: input.projectionFingerprints }),
};
assertIJsonValue(payload);
const canonical = canonicalize(payload);
if (canonical === undefined) throw new Error("I-JSON value required for public render hashing");
const digest = await globalThis.crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(HASH_DOMAIN_PREFIX + canonical),
);
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
}
@@ -161,13 +161,22 @@ describe("semantic selector compilation", () => {
expect(compileStylesheet({ languageVersion: 1, text: fixture }).program).not.toBeNull();
const invalid = compileStylesheet({ languageVersion: 1, text: "@version 1;\nsection:hover { color: red; }" });
expect(invalid.program).toBeNull();
expect(invalid.program).not.toBeNull();
expect(invalid.diagnostics).toContainEqual(expect.objectContaining({ code: "INVALID_SELECTOR" }));
});
it("treats selector-count overflow as a fatal resource limit", () => {
const selectors = new Array(65).fill("section").join(",");
const result = compileStylesheet({ languageVersion: 1, text: `@version 1;\n${selectors} { color: red; }` });
expect(result.program).toBeNull();
expect(result.diagnostics).toContainEqual(expect.objectContaining({ code: "RESOURCE_LIMIT", severity: "error" }));
expect(result.diagnostics).not.toContainEqual(expect.objectContaining({ code: "INVALID_SELECTOR" }));
});
it("rejects uppercase pseudo names while compiling a stylesheet", () => {
const result = compileStylesheet({ languageVersion: 1, text: "@version 1;\n:ROOT { color: red; }" });
expect(result.program).toBeNull();
expect(result.program).not.toBeNull();
expect(result.diagnostics).toContainEqual(expect.objectContaining({ code: "INVALID_SELECTOR" }));
});
});
+16 -6
View File
@@ -46,6 +46,7 @@ export type CompiledSelector = {
export type CompileSelectorResult = {
selector: CompiledSelector | null;
error?: string;
resourceLimit?: true;
};
type SelectorAst = {
@@ -65,6 +66,8 @@ type CompileContext = {
depth: number;
};
class SelectorResourceLimitError extends Error {}
type TreeNode = {
node: SemanticNode;
parent: TreeNode | null;
@@ -208,7 +211,7 @@ function compileSimple(node: SelectorAst, context: CompileContext): CompiledSimp
}
if (["is", "where", "not"].includes(name)) {
if (context.depth >= SEMANTIC_CSS_LIMITS_V1.maxFunctionDepth) {
throw new Error("Selector function nesting is too deep.");
throw new SelectorResourceLimitError("Selector function nesting is too deep.");
}
const nested = childrenOf(node);
if (nested.length !== 1 || nested[0]?.type !== "SelectorList") {
@@ -219,7 +222,7 @@ function compileSimple(node: SelectorAst, context: CompileContext): CompiledSimp
}
if (name === "nth-child" || name === "nth-of-type") {
if (context.depth >= SEMANTIC_CSS_LIMITS_V1.maxFunctionDepth) {
throw new Error("Selector function nesting is too deep.");
throw new SelectorResourceLimitError("Selector function nesting is too deep.");
}
return compileNth(node, name, context);
}
@@ -251,7 +254,7 @@ function compileComplex(node: SelectorAst, context: CompileContext): CompiledCom
selectors = [];
combinators.push(name);
if (combinators.length > SEMANTIC_CSS_LIMITS_V1.maxCombinatorsPerSelector) {
throw new Error("Selector has too many combinators.");
throw new SelectorResourceLimitError("Selector has too many combinators.");
}
continue;
}
@@ -270,9 +273,12 @@ function compileComplex(node: SelectorAst, context: CompileContext): CompiledCom
function compileSelectorList(node: SelectorAst, context: CompileContext): readonly CompiledComplexSelector[] {
if (node.type !== "SelectorList") throw new Error("Expected a SelectorList AST.");
const selectors = childrenOf(node);
if (selectors.length === 0 || selectors.length > SEMANTIC_CSS_LIMITS_V1.maxSelectorsPerRule) {
if (selectors.length === 0) {
throw new Error("Selector list has an unsupported number of selectors.");
}
if (selectors.length > SEMANTIC_CSS_LIMITS_V1.maxSelectorsPerRule) {
throw new SelectorResourceLimitError("Selector list has an unsupported number of selectors.");
}
return selectors.map((selector) => compileComplex(selector, context));
}
@@ -280,13 +286,17 @@ export function compileSelector(source: string | CssNode): CompileSelectorResult
try {
const text = typeof source === "string" ? source : csstree.generate(source);
if (Array.from(text).length > SEMANTIC_CSS_LIMITS_V1.maxSelectorCodePoints)
throw new Error("Selector is too long.");
throw new SelectorResourceLimitError("Selector is too long.");
const ast = (
typeof source === "string" ? csstree.parse(source, { context: "selectorList", positions: true }) : source
) as SelectorAst;
return { selector: { selectors: compileSelectorList(ast, { depth: 0 }) } };
} catch (error) {
return { selector: null, error: error instanceof Error ? error.message : "Invalid selector." };
return {
selector: null,
error: error instanceof Error ? error.message : "Invalid selector.",
...(error instanceof SelectorResourceLimitError ? { resourceLimit: true } : {}),
};
}
}
+38 -7
View File
@@ -51,13 +51,44 @@ describe("Semantic CSS value compilation", () => {
expect(() => structuredClone(first.program)).not.toThrow();
});
it("keeps valid rules and declarations when neighboring fragments are invalid", () => {
const result = compileStylesheet({
languageVersion: 1,
text: "@version 1; section:hover { color: red; } name { unknown: 1; color: #123456; }",
});
expect(result.program?.rules).toEqual([
expect.objectContaining({
declarations: [expect.objectContaining({ property: "color", value: "#123456" })],
}),
]);
expect(result.diagnostics).toEqual(
expect.arrayContaining([
expect.objectContaining({ code: "INVALID_SELECTOR", severity: "error" }),
expect.objectContaining({ code: "UNSUPPORTED_PROPERTY", severity: "error" }),
]),
);
});
it("omits an invalid value without dropping valid declarations in the rule", () => {
const result = compileStylesheet({
languageVersion: 1,
text: "@version 1; name { opacity: 2; color: #123456; }",
});
expect(result.program?.rules[0]?.declarations).toEqual([
expect.objectContaining({ property: "color", value: "#123456" }),
]);
expect(result.diagnostics).toContainEqual(expect.objectContaining({ code: "INVALID_VALUE", severity: "error" }));
});
it("rejects assignments to reserved system variables", () => {
const result = compileStylesheet({
languageVersion: 1,
text: "@version 1; :root { --resume-primary-color: red; } name { color: blue; }",
});
expect(result.program).toBeNull();
expect(result.program).not.toBeNull();
expect(result.diagnostics).toContainEqual(
expect.objectContaining({ code: "SYSTEM_VARIABLE_READONLY", severity: "error" }),
);
@@ -70,7 +101,7 @@ describe("Semantic CSS value compilation", () => {
text: `@version 1; :root { --asset: ${value}; } picture { background-color: var(--asset); }`,
});
expect(result.program, value).toBeNull();
expect(result.program, value).not.toBeNull();
expect(result.diagnostics, value).toContainEqual(
expect.objectContaining({ code: "FORBIDDEN_CSS_VALUE", severity: "error" }),
);
@@ -93,7 +124,7 @@ describe("Semantic CSS value compilation", () => {
languageVersion: 1,
text: `@version 1; field { margin-top: ${value}; }`,
});
expect(result.program, value).toBeNull();
expect(result.program, value).not.toBeNull();
expect(result.diagnostics, value).toContainEqual(
expect.objectContaining({ code: "INVALID_VALUE", severity: "error" }),
);
@@ -121,7 +152,7 @@ describe("Semantic CSS value compilation", () => {
text: `@version 1; section { border-style: ${value}; }`,
});
expect(result.program).toBeNull();
expect(result.program).not.toBeNull();
expect(result.diagnostics).toContainEqual(expect.objectContaining({ code: "INVALID_VALUE", severity: "error" }));
},
);
@@ -211,7 +242,7 @@ describe("Semantic CSS value compilation", () => {
text: `@version 1;section{flex:${grow} auto ${trailing}}`,
});
expect(result.program).toBeNull();
expect(result.program).not.toBeNull();
expect(result.diagnostics).toContainEqual(
expect.objectContaining({ code: "INVALID_VALUE", severity: "error" }),
);
@@ -240,7 +271,7 @@ describe("Semantic CSS value compilation", () => {
text: `@version 1;section{${declaration}}`,
});
expect(rejected.program, declaration).toBeNull();
expect(rejected.program, declaration).not.toBeNull();
expect(rejected.diagnostics, declaration).toContainEqual(
expect.objectContaining({ code: "INVALID_VALUE", severity: "error" }),
);
@@ -406,7 +437,7 @@ describe("Semantic CSS value compilation", () => {
fc.assert(
fc.property(forbiddenBody, ({ body, code }) => {
const result = compileStylesheet({ languageVersion: 1, text: `@version 1;${body}` });
expect(result.program).toBeNull();
expect(result.program).not.toBeNull();
expect(result.diagnostics).toContainEqual(expect.objectContaining({ code, severity: "error" }));
}),
{ numRuns: 100 },
+5 -3
View File
@@ -11,7 +11,7 @@ import type {
StyleProgram,
} from "./types";
import * as csstree from "css-tree";
import { createDiagnostic, EMPTY_SOURCE_RANGE } from "./diagnostics";
import { createDiagnostic, EMPTY_SOURCE_RANGE, isFatalStylesheetDiagnostic } from "./diagnostics";
import { SEMANTIC_CSS_LIMITS_V1 } from "./limits";
import {
PROPERTY_REGISTRY_V1,
@@ -542,7 +542,7 @@ export function compileProgram(stylesheet: ParsedStylesheet, languageVersion: nu
? compileSelector(node.prelude)
: { selector: null, error: "Missing selector." };
if (!selectorResult.selector) {
const code = /too many|too long/i.test(selectorResult.error ?? "") ? "RESOURCE_LIMIT" : "INVALID_SELECTOR";
const code = selectorResult.resourceLimit ? "RESOURCE_LIMIT" : "INVALID_SELECTOR";
diagnostic(diagnostics, code, selectorResult.error ?? "Invalid selector.", node.prelude ?? node);
return;
}
@@ -594,9 +594,11 @@ export function compileProgram(stylesheet: ParsedStylesheet, languageVersion: nu
continue;
}
for (const [expandedProperty, expandedValue] of expanded) {
const diagnosticCount = diagnostics.length;
validateValue(expandedProperty, expandedValue, declaration, diagnostics);
const syntaxError = property.startsWith("--") ? null : valueSyntaxError(expandedProperty, expandedValue);
if (syntaxError) diagnostic(diagnostics, "INVALID_VALUE", syntaxError, declaration);
if (diagnostics.length > diagnosticCount && diagnostics.at(-1)?.severity === "error") continue;
declarations.push({
property: expandedProperty,
value: expandedValue,
@@ -651,7 +653,7 @@ export function compileProgram(stylesheet: ParsedStylesheet, languageVersion: nu
const ast = stylesheet.ast as AstNode | null;
if (ast) visit(children(ast), [], 0);
const program = { languageVersion, rules } satisfies StyleProgram;
return diagnostics.some(({ severity }) => severity === "error")
return diagnostics.some(isFatalStylesheetDiagnostic)
? { program: null, diagnostics }
: { program: structuredClone(program), diagnostics };
}
+14 -2
View File
@@ -36,6 +36,18 @@ describe("compileStylesheet", () => {
);
});
it("rejects a duplicate version directive nested in an at-rule block", () => {
const result = compileStylesheet({
languageVersion: 1,
text: "@version 1; @media (width: 600pt) { @version 1; name { color: red; } }",
});
expect(result.program).toBeNull();
expect(result.diagnostics).toContainEqual(
expect.objectContaining({ code: "DUPLICATE_VERSION_DIRECTIVE", severity: "error" }),
);
});
it("rejects malformed directives", () => {
const result = compileStylesheet({ languageVersion: 1, text: "@version one;" });
@@ -59,10 +71,10 @@ describe("compileStylesheet", () => {
);
});
it("does not compile a recovered CSS error", () => {
it("compiles around a recovered CSS error", () => {
const result = compileStylesheet({ languageVersion: 1, text: "@version 1;\nsection { color red; }" });
expect(result.program).toBeNull();
expect(result.program).not.toBeNull();
expect(result.diagnostics).toContainEqual(expect.objectContaining({ code: "CSS_PARSE_ERROR", severity: "error" }));
});
});