mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-08-23 23:02:17 +10:00
refactor(stylesheet): move Semantic CSS to the browser (#3329)
This commit is contained in:
@@ -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" }));
|
||||
});
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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" }));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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" }));
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user