feat: add semantic CSS stylesheets (#3274)

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
This commit is contained in:
Amruth Pillai
2026-07-30 12:39:15 +02:00
committed by GitHub
co-authored by Cursor Agent
parent 4ac19f81b3
commit d2ffbf9618
320 changed files with 78393 additions and 2915 deletions
+10 -1
View File
@@ -7,7 +7,11 @@
"./export-sections": "./src/export-sections.ts",
"./icons": "./src/icons.ts",
"./markdown": "./src/markdown.ts",
"./patch": "./src/patch.ts"
"./patch": "./src/patch.ts",
"./stylesheet": "./src/stylesheet/index.ts",
"./stylesheet/registry": "./src/stylesheet/registry/index.ts",
"./stylesheet/types": "./src/stylesheet/semantic-types.ts",
"./stylesheet/render-data": "./src/stylesheet/render-data.ts"
},
"scripts": {
"typecheck": "tsgo --noEmit",
@@ -17,13 +21,18 @@
"test:agent": "vitest run --reporter=agent --reporter=json --outputFile.json=reports/vitest-results.json --passWithNoTests"
},
"dependencies": {
"@bramus/specificity": "^2.4.2",
"@reactive-resume/schema": "workspace:*",
"canonicalize": "^3.0.0",
"css-tree": "^3.2.1",
"fast-json-patch": "^3.1.1",
"zod": "^4.4.3"
},
"devDependencies": {
"@reactive-resume/config": "workspace:*",
"@typescript/native-preview": "7.0.0-dev.20260707.2",
"fast-check": "^4.9.0",
"tsx": "^4.23.1",
"typescript": "^7.0.2"
}
}
+39
View File
@@ -1,3 +1,4 @@
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import { describe, expect, it } from "vitest";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { applyResumePatches, jsonPatchOperationSchema, ResumePatchError } from "./patch";
@@ -69,6 +70,44 @@ describe("applyResumePatches", () => {
expect(JSON.stringify(defaultResumeData)).toBe(before);
});
it("normalizes an unrelated patch without losing compatible custom-section overlap", () => {
const data = {
...structuredClone(defaultResumeData),
customSections: [
{
id: "custom-experience",
type: "experience",
title: "Experience",
icon: "",
columns: 1,
hidden: false,
keepTogether: false,
startOnNewPage: false,
items: [
{
id: "experience-item",
hidden: false,
company: "Analytical Engines",
position: "Programmer",
location: "London",
period: "18421843",
description: "<p>Wrote the first algorithm.</p>",
content: "<p>Compatible overlap</p>",
},
],
},
],
} as unknown as ResumeData;
const result = applyResumePatches(data, [{ op: "replace", path: "/basics/name", value: "Ada" }]);
expect(result.customSections[0]?.items[0]).toMatchObject({
content: "<p>Compatible overlap</p>",
roles: [],
website: { url: "", label: "", inlineLink: false },
});
});
it("applies multiple ops in sequence", () => {
const result = applyResumePatches(defaultResumeData, [
{ op: "replace", path: "/basics/name", value: "Alice" },
+8 -6
View File
@@ -2,7 +2,7 @@ import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { JsonPatchError, Operation } from "fast-json-patch";
import jsonpatch from "fast-json-patch";
import z from "zod";
import { resumeDataSchema } from "@reactive-resume/schema/resume/data";
import { parseResumeData } from "@reactive-resume/schema/resume/data";
/**
* A Zod schema that models JSON Patch (RFC 6902) operations as a discriminated union on `op`.
@@ -117,9 +117,11 @@ export function applyResumePatches(data: ResumeData, operations: Operation[]): R
throw error;
}
// Validate the result still conforms to ResumeData.
const parsed = resumeDataSchema.safeParse(patched);
if (!parsed.success) throw new Error(`Patch produced invalid resume data: ${parsed.error.message}`);
return parsed.data;
try {
return parseResumeData(patched);
} catch (error) {
throw new Error(`Patch produced invalid resume data: ${error instanceof Error ? error.message : String(error)}`, {
cause: error,
});
}
}
@@ -0,0 +1,11 @@
/* biome-ignore lint/suspicious/noUnknownAtRules: Semantic CSS uses a version directive. */
@version 1;
:root {
--accent: var(--resume-primary-color);
}
section-heading {
color: var(--accent);
break-inside: avoid;
}
@@ -0,0 +1,13 @@
/* biome-ignore lint/suspicious/noUnknownAtRules: Semantic CSS version directive */
@version 1;
section[type="experience"] > section-heading {
color: red;
}
/* biome-ignore lint/correctness/noUnknownTypeSelector: Semantic CSS semantic element */
region[placement="sidebar"] section,
/* biome-ignore lint/correctness/noUnknownTypeSelector: Semantic CSS semantic element */
item[role~="nested-role"]:nth-child(2) {
color: blue;
}
@@ -0,0 +1,118 @@
import type { SemanticNode } from "./types";
import { describe, expect, it } from "vitest";
import { analyzeStylesheet } from "./analyze";
import { compileStylesheet } from "./compile";
import { SEMANTIC_CSS_LIMITS_V1 } from "./limits";
const tree: SemanticNode = {
key: "resume",
kind: "resume",
attributes: { template: "onyx" },
roles: [],
children: [
{
key: "name",
kind: "name",
attributes: {},
roles: ["primary-text"],
children: [],
},
{
key: "picture",
kind: "picture",
attributes: {},
roles: ["picture"],
children: [],
},
],
};
function compile(source: string) {
const result = compileStylesheet({ languageVersion: 1, text: source });
if (!result.program) throw new Error(result.diagnostics.map(({ code }) => code).join(","));
return result.program;
}
function semanticTreeOfSize(size: number, shape: "deep" | "wide"): SemanticNode {
if (shape === "wide") {
return {
key: "root",
kind: "resume",
attributes: {},
roles: [],
children: Array.from({ length: size - 1 }, (_, index) => ({
key: `item-${index}`,
kind: "item",
attributes: {},
roles: [],
children: [],
})),
};
}
let root: SemanticNode = { key: "node-0", kind: "item", attributes: {}, roles: [], children: [] };
for (let index = 1; index < size; index++) {
root = { key: `node-${index}`, kind: "item", attributes: {}, roles: [], children: [root] };
}
return root;
}
function oversizedFrontierTree(): { tree: SemanticNode; childReads: () => number } {
let childReads = 0;
const children = new Proxy({} as readonly SemanticNode[], {
get: (_target, property) => {
if (property === "length") return SEMANTIC_CSS_LIMITS_V1.maxSemanticNodes + 1;
if (property === Symbol.iterator || (typeof property === "string" && /^\d+$/.test(property))) {
childReads++;
throw new Error("Oversized frontier entries must not be read.");
}
},
});
return {
tree: { key: "oversized-root", kind: "resume", attributes: {}, roles: [], children },
childReads: () => childReads,
};
}
describe("Semantic CSS semantic analysis", () => {
it("warns about selectors that match no immutable semantic node", () => {
const program = compile('@version 1; section[type="education"] { color: red; }');
const diagnostics = analyzeStylesheet(program, tree);
expect(diagnostics).toContainEqual(expect.objectContaining({ code: "SELECTOR_NO_MATCH", severity: "warning" }));
});
it("warns when a known property cannot apply to any matched node kind", () => {
const program = compile("@version 1; picture { color: red; }");
const diagnostics = analyzeStylesheet(program, tree);
expect(diagnostics).toContainEqual(
expect.objectContaining({ code: "PROPERTY_NOT_APPLICABLE", severity: "warning" }),
);
});
it("does not warn for a selector and declaration with a real target", () => {
const program = compile("@version 1; name { color: red; }");
expect(analyzeStylesheet(program, tree)).toEqual([]);
});
it("accepts the exact analysis node budget and rejects deep or wide trees one node over", () => {
const program = { languageVersion: 1, rules: [] };
expect(analyzeStylesheet(program, semanticTreeOfSize(SEMANTIC_CSS_LIMITS_V1.maxSemanticNodes, "wide"))).toEqual([]);
for (const shape of ["deep", "wide"] as const) {
expect(
analyzeStylesheet(program, semanticTreeOfSize(SEMANTIC_CSS_LIMITS_V1.maxSemanticNodes + 1, shape)),
).toContainEqual(expect.objectContaining({ code: "RESOURCE_LIMIT", severity: "error" }));
}
});
it("rejects an oversized root frontier without reading or queueing child entries", () => {
const frontier = oversizedFrontierTree();
const diagnostics = analyzeStylesheet({ languageVersion: 1, rules: [] }, frontier.tree);
expect(diagnostics).toContainEqual(expect.objectContaining({ code: "RESOURCE_LIMIT", severity: "error" }));
expect(frontier.childReads()).toBe(0);
});
});
+56
View File
@@ -0,0 +1,56 @@
import type { SemanticCssDiagnostic, SemanticNode, StyleProgram } from "./types";
import { createDiagnostic } from "./diagnostics";
import { SEMANTIC_CSS_LIMITS_V1 } from "./limits";
import { PROPERTY_REGISTRY_V1 } from "./registry/properties";
import { createSelectorMatcher } from "./selector";
function flatten(root: SemanticNode): SemanticNode[] | null {
const nodes: SemanticNode[] = [];
const stack = [root];
while (stack.length > 0) {
const node = stack.pop();
if (!node) break;
if (nodes.length >= SEMANTIC_CSS_LIMITS_V1.maxSemanticNodes) return null;
nodes.push(node);
const childCount = node.children.length;
if (childCount > SEMANTIC_CSS_LIMITS_V1.maxSemanticNodes - nodes.length - stack.length) return null;
for (let index = 0; index < childCount; index++) {
const child = node.children[index];
if (child) stack.push(child);
}
}
return nodes;
}
export function analyzeStylesheet(program: StyleProgram, tree: SemanticNode): readonly SemanticCssDiagnostic[] {
const diagnostics: SemanticCssDiagnostic[] = [];
const nodes = flatten(tree);
if (!nodes) {
return [createDiagnostic("RESOURCE_LIMIT", "error", "The semantic tree exceeds the Semantic CSS node limit.")];
}
const matchesSelector = createSelectorMatcher(tree);
for (const rule of program.rules) {
const matches = nodes.filter((node) => matchesSelector(rule.selector, node.key));
if (matches.length === 0) {
diagnostics.push(
createDiagnostic("SELECTOR_NO_MATCH", "warning", "This selector matches no semantic resume node.", rule.range),
);
continue;
}
for (const declaration of rule.declarations) {
if (declaration.property.startsWith("--")) continue;
const definition = PROPERTY_REGISTRY_V1[declaration.property];
if (definition && !matches.some((node) => definition.appliesTo.includes(node.kind))) {
diagnostics.push(
createDiagnostic(
"PROPERTY_NOT_APPLICABLE",
"warning",
`${declaration.property} cannot apply to the matched semantic node kinds.`,
declaration.range,
),
);
}
}
}
return diagnostics;
}
+55
View File
@@ -0,0 +1,55 @@
import type { CompileStylesheetResult } from "./types";
const MAX_ENTRIES = 128;
const MAX_SERIALIZED_BYTES = 16 * 1024 * 1024;
type CacheEntry = {
value: CompileStylesheetResult;
size: number;
};
function serializedSize(value: CompileStylesheetResult): number {
return new TextEncoder().encode(JSON.stringify(value)).byteLength;
}
export class StylesheetCompilationCache {
readonly #entries = new Map<string, CacheEntry>();
#size = 0;
get(key: string): CompileStylesheetResult | undefined {
const entry = this.#entries.get(key);
if (!entry) return;
this.#entries.delete(key);
this.#entries.set(key, entry);
return entry.value;
}
set(key: string, value: CompileStylesheetResult): void {
const previous = this.#entries.get(key);
if (previous) {
this.#size -= previous.size;
this.#entries.delete(key);
}
const entry = { value, size: serializedSize(value) };
if (entry.size > MAX_SERIALIZED_BYTES) return;
this.#entries.set(key, entry);
this.#size += entry.size;
while (this.#entries.size > MAX_ENTRIES || this.#size > MAX_SERIALIZED_BYTES) {
const oldestKey = this.#entries.keys().next().value;
if (typeof oldestKey !== "string") break;
const oldest = this.#entries.get(oldestKey);
this.#entries.delete(oldestKey);
this.#size -= oldest?.size ?? 0;
}
}
}
export const stylesheetCompilationCache = new StylesheetCompilationCache();
const SEMANTIC_CSS_COMPILER_BUILD_ID = "semantic-css-v1-values-2";
export function stylesheetCacheKey(languageVersion: number, source: string, registry: string): string {
return JSON.stringify([languageVersion, source, SEMANTIC_CSS_COMPILER_BUILD_ID, registry]);
}
@@ -0,0 +1,596 @@
import type { BaseSettingsSnapshot, ResolvedNodeStyle, ResolveStylesheetContext, SemanticNode } from "./types";
import { describe, expect, it } from "vitest";
import fc from "fast-check";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { resolveStylesheet } from "./cascade";
import { compileStylesheet } from "./compile";
import { SEMANTIC_CSS_LIMITS_V1 } from "./limits";
import { PROPERTY_REGISTRY_V1, SEMANTIC_NODE_KINDS } from "./registry";
const node = (
key: string,
kind: SemanticNode["kind"],
options: Partial<Omit<SemanticNode, "key" | "kind">> = {},
): SemanticNode =>
Object.freeze({
key,
kind,
attributes: Object.freeze(options.attributes ?? {}),
roles: Object.freeze(options.roles ?? []),
children: Object.freeze(options.children ?? []),
...(options.id ? { id: options.id } : {}),
});
const items = node("items-experience", "section-items", {
children: [node("item-1", "item"), node("item-2", "item"), node("item-3", "item")],
});
const tree = node("resume", "resume", {
attributes: { template: "onyx" },
children: [
node("page-1", "page", {
attributes: { "page-number": "1" },
children: [
node("region-main", "region", {
attributes: { placement: "main", region: "body" },
children: [
node("section-experience", "section", {
id: "experience",
attributes: { type: "experience", placement: "main", origin: "native" },
children: [node("heading-experience", "section-heading", { roles: ["section-title"] }), items],
}),
],
}),
],
}),
],
});
const baseSettings: BaseSettingsSnapshot = {
picture: defaultResumeData.picture,
template: defaultResumeData.metadata.template,
design: defaultResumeData.metadata.design,
typography: defaultResumeData.metadata.typography,
page: defaultResumeData.metadata.page,
layout: { sidebarWidth: defaultResumeData.metadata.layout.sidebarWidth },
};
const blankStyle: ResolvedNodeStyle = { style: {}, structural: {}, hidden: false, order: 0 };
const context: ResolveStylesheetContext = {
baseStyles: {
resume: blankStyle,
"heading-experience": { ...blankStyle, style: { color: "black" } },
},
baseSettings,
pages: [{ pageKey: "page-1", width: 595.28, height: 841.89 }],
};
const registryTree = node("resume", "resume", {
children: SEMANTIC_NODE_KINDS.filter((kind) => kind !== "resume").map((kind) => node(kind, kind)),
});
const registryContext: ResolveStylesheetContext = {
...context,
baseStyles: {},
pages: [{ pageKey: "page", width: 595.28, height: 841.89 }],
};
const advertisedPropertyHints = Object.entries(PROPERTY_REGISTRY_V1).flatMap(([property, definition]) => {
if (!definition) return [];
const kind = definition.appliesTo[0];
if (!kind) return [];
return [
...definition.values.map((value) => ({ property, kind, hint: `keyword ${value}`, value })),
...definition.units.map((unit) => ({ property, kind, hint: `unit ${unit}`, value: `1${unit}` })),
];
});
function resolve(source: string, customContext: ResolveStylesheetContext = context) {
const compiled = compileStylesheet({ languageVersion: 1, text: `@version 1;${source}` });
if (!compiled.program) throw new Error(compiled.diagnostics.map(({ code }) => code).join(","));
return resolveStylesheet(compiled.program, tree, customContext);
}
function resolveTree(source: string, semanticTree: SemanticNode, customContext: ResolveStylesheetContext) {
const compiled = compileStylesheet({ languageVersion: 1, text: `@version 1;${source}` });
if (!compiled.program) throw new Error(compiled.diagnostics.map(({ code }) => code).join(","));
return resolveStylesheet(compiled.program, semanticTree, customContext);
}
function find(nodeToSearch: SemanticNode, key: string): SemanticNode | undefined {
if (nodeToSearch.key === key) return nodeToSearch;
for (const child of nodeToSearch.children) {
const match = find(child, key);
if (match) return match;
}
}
function semanticTreeOfSize(size: number, shape: "deep" | "wide"): SemanticNode {
if (shape === "wide") {
return node("root", "resume", {
children: Array.from({ length: size - 1 }, (_, index) => node(`item-${index}`, "item")),
});
}
let root = node("node-0", "item");
for (let index = 1; index < size; index++) root = node(`node-${index}`, "item", { children: [root] });
return root;
}
function oversizedFrontierTree(): { tree: SemanticNode; childReads: () => number } {
let childReads = 0;
const children = new Proxy({} as readonly SemanticNode[], {
get: (_target, property) => {
if (property === "length") return SEMANTIC_CSS_LIMITS_V1.maxSemanticNodes + 1;
if (property === Symbol.iterator || (typeof property === "string" && /^\d+$/.test(property))) {
childReads++;
throw new Error("Oversized frontier entries must not be read.");
}
},
});
return {
tree: node("oversized-root", "resume", { children }),
childReads: () => childReads,
};
}
describe("Semantic CSS cascade and structural resolution", () => {
it.each(advertisedPropertyHints)(
"accepts the advertised $hint for $property through cascade resolution",
({ property, kind, value }) => {
const compiled = compileStylesheet({
languageVersion: 1,
text: `@version 1; ${kind} { ${property}: ${value}; }`,
});
expect(
compiled.diagnostics.filter(({ severity }) => severity === "error"),
`${property}: ${value} failed compilation`,
).toEqual([]);
expect(compiled.program, `${property}: ${value} did not compile`).not.toBeNull();
if (!compiled.program) return;
const resolved = resolveStylesheet(compiled.program, registryTree, registryContext);
expect(
resolved.diagnostics.filter(({ severity }) => severity === "error"),
`${property}: ${value} failed cascade resolution`,
).toEqual([]);
},
);
it("resolves two-number flex as grow and shrink with an implicit basis", () => {
const result = resolve("item { flex: 2 3; }");
expect(result.nodes["item-1"]?.style).toMatchObject({
"flex-grow": 2,
"flex-shrink": 3,
"flex-basis": "0%",
});
});
it("resolves base, normal and important rules by specificity then source order", () => {
const result = resolve(`
section-heading { color: red; }
section[type="experience"] > section-heading { color: blue !important; }
section#experience > section-heading { color: green; }
`);
expect(result.nodes["heading-experience"]?.style.color).toBe("blue");
});
it("cascades canonical and alias identities before choosing one winner", () => {
const contactTree = node("resume", "resume", {
children: [
node("page-1", "page", {
attributes: { "page-number": "1" },
children: [
node("contact-list", "contact-list", {
children: [
node("contact-email", "contact-item", {
attributes: { name: "email" },
roles: ["structured-link"],
children: [node("contact-email-link", "link", { roles: ["structured-link"] })],
}),
],
}),
],
}),
],
});
const aliasContext = {
...context,
baseStyles: {},
aliases: { "contact-email": ["contact-email-link"] },
} satisfies ResolveStylesheetContext;
const laterAlias = resolveTree("contact-item { color: red; } link { color: blue; }", contactTree, aliasContext);
const laterCanonical = resolveTree("link { color: blue; } contact-item { color: red; }", contactTree, aliasContext);
const specificCanonical = resolveTree(
"contact-item[name='email'] { color: red; } link { color: blue; }",
contactTree,
aliasContext,
);
const importantAlias = resolveTree(
"contact-item[name='email'] { color: red; } link { color: blue !important; }",
contactTree,
aliasContext,
);
expect(laterAlias.nodes["contact-email"]?.style.color).toBe("blue");
expect(laterCanonical.nodes["contact-email"]?.style.color).toBe("red");
expect(specificCanonical.nodes["contact-email"]?.style.color).toBe("red");
expect(importantAlias.nodes["contact-email"]?.style.color).toBe("blue");
});
it("applies alias display and order state to the canonical render owner", () => {
const contactTree = node("resume", "resume", {
children: [
node("page-1", "page", {
attributes: { "page-number": "1" },
children: [
node("contact-list", "contact-list", {
children: [
node("contact-phone", "contact-item", {
attributes: { name: "phone" },
roles: ["structured-link"],
children: [node("contact-phone-link", "link", { roles: ["structured-link"] })],
}),
node("contact-email", "contact-item", {
attributes: { name: "email" },
roles: ["structured-link"],
children: [node("contact-email-link", "link", { roles: ["structured-link"] })],
}),
],
}),
],
}),
],
});
const aliasContext = {
...context,
baseStyles: {},
aliases: {
"contact-email": ["contact-email-link"],
"contact-phone": ["contact-phone-link"],
},
} satisfies ResolveStylesheetContext;
const ordered = resolveTree("contact-item[name='email'] > link { order: -1; }", contactTree, aliasContext);
const hidden = resolveTree("contact-item[name='email'] > link { display: none; }", contactTree, aliasContext);
expect(find(ordered.renderTree, "contact-list")?.children.map(({ key }) => key)).toEqual([
"contact-email",
"contact-phone",
]);
expect(find(hidden.renderTree, "contact-list")?.children.map(({ key }) => key)).toEqual(["contact-phone"]);
expect(hidden.nodes["contact-email"]?.hidden).toBe(true);
});
it("resolves inherited custom properties, fallbacks, cycles, and CSS-wide keywords", () => {
const valid = resolve(`
:root { --accent: var(--resume-primary-color); color: red; }
section { color: inherit; }
section-heading { color: var(--missing, var(--accent)); }
`);
expect(valid.nodes["heading-experience"]?.style.color).toBe(baseSettings.design.colors.primary);
const reverted = resolve("section { color: red; } section-heading { color: revert; }");
expect(reverted.nodes["heading-experience"]?.style.color).toBe("black");
const compiled = compileStylesheet({
languageVersion: 1,
text: "@version 1; :root { --a: var(--b); --b: var(--a); } section { color: var(--a); }",
});
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.diagnostics).toContainEqual(expect.objectContaining({ code: "VARIABLE_CYCLE", severity: "error" }));
});
it("normalizes PDF lengths with the correct font-relative bases and expands spacing shorthands", () => {
const result = resolve(`
section-heading { font-size: 2em; margin: 96px 1em 1in 25.4mm; padding: 1rem 2cm; }
`);
expect(result.nodes["heading-experience"]?.style).toMatchObject({
"font-size": 20,
"margin-top": 72,
"margin-right": 20,
"margin-bottom": 72,
"margin-left": 72,
"padding-top": 10,
"padding-right": 56.69291338582677,
"padding-bottom": 10,
"padding-left": 56.69291338582677,
});
});
it("resolves variables before expanding multi-token shorthands", () => {
const result = resolve(`
:root {
--space: 1pt 2pt 3pt 4pt;
--edge: 2pt solid red;
--gaps: 5pt 6pt;
--flex: 2 3 25%;
}
section {
margin: var(--space);
padding: var(--space);
border: var(--edge);
gap: var(--gaps);
flex: var(--flex);
}
`);
expect(result.nodes["section-experience"]?.style).toMatchObject({
"margin-top": 1,
"margin-right": 2,
"margin-bottom": 3,
"margin-left": 4,
"padding-top": 1,
"padding-right": 2,
"padding-bottom": 3,
"padding-left": 4,
"border-top-width": 2,
"border-top-style": "solid",
"border-top-color": "red",
"row-gap": 5,
"column-gap": 6,
"flex-grow": 2,
"flex-shrink": 3,
"flex-basis": "25%",
});
});
it("does not evaluate a losing variable-backed shorthand", () => {
const result = resolve(`
:root { --invalid-space: 1pt 2pt 3pt 4pt 5pt; }
section { margin: var(--invalid-space); margin: 6pt !important; }
`);
expect(result.nodes["section-experience"]?.style).toMatchObject({
"margin-top": 6,
"margin-right": 6,
"margin-bottom": 6,
"margin-left": 6,
});
expect(result.diagnostics).not.toContainEqual(
expect.objectContaining({ code: "INVALID_VALUE", 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); }");
expect(result.nodes["heading-experience"]?.style["font-size"]).toBe(3);
expect(result.diagnostics).toContainEqual(expect.objectContaining({ code: "EXTREME_VALUE", severity: "warning" }));
});
it("resolves authored size before media and rejects size inside media", () => {
const result = resolve(
`
page { size: 400pt 600pt; padding-top: var(--resume-page-width); }
@media (min-width: 390pt) and (orientation: portrait) { page { margin-top: 10pt; } }
`,
{ ...context, pages: [{ pageKey: "page-1", width: 800, height: 400 }] },
);
expect(result.nodes["page-1"]?.structural.pageSize).toEqual({ width: 400, height: 600 });
expect(result.nodes["page-1"]?.style["margin-top"]).toBe(10);
expect(result.nodes["page-1"]?.style["padding-top"]).toBe(400);
const invalid = compileStylesheet({
languageVersion: 1,
text: "@version 1; @media (width: 400pt) { page { size: A4; } }",
});
expect(invalid.program).toBeNull();
expect(invalid.diagnostics).toContainEqual(expect.objectContaining({ code: "MEDIA_PAGE_SIZE", severity: "error" }));
});
it("resolves a relative authored page size exactly once against authored dimensions", () => {
const result = resolve(
`
:root { --page-size: 50vw 50vh; }
page { size: var(--page-size); }
@media (width: 400pt) { :root { --page-size: var(--missing); } }
`,
{
...context,
pages: [{ pageKey: "page-1", width: 800, height: 600 }],
},
);
expect(result.nodes["page-1"]?.structural.pageSize).toEqual({ width: 400, height: 300 });
expect(result.diagnostics).not.toContainEqual(
expect.objectContaining({ code: "UNRESOLVED_VARIABLE", severity: "error" }),
);
});
it("bounds generated branching variable expansion by aggregate work and output", () => {
fc.assert(
fc.property(fc.integer({ min: 3, max: 4 }), (branches) => {
const depth = Math.ceil(Math.log(SEMANTIC_CSS_LIMITS_V1.maxSourceBytes) / Math.log(branches)) + 1;
const variables = Array.from({ length: depth }, (_, index) => {
const next =
index === depth - 1 ? "r" : Array.from({ length: branches }, () => `var(--v${index + 1})`).join("");
return `--v${index}:${next};`;
}).join("");
const compiled = compileStylesheet({
languageVersion: 1,
text: `@version 1;:root{${variables}}section-heading{color:var(--v0);}`,
});
if (!compiled.program) throw new Error(compiled.diagnostics.map(({ code }) => code).join(","));
const result = resolveStylesheet(compiled.program, tree, context);
expect(result.nodes).toEqual({});
expect(result.diagnostics).toContainEqual(
expect.objectContaining({ code: "RESOURCE_LIMIT", severity: "error" }),
);
}),
{ numRuns: 8 },
);
});
it.each([
{ keyword: "InItIaL", color: undefined, hidden: false, order: 0, fixed: undefined, breakBefore: undefined },
{ keyword: "uNsEt", color: "purple", hidden: false, order: 0, fixed: undefined, breakBefore: undefined },
{ keyword: "ReVeRt", color: "navy", hidden: true, order: 7, fixed: true, breakBefore: "page" },
{ keyword: "InHeRiT", color: "purple", hidden: true, order: 3, fixed: true, breakBefore: "page" },
] as const)(
"applies case-insensitive $keyword semantics to style, hidden, order, and structural properties",
({ keyword, color, hidden, order, fixed, breakBefore }) => {
const customContext: ResolveStylesheetContext = {
...context,
baseStyles: {
...context.baseStyles,
resume: {
style: {},
structural: { fixed: true, breakBefore: "page" },
hidden: true,
order: 3,
},
"region-main": {
style: { color: "purple" },
structural: { fixed: true, breakBefore: "page" },
hidden: true,
order: 3,
},
"section-experience": {
style: { color: "navy" },
structural: { fixed: true, breakBefore: "page" },
hidden: true,
order: 7,
},
},
};
const result = resolve(
`section { color: ${keyword}; display: ${keyword}; order: ${keyword}; -resume-fixed: ${keyword}; break-before: ${keyword}; }`,
customContext,
);
expect(result.nodes["section-experience"]).toMatchObject({ hidden, order });
expect(result.nodes["section-experience"]?.style.color).toBe(color);
expect(result.nodes["section-experience"]?.structural.fixed).toBe(fixed);
expect(result.nodes["section-experience"]?.structural.breakBefore).toBe(breakBefore);
},
);
it("makes size revert expose the builder page size", () => {
const result = resolve("page { size: ReVeRt; }", {
...context,
pages: [{ pageKey: "page-1", width: 800, height: 600 }],
});
expect(result.nodes["page-1"]?.structural.pageSize).toBe("A4");
});
it.each([
{ keyword: "initial", expected: undefined },
{ keyword: "unset", expected: undefined },
{ keyword: "inherit", expected: "LETTER" },
{ keyword: "revert", expected: { width: 700, height: 900 } },
] as const)("applies $keyword to page size structure", ({ keyword, expected }) => {
const result = resolve(`page { size: ${keyword}; }`, {
...context,
baseStyles: {
...context.baseStyles,
resume: { ...blankStyle, structural: { pageSize: "LETTER" } },
"page-1": { ...blankStyle, structural: { pageSize: { width: 700, height: 900 } } },
},
pages: [{ pageKey: "page-1", width: 800, height: 600 }],
});
expect(result.nodes["page-1"]?.structural.pageSize).toEqual(expected);
});
it("normalizes supported line-height lengths and enforces font-size and opacity bounds", () => {
const valid = resolve("section-heading { line-height: 12pt; font-size: 0; opacity: 0; }");
expect(valid.nodes["heading-experience"]?.style).toMatchObject({
"line-height": 12,
"font-size": 0,
opacity: 0,
});
const upper = resolve("section-heading { opacity: 1; }");
expect(upper.nodes["heading-experience"]?.style.opacity).toBe(1);
for (const declaration of ["font-size: -0.01pt", "opacity: -0.0001", "opacity: 1.0001"]) {
const compiled = compileStylesheet({
languageVersion: 1,
text: `@version 1;section-heading{${declaration}}`,
});
expect(compiled.program, declaration).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.diagnostics).toContainEqual(expect.objectContaining({ code: "INVALID_VALUE", severity: "error" }));
});
it("matches positional selectors before applying display and stable order exactly once", () => {
const result = resolve(`
item:nth-child(2) { display: none; }
item:last-child { order: -1; }
`);
const renderedItems = find(result.renderTree, "items-experience");
expect(renderedItems?.children.map(({ key }) => key)).toEqual(["item-3", "item-1"]);
expect(tree.children[0]?.children[0]?.children[0]?.children[1]?.children.map(({ key }) => key)).toEqual([
"item-1",
"item-2",
"item-3",
]);
});
it("maps structural declarations without mixing them into renderer styles", () => {
const result = resolve(`
section { break-before: page; break-inside: avoid; -resume-fixed: true; -resume-min-presence-ahead: 12pt; }
section-heading { orphans: 2; widows: 3; order: 4; }
`);
expect(result.nodes["section-experience"]).toMatchObject({
structural: { breakBefore: "page", breakInside: "avoid", fixed: true, minPresenceAhead: 12 },
});
expect(result.nodes["heading-experience"]).toMatchObject({
structural: { orphans: 2, widows: 3 },
order: 4,
});
expect(result.nodes["section-experience"]?.style["break-before"]).toBeUndefined();
});
it.each([
["0", false],
["1", true],
] as const)("maps the accepted -resume-fixed value %s to %s", (value, expected) => {
const result = resolve(`section { -resume-fixed: ${value}; }`);
expect(result.nodes["section-experience"]?.structural.fixed).toBe(expected);
expect(result.diagnostics.filter(({ severity }) => severity === "error")).toEqual([]);
});
it("accepts the exact semantic node budget and rejects deep or wide trees one node over", () => {
const program = { languageVersion: 1, rules: [] };
const exact = resolveStylesheet(
program,
semanticTreeOfSize(SEMANTIC_CSS_LIMITS_V1.maxSemanticNodes, "wide"),
context,
);
expect(Object.keys(exact.nodes)).toHaveLength(SEMANTIC_CSS_LIMITS_V1.maxSemanticNodes);
for (const shape of ["deep", "wide"] as const) {
const result = resolveStylesheet(
program,
semanticTreeOfSize(SEMANTIC_CSS_LIMITS_V1.maxSemanticNodes + 1, shape),
context,
);
expect(result.nodes).toEqual({});
expect(result.diagnostics).toContainEqual(expect.objectContaining({ code: "RESOURCE_LIMIT", severity: "error" }));
}
});
it("rejects an oversized root frontier without reading or queueing child entries", () => {
const frontier = oversizedFrontierTree();
const result = resolveStylesheet({ languageVersion: 1, rules: [] }, frontier.tree, context);
expect(result.nodes).toEqual({});
expect(result.diagnostics).toContainEqual(expect.objectContaining({ code: "RESOURCE_LIMIT", severity: "error" }));
expect(frontier.childReads()).toBe(0);
});
});
+934
View File
@@ -0,0 +1,934 @@
import type { Specificity } from "./selector";
import type {
CompiledDeclaration,
CompiledMediaQuery,
CompiledStyleRule,
ResolvedNodeStyle,
ResolvedPageDimensions,
ResolvedPageSize,
ResolveStylesheetContext,
ResolveStylesheetResult,
SemanticCssDiagnostic,
SemanticNode,
SourceRange,
StructuralPresentation,
StyleProgram,
} from "./types";
import { createDiagnostic } from "./diagnostics";
import { SEMANTIC_CSS_LIMITS_V1 } from "./limits";
import { PROPERTY_REGISTRY_V1 } from "./registry/properties";
import { createSystemVariables } from "./registry/system-variables";
import { createSelectorMatcher } from "./selector";
import {
cssFunctionDepth,
decodeCssEscapes,
expandShorthand,
SEMANTIC_CSS_LENGTH_PROPERTIES,
valueSyntaxError,
} from "./values";
type FlatNode = {
node: SemanticNode;
parent: FlatNode | null;
pageKey: string | null;
};
type MatchedRule = {
rule: CompiledStyleRule;
specificity: Specificity;
};
type Winner = {
declaration: CompiledDeclaration;
specificity: Specificity;
};
type LengthContext = {
page: ResolvedPageDimensions;
parentFontSize: number;
fontSize: number;
rootFontSize: number;
};
type CssWideKeyword = "inherit" | "initial" | "revert" | "unset";
type VariableExpansionBudget = {
work: number;
};
const absoluteUnitToPt = {
pt: 1,
px: 72 / 96,
in: 72,
mm: 72 / 25.4,
cm: 72 / 2.54,
} as const;
const cssWideKeywords = new Set(["inherit", "initial", "revert", "unset"]);
const maxVariableExpansionOutputCodeUnits = SEMANTIC_CSS_LIMITS_V1.maxSourceBytes;
const maxVariableExpansionWorkCodeUnits = SEMANTIC_CSS_LIMITS_V1.maxSourceBytes * 4;
const structuralKeys: Readonly<Record<string, keyof StructuralPresentation>> = {
"break-before": "breakBefore",
"break-inside": "breakInside",
"-resume-fixed": "fixed",
"-resume-min-presence-ahead": "minPresenceAhead",
orphans: "orphans",
widows: "widows",
size: "pageSize",
};
function cssWideKeyword(value: string): CssWideKeyword | null {
const keyword = value.toLowerCase();
return cssWideKeywords.has(keyword) ? (keyword as CssWideKeyword) : null;
}
function compareSpecificity(left: Specificity, right: Specificity): number {
return left[0] - right[0] || left[1] - right[1] || left[2] - right[2];
}
function candidateWins(candidate: Winner, current: Winner | undefined): boolean {
if (!current) return true;
if (candidate.declaration.important !== current.declaration.important) return candidate.declaration.important;
return (
compareSpecificity(candidate.specificity, current.specificity) > 0 ||
(compareSpecificity(candidate.specificity, current.specificity) === 0 &&
candidate.declaration.sourceOrder > current.declaration.sourceOrder)
);
}
function flattenTree(root: SemanticNode): FlatNode[] | null {
const result: FlatNode[] = [];
const stack: { node: SemanticNode; parent: FlatNode | null; pageKey: string | null }[] = [
{ node: root, parent: null, pageKey: null },
];
while (stack.length > 0) {
const next = stack.pop();
if (!next) break;
if (result.length >= SEMANTIC_CSS_LIMITS_V1.maxSemanticNodes) return null;
const pageKey = next.node.kind === "page" ? next.node.key : next.pageKey;
const flat = { node: next.node, parent: next.parent, pageKey };
result.push(flat);
const childCount = next.node.children.length;
if (childCount > SEMANTIC_CSS_LIMITS_V1.maxSemanticNodes - result.length - stack.length) return null;
for (let index = childCount - 1; index >= 0; index--) {
const child = next.node.children[index];
if (child) stack.push({ node: child, parent: flat, pageKey });
}
}
return result;
}
function matchingSpecificity(
rule: CompiledStyleRule,
nodeKey: string,
matches: ReturnType<typeof createSelectorMatcher>,
): Specificity | null {
let best: Specificity | null = null;
for (const selector of rule.selector.selectors) {
if (!matches({ selectors: [selector] }, nodeKey)) continue;
if (!best || compareSpecificity(selector.specificity, best) > 0) best = selector.specificity;
}
return best;
}
function defaultPageDimensions(
format: ResolveStylesheetContext["baseSettings"]["page"]["format"],
): ResolvedPageDimensions {
if (format === "letter") return { width: 612, height: 792 };
return { width: 595.28, height: 841.89 };
}
function initialPages(
flatNodes: readonly FlatNode[],
context: ResolveStylesheetContext,
): Map<string, ResolvedPageDimensions> {
const authored = new Map(context.pages.map((page) => [page.pageKey, { width: page.width, height: page.height }]));
const fallback = defaultPageDimensions(context.baseSettings.page.format);
const pages = new Map<string, ResolvedPageDimensions>();
for (const { node } of flatNodes) {
if (node.kind === "page") pages.set(node.key, authored.get(node.key) ?? fallback);
}
return pages;
}
function pageFor(
node: FlatNode,
pages: ReadonlyMap<string, ResolvedPageDimensions>,
fallback: ResolvedPageDimensions,
): ResolvedPageDimensions {
return (node.pageKey ? pages.get(node.pageKey) : undefined) ?? pages.values().next().value ?? fallback;
}
function toPoints(value: string, property: string, context: LengthContext): number | string | null {
const match = value
.trim()
.match(/^([+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?)\s*(pt|px|in|mm|cm|%|vw|vh|em|rem)?$/i);
if (!match) return null;
const number = Number(match[1]);
if (!Number.isFinite(number)) return null;
const unit = match[2]?.toLowerCase() ?? "pt";
if (unit === "%") return property === "font-size" ? (number / 100) * context.parentFontSize : `${number}%`;
if (unit === "vw") return (number / 100) * context.page.width;
if (unit === "vh") return (number / 100) * context.page.height;
if (unit === "rem") return number * context.rootFontSize;
if (unit === "em") {
return number * (property === "font-size" ? context.parentFontSize : context.fontSize);
}
return number * absoluteUnitToPt[unit as keyof typeof absoluteUnitToPt];
}
function mediaMatches(query: CompiledMediaQuery, dimensions: ResolvedPageDimensions, rootFontSize: number): boolean {
const lengthContext = {
page: dimensions,
parentFontSize: rootFontSize,
fontSize: rootFontSize,
rootFontSize,
};
return query.features.every((feature) => {
if (feature.name === "orientation") {
return feature.value === (dimensions.width > dimensions.height ? "landscape" : "portrait");
}
const expected = toPoints(feature.value, feature.name, lengthContext);
if (typeof expected !== "number") return false;
const actual = dimensions[feature.name];
if (feature.comparison === "min") return actual >= expected;
if (feature.comparison === "max") return actual <= expected;
return actual === expected;
});
}
function ruleApplies(
rule: CompiledStyleRule,
dimensions: ResolvedPageDimensions,
rootFontSize: number,
includeMedia: boolean,
): boolean {
if (rule.media.length === 0) return true;
return includeMedia && rule.media.some((query) => mediaMatches(query, dimensions, rootFontSize));
}
function winnersFor(
matches: readonly MatchedRule[],
dimensions: ResolvedPageDimensions,
rootFontSize: number,
includeMedia: boolean,
): Map<string, Winner> {
const winners = new Map<string, Winner>();
for (const { rule, specificity } of matches) {
if (!ruleApplies(rule, dimensions, rootFontSize, includeMedia)) continue;
for (const declaration of rule.declarations) {
const candidate = { declaration, specificity };
if (candidateWins(candidate, winners.get(declaration.property))) {
winners.set(declaration.property, candidate);
}
}
}
return winners;
}
function splitVariable(value: string): [name: string, fallback: string | null] {
let depth = 0;
for (let index = 0; index < value.length; index++) {
if (value[index] === "(") depth++;
if (value[index] === ")") depth--;
if (value[index] === "," && depth === 0) {
return [value.slice(0, index).trim(), value.slice(index + 1).trim()];
}
}
return [value.trim(), null];
}
function matchingParenthesis(value: string, opening: number): number {
let depth = 0;
for (let index = opening; index < value.length; index++) {
if (value[index] === "(") depth++;
if (value[index] === ")" && --depth === 0) return index;
}
return -1;
}
function expandVariables(
source: string,
custom: ReadonlyMap<string, string>,
system: Readonly<Record<string, string>>,
diagnostics: SemanticCssDiagnostic[],
range: SourceRange,
stack: readonly string[] = [],
depth = 0,
budget: VariableExpansionBudget = { work: 0 },
): string | null {
if (depth > SEMANTIC_CSS_LIMITS_V1.maxVariableExpansionDepth) {
diagnostics.push(
createDiagnostic("RESOURCE_LIMIT", "error", "Variable expansion exceeds the Semantic CSS limit.", range),
);
return null;
}
if (
source.length > maxVariableExpansionOutputCodeUnits ||
source.length > maxVariableExpansionWorkCodeUnits - budget.work
) {
diagnostics.push(
createDiagnostic("RESOURCE_LIMIT", "error", "Variable expansion exceeds the Semantic CSS limit.", range),
);
return null;
}
budget.work += source.length;
let value = decodeCssEscapes(source);
if (value.length > maxVariableExpansionOutputCodeUnits) {
diagnostics.push(
createDiagnostic("RESOURCE_LIMIT", "error", "Variable expansion exceeds the Semantic CSS limit.", range),
);
return null;
}
while (true) {
const match = /var\s*\(/i.exec(value);
if (!match) break;
const opening = (match.index ?? 0) + match[0].lastIndexOf("(");
const closing = matchingParenthesis(value, opening);
if (closing < 0) {
diagnostics.push(createDiagnostic("INVALID_VALUE", "error", "Malformed var() expression.", range));
return null;
}
const [name, fallback] = splitVariable(value.slice(opening + 1, closing));
if (!name.startsWith("--")) {
diagnostics.push(createDiagnostic("INVALID_VALUE", "error", "var() requires a custom-property name.", range));
return null;
}
if (stack.includes(name)) {
diagnostics.push(createDiagnostic("VARIABLE_CYCLE", "error", `Variable cycle detected at ${name}.`, range));
return null;
}
const replacement = custom.get(name) ?? system[name] ?? fallback;
if (replacement === null || replacement === undefined) {
diagnostics.push(
createDiagnostic("UNRESOLVED_VARIABLE", "error", `No value or fallback exists for ${name}.`, range),
);
return null;
}
const expanded = expandVariables(
replacement,
custom,
system,
diagnostics,
range,
[...stack, name],
depth + 1,
budget,
);
if (expanded === null) return null;
const nextLength = (match.index ?? 0) + expanded.length + value.length - closing - 1;
if (
nextLength > maxVariableExpansionOutputCodeUnits ||
nextLength > maxVariableExpansionWorkCodeUnits - budget.work
) {
diagnostics.push(
createDiagnostic("RESOURCE_LIMIT", "error", "Variable expansion exceeds the Semantic CSS limit.", range),
);
return null;
}
budget.work += nextLength;
value = `${value.slice(0, match.index)}${expanded}${value.slice(closing + 1)}`;
}
if (/\burl\s*\(/i.test(value)) {
diagnostics.push(
createDiagnostic("FORBIDDEN_CSS_VALUE", "error", "External CSS resources are not supported.", range),
);
return null;
}
if (cssFunctionDepth(value) > SEMANTIC_CSS_LIMITS_V1.maxFunctionDepth) {
diagnostics.push(
createDiagnostic("RESOURCE_LIMIT", "error", "CSS function nesting exceeds the Semantic CSS limit.", range),
);
return null;
}
return value.trim();
}
function expandedWinnersFor(
matches: readonly MatchedRule[],
dimensions: ResolvedPageDimensions,
rootFontSize: number,
custom: ReadonlyMap<string, string>,
system: Readonly<Record<string, string>>,
diagnostics: SemanticCssDiagnostic[],
): Map<string, Winner> {
const cascaded = new Map<string, Winner>();
for (const { rule, specificity } of matches) {
if (!ruleApplies(rule, dimensions, rootFontSize, true)) continue;
for (const declaration of rule.declarations) {
if (declaration.property.startsWith("--")) continue;
const targets = expandShorthand(declaration.property, "inherit") ?? [[declaration.property, "inherit"]];
for (const [property] of targets) {
const candidate = { declaration, specificity };
if (candidateWins(candidate, cascaded.get(property))) cascaded.set(property, candidate);
}
}
}
const expansions = new Map<CompiledDeclaration, readonly [property: string, value: string][] | null>();
const winners = new Map<string, Winner>();
for (const [property, winner] of cascaded) {
if (property === "size") {
winners.set(property, winner);
continue;
}
let declarations = expansions.get(winner.declaration);
if (declarations === undefined) {
const expandedValue = expandVariables(
winner.declaration.value,
custom,
system,
diagnostics,
winner.declaration.range,
);
declarations = expandedValue === null ? null : expandShorthand(winner.declaration.property, expandedValue);
if (expandedValue !== null && !declarations) {
diagnostics.push(
createDiagnostic(
"INVALID_VALUE",
"error",
`Invalid ${winner.declaration.property} shorthand.`,
winner.declaration.range,
),
);
}
expansions.set(winner.declaration, declarations);
}
const value = declarations?.find(([expandedProperty]) => expandedProperty === property)?.[1];
if (value === undefined) continue;
winners.set(property, {
declaration: { ...winner.declaration, property, value },
specificity: winner.specificity,
});
}
return winners;
}
function customProperties(
winners: ReadonlyMap<string, Winner>,
parent: ReadonlyMap<string, string> | undefined,
): Map<string, string> {
let custom: Map<string, string> | undefined;
for (const [property, { declaration }] of winners) {
if (!property.startsWith("--")) continue;
const keyword = cssWideKeyword(declaration.value);
if (keyword === "inherit" || keyword === "revert" || keyword === "unset") continue;
if (keyword === "initial") {
if (parent?.has(property)) {
custom ??= new Map(parent);
custom.delete(property);
}
} else if (parent?.get(property) !== declaration.value) {
custom ??= new Map(parent);
custom.set(property, declaration.value);
}
}
return custom ?? (parent as Map<string, string> | undefined) ?? new Map();
}
function parsePageSize(value: string, lengthContext: LengthContext): ResolvedPageSize | null {
if (value.toLowerCase() === "a4") return "A4";
if (value.toLowerCase() === "letter") return "LETTER";
const parts = value.trim().split(/\s+/);
if (parts.length < 1 || parts.length > 2) return null;
const width = toPoints(parts[0] ?? "", "size", lengthContext);
const height = parts[1] ? toPoints(parts[1], "size", lengthContext) : undefined;
if (typeof width !== "number" || (height !== undefined && typeof height !== "number")) return null;
if (
width <= 0 ||
Math.abs(width) > SEMANTIC_CSS_LIMITS_V1.maxAbsoluteLengthPt ||
(height !== undefined && (height <= 0 || Math.abs(height) > SEMANTIC_CSS_LIMITS_V1.maxAbsoluteLengthPt))
) {
return null;
}
return height === undefined ? { width } : { width, height };
}
function dimensionsForSize(size: ResolvedPageSize, authored: ResolvedPageDimensions): ResolvedPageDimensions {
if (size === "A4") return { width: 595.28, height: 841.89 };
if (size === "LETTER") return { width: 612, height: 792 };
return { width: size.width, height: size.height ?? authored.height };
}
function builderPageSize(context: ResolveStylesheetContext, nodeKey: string): ResolvedPageSize {
return (
context.baseStyles[nodeKey]?.structural.pageSize ??
(context.baseSettings.page.format === "letter" ? "LETTER" : "A4")
);
}
function cssWideValue(
keyword: CssWideKeyword,
property: string,
base: Readonly<Record<string, string | number>>,
parent: Readonly<Record<string, string | number>> | undefined,
): string | number | undefined {
const definition = PROPERTY_REGISTRY_V1[property];
if (keyword === "inherit") return parent?.[property];
if (keyword === "initial") return;
if (keyword === "unset") return definition?.inheritable ? parent?.[property] : undefined;
return base[property] ?? (definition?.inheritable ? parent?.[property] : undefined);
}
function normalizeValue(property: string, value: string, context: LengthContext): string | number | null {
if (SEMANTIC_CSS_LENGTH_PROPERTIES.has(property)) {
const length = toPoints(value, property, context);
if (length !== null) return length;
if (/^(auto|none|normal|max-content|min-content|fit-content|thin|medium|thick)$/i.test(value)) {
return value.toLowerCase();
}
return null;
}
if (property === "line-height") {
if (value.toLowerCase() === "normal") return "normal";
return toPoints(value, property, context);
}
if (/^(opacity|flex-grow|flex-shrink|order|orphans|widows|z-index|max-lines)$/i.test(property)) {
const number = Number(value);
if (!Number.isFinite(number)) return null;
if (property === "opacity" && (number < 0 || number > 1)) return null;
return number;
}
return value;
}
function applyStructuralCssWide(
keyword: CssWideKeyword,
property: string,
structural: StructuralPresentation,
base: ResolvedNodeStyle,
parent: ResolvedNodeStyle | undefined,
revertPageSize: ResolvedPageSize,
): boolean {
const key = structuralKeys[property];
if (!key) return false;
const value =
keyword === "inherit"
? parent?.structural[key]
: keyword === "revert"
? (base.structural[key] ?? (key === "pageSize" ? revertPageSize : undefined))
: undefined;
if (value === undefined) delete structural[key];
else Object.assign(structural, { [key]: value });
return true;
}
function structuralValue(
property: string,
value: string | number,
structural: StructuralPresentation,
pageSize?: ResolvedPageSize,
): boolean {
if (property === "break-before") {
if (value === "page") structural.breakBefore = "page";
else if (value === "auto") delete structural.breakBefore;
else return false;
}
if (property === "break-inside") {
if (value === "avoid") structural.breakInside = "avoid";
else if (value === "auto") delete structural.breakInside;
else return false;
}
if (property === "-resume-fixed") {
if (value === "true" || value === "1" || value === 1) structural.fixed = true;
else if (value === "false" || value === "0" || value === 0) structural.fixed = false;
else return false;
}
if (property === "-resume-min-presence-ahead") {
if (typeof value === "number" && value >= 0) structural.minPresenceAhead = value;
else return false;
}
if (property === "orphans" || property === "widows") {
if (typeof value !== "number" || !Number.isInteger(value) || value < 1) return false;
if (property === "orphans") structural.orphans = value;
else structural.widows = value;
}
if (property === "size") {
if (!pageSize) return false;
structural.pageSize = pageSize;
}
return true;
}
function createRenderTree(
tree: SemanticNode,
flatNodes: readonly FlatNode[],
nodes: Readonly<Record<string, ResolvedNodeStyle>>,
): SemanticNode {
const rendered = new Map<string, SemanticNode>();
for (let index = flatNodes.length - 1; index >= 0; index--) {
const source = flatNodes[index]?.node;
if (!source) continue;
const children = source.children
.map((child, sourceIndex) => ({ child: rendered.get(child.key), sourceIndex }))
.filter((entry): entry is { child: SemanticNode; sourceIndex: number } => entry.child !== undefined)
.filter(({ child }) => !nodes[child.key]?.hidden)
.sort((left, right) => {
const order = (nodes[left.child.key]?.order ?? 0) - (nodes[right.child.key]?.order ?? 0);
return order || left.sourceIndex - right.sourceIndex;
})
.map(({ child }) => child);
rendered.set(source.key, { ...source, attributes: { ...source.attributes }, roles: [...source.roles], children });
}
return rendered.get(tree.key) ?? tree;
}
export function resolveStylesheet(
program: StyleProgram,
tree: SemanticNode,
context: ResolveStylesheetContext,
): ResolveStylesheetResult {
const flatNodes = flattenTree(tree);
if (!flatNodes) {
return {
nodes: {},
renderTree: tree,
diagnostics: [
createDiagnostic("RESOURCE_LIMIT", "error", "The semantic tree exceeds the Semantic CSS node limit."),
],
};
}
if (
context.pages.some(
({ width, height }) =>
!Number.isFinite(width) ||
!Number.isFinite(height) ||
width <= 0 ||
height <= 0 ||
width > SEMANTIC_CSS_LIMITS_V1.maxAbsoluteLengthPt ||
height > SEMANTIC_CSS_LIMITS_V1.maxAbsoluteLengthPt,
)
) {
return {
nodes: {},
renderTree: tree,
diagnostics: [
createDiagnostic("INVALID_VALUE", "error", "Authored page dimensions must be finite and positive."),
],
};
}
const diagnostics: SemanticCssDiagnostic[] = [];
const matched = new Map<string, MatchedRule[]>();
const selectorMatches = createSelectorMatcher(tree);
for (const { node } of flatNodes) {
const rules: MatchedRule[] = [];
for (const rule of program.rules) {
const specificity = matchingSpecificity(rule, node.key, selectorMatches);
if (specificity) rules.push({ rule, specificity });
}
matched.set(node.key, rules);
}
const nodeKinds = new Map(flatNodes.map(({ node }) => [node.key, node.kind]));
const cascadeKinds = new Map<string, ReadonlySet<SemanticNode["kind"]>>();
for (const [canonicalKey, aliasKeys] of Object.entries(context.aliases ?? {})) {
const canonicalKind = nodeKinds.get(canonicalKey);
if (!canonicalKind) continue;
const kinds = new Set<SemanticNode["kind"]>([canonicalKind]);
const rules = [...(matched.get(canonicalKey) ?? [])];
for (const aliasKey of aliasKeys) {
const aliasKind = nodeKinds.get(aliasKey);
if (!aliasKind) continue;
kinds.add(aliasKind);
rules.push(...(matched.get(aliasKey) ?? []));
}
matched.set(canonicalKey, rules);
cascadeKinds.set(canonicalKey, kinds);
}
for (const rule of program.rules) {
const matchingNodes = flatNodes.filter(({ node }) =>
matched.get(node.key)?.some((matchedRule) => matchedRule.rule === rule),
);
if (matchingNodes.length === 0) {
diagnostics.push(
createDiagnostic("SELECTOR_NO_MATCH", "warning", "This selector matches no semantic resume node.", rule.range),
);
continue;
}
for (const declaration of rule.declarations) {
if (declaration.property.startsWith("--")) continue;
const definition = PROPERTY_REGISTRY_V1[declaration.property];
if (definition && !matchingNodes.some(({ node }) => definition.appliesTo.includes(node.kind))) {
diagnostics.push(
createDiagnostic(
"PROPERTY_NOT_APPLICABLE",
"warning",
`${declaration.property} cannot apply to the matched semantic node kinds.`,
declaration.range,
),
);
}
}
}
const rootFontSize = context.baseSettings.typography.body.fontSize;
const fallbackDimensions = defaultPageDimensions(context.baseSettings.page.format);
const pages = initialPages(flatNodes, context);
const resolvedPageSizes = new Map<string, ResolvedPageSize>();
const resolvedPageSizeValues = new Map<string, string>();
const preliminaryCustom = new Map<string, Map<string, string>>();
for (const node of flatNodes) {
const dimensions = pageFor(node, pages, fallbackDimensions);
const rawWinners = winnersFor(matched.get(node.node.key) ?? [], dimensions, rootFontSize, false);
const custom = customProperties(rawWinners, node.parent ? preliminaryCustom.get(node.parent.node.key) : undefined);
preliminaryCustom.set(node.node.key, custom);
if (node.node.kind !== "page") continue;
const size = rawWinners.get("size");
if (!size) continue;
const system = createSystemVariables(context.baseSettings, dimensions);
const expanded = expandVariables(size.declaration.value, custom, system, diagnostics, size.declaration.range);
if (!expanded) continue;
const keyword = cssWideKeyword(expanded);
const parsed =
keyword === "revert"
? builderPageSize(context, node.node.key)
: keyword === "inherit"
? node.parent
? context.baseStyles[node.parent.node.key]?.structural.pageSize
: undefined
: keyword
? undefined
: parsePageSize(expanded, {
page: dimensions,
parentFontSize: rootFontSize,
fontSize: rootFontSize,
rootFontSize,
});
if (keyword && !parsed) {
resolvedPageSizeValues.set(node.node.key, expanded);
continue;
}
if (!parsed) {
diagnostics.push(createDiagnostic("INVALID_VALUE", "error", "Invalid page size.", size.declaration.range));
continue;
}
resolvedPageSizeValues.set(node.node.key, expanded);
resolvedPageSizes.set(node.node.key, parsed);
pages.set(node.node.key, dimensionsForSize(parsed, dimensions));
}
const customByNode = new Map<string, Map<string, string>>();
const resolved: Record<string, ResolvedNodeStyle> = {};
for (const node of flatNodes) {
const dimensions = pageFor(node, pages, fallbackDimensions);
const rawWinners = winnersFor(matched.get(node.node.key) ?? [], dimensions, rootFontSize, true);
const custom = customProperties(rawWinners, node.parent ? customByNode.get(node.parent.node.key) : undefined);
customByNode.set(node.node.key, custom);
const system = createSystemVariables(context.baseSettings, dimensions);
const winners = expandedWinnersFor(
matched.get(node.node.key) ?? [],
dimensions,
rootFontSize,
custom,
system,
diagnostics,
);
const base = context.baseStyles[node.node.key] ?? { style: {}, structural: {}, hidden: false, order: 0 };
const parent = node.parent ? resolved[node.parent.node.key] : undefined;
const style: Record<string, string | number> = { ...base.style };
const specifiedStyleProperties = new Set<string>();
const hostBaseStyleProperties = new Set<string>();
for (const [property, definition] of Object.entries(PROPERTY_REGISTRY_V1)) {
if (!definition?.inheritable) continue;
const inheritedFromAuthoredRule = parent?.specifiedStyleProperties?.includes(property) && !winners.has(property);
if (inheritedFromAuthoredRule) {
specifiedStyleProperties.add(property);
if (parent?.hostBaseStyleProperties?.includes(property)) hostBaseStyleProperties.add(property);
if (parent?.style[property] === undefined) delete style[property];
else style[property] = parent.style[property];
} else if (style[property] === undefined && parent?.style[property] !== undefined) {
style[property] = parent.style[property];
}
}
const fontSizeWinner = winners.get("font-size");
const parentFontSize =
typeof parent?.style["font-size"] === "number"
? parent.style["font-size"]
: context.baseSettings.typography.body.fontSize;
const applicableKinds = cascadeKinds.get(node.node.key) ?? new Set([node.node.kind]);
if (fontSizeWinner && PROPERTY_REGISTRY_V1["font-size"]?.appliesTo.some((kind) => applicableKinds.has(kind))) {
const expanded = fontSizeWinner.declaration.value;
const keyword = cssWideKeyword(expanded);
if (keyword) {
specifiedStyleProperties.add("font-size");
if (keyword === "revert") hostBaseStyleProperties.add("font-size");
const wide = cssWideValue(keyword, "font-size", base.style, parent?.style);
if (wide === undefined) delete style["font-size"];
else style["font-size"] = wide;
} else {
const syntaxError = valueSyntaxError("font-size", expanded);
if (syntaxError) {
diagnostics.push(createDiagnostic("INVALID_VALUE", "error", syntaxError, fontSizeWinner.declaration.range));
} else {
const normalized = normalizeValue("font-size", expanded, {
page: dimensions,
parentFontSize,
fontSize: parentFontSize,
rootFontSize,
});
if (normalized === null) {
diagnostics.push(
createDiagnostic("INVALID_VALUE", "error", "Invalid font-size value.", fontSizeWinner.declaration.range),
);
} else if (
typeof normalized === "number" &&
(normalized < 0 || normalized > SEMANTIC_CSS_LIMITS_V1.maxAbsoluteLengthPt)
) {
diagnostics.push(
createDiagnostic(
"INVALID_VALUE",
"error",
"font-size exceeds the Semantic CSS length limit.",
fontSizeWinner.declaration.range,
),
);
} else {
specifiedStyleProperties.add("font-size");
style["font-size"] = normalized;
if (typeof normalized === "number" && (normalized < 4 || normalized > 72)) {
diagnostics.push(
createDiagnostic(
"EXTREME_VALUE",
"warning",
"This font size is renderable but unusually extreme.",
fontSizeWinner.declaration.range,
),
);
}
}
}
}
}
const fontSize = typeof style["font-size"] === "number" ? style["font-size"] : parentFontSize;
const structural: StructuralPresentation = { ...base.structural };
let hidden = base.hidden;
let order = base.order;
for (const [property, winner] of winners) {
if (property.startsWith("--") || property === "font-size") continue;
const definition = PROPERTY_REGISTRY_V1[property];
if (!definition?.appliesTo.some((kind) => applicableKinds.has(kind))) continue;
const expanded = winner.declaration.value;
if (property === "size") {
const resolvedValue = resolvedPageSizeValues.get(node.node.key);
if (resolvedValue === undefined) continue;
const sizeKeyword = cssWideKeyword(resolvedValue);
if (sizeKeyword) {
applyStructuralCssWide(
sizeKeyword,
property,
structural,
base,
parent,
builderPageSize(context, node.node.key),
);
} else {
const pageSize = resolvedPageSizes.get(node.node.key);
if (pageSize) structuralValue(property, resolvedValue, structural, pageSize);
}
continue;
}
const keyword = cssWideKeyword(expanded);
if (keyword) {
if (property === "display") {
if (keyword === "inherit") {
hidden = parent?.hidden ?? false;
if (!hidden && parent?.style.display !== undefined) style.display = parent.style.display;
else delete style.display;
} else if (keyword === "revert") {
hidden = base.hidden;
if (base.style.display === undefined) delete style.display;
else style.display = base.style.display;
} else {
hidden = false;
delete style.display;
}
} else if (property === "order") {
order = keyword === "inherit" ? (parent?.order ?? 0) : keyword === "revert" ? base.order : 0;
} else if (definition.category === "structural") {
applyStructuralCssWide(keyword, property, structural, base, parent, builderPageSize(context, node.node.key));
} else {
specifiedStyleProperties.add(property);
if (keyword === "revert") hostBaseStyleProperties.add(property);
const wide = cssWideValue(keyword, property, base.style, parent?.style);
if (wide === undefined) delete style[property];
else style[property] = wide;
}
continue;
}
const syntaxError = valueSyntaxError(property, expanded);
if (syntaxError) {
diagnostics.push(createDiagnostic("INVALID_VALUE", "error", syntaxError, winner.declaration.range));
continue;
}
const normalized = normalizeValue(property, expanded, {
page: dimensions,
parentFontSize,
fontSize,
rootFontSize,
});
if (normalized === null) {
diagnostics.push(
createDiagnostic("INVALID_VALUE", "error", `Invalid ${property} value.`, winner.declaration.range),
);
continue;
}
if (typeof normalized === "number" && Math.abs(normalized) > SEMANTIC_CSS_LIMITS_V1.maxAbsoluteLengthPt) {
diagnostics.push(
createDiagnostic(
"INVALID_VALUE",
"error",
`${property} exceeds the Semantic CSS length limit.`,
winner.declaration.range,
),
);
continue;
}
if (property === "display") {
hidden = normalized === "none";
if (hidden) delete style.display;
else style.display = normalized;
continue;
}
if (property === "order") {
const nextOrder = typeof normalized === "number" ? normalized : Number(normalized);
if (!Number.isInteger(nextOrder)) {
diagnostics.push(
createDiagnostic("INVALID_VALUE", "error", "order must be an integer.", winner.declaration.range),
);
} else order = nextOrder;
continue;
}
if (definition.category === "structural") {
if (!structuralValue(property, normalized, structural)) {
diagnostics.push(
createDiagnostic("INVALID_VALUE", "error", `Invalid ${property} value.`, winner.declaration.range),
);
}
continue;
}
specifiedStyleProperties.add(property);
style[property] = normalized;
}
resolved[node.node.key] = {
style,
specifiedStyleProperties: [...specifiedStyleProperties],
hostBaseStyleProperties: [...hostBaseStyleProperties],
structural,
hidden,
order,
};
}
if (diagnostics.some(({ severity }) => severity === "error")) {
return { nodes: {}, renderTree: tree, diagnostics };
}
return { nodes: resolved, renderTree: createRenderTree(tree, flatNodes, resolved), diagnostics };
}
+114
View File
@@ -0,0 +1,114 @@
import type { StylesheetSource } from "@reactive-resume/schema/resume/stylesheet";
import type { CompileStylesheetResult } from "./types";
import { stylesheetCacheKey, stylesheetCompilationCache } from "./cache";
import { createDiagnostic } from "./diagnostics";
import { SEMANTIC_CSS_LIMITS_V1 } from "./limits";
import { parseStylesheet } from "./parse";
import { PROPERTY_REGISTRY_V1 } from "./registry/properties";
import { SEMANTIC_NODE_KINDS } from "./registry/semantic";
import { SYSTEM_VARIABLE_REGISTRY_V1 } from "./registry/system-variables";
import { compileProgram, cssFunctionDepth } from "./values";
import { getStylesheetCompiler } from "./version";
function isPositiveInteger(value: string): boolean {
return /^[1-9]\d*$/.test(value);
}
export function compileStylesheet(source: StylesheetSource): CompileStylesheetResult {
if (new TextEncoder().encode(source.text).byteLength > SEMANTIC_CSS_LIMITS_V1.maxSourceBytes) {
return {
program: null,
diagnostics: [
createDiagnostic("RESOURCE_LIMIT", "error", "The stylesheet source exceeds the Semantic CSS byte limit."),
],
};
}
if (cssFunctionDepth(source.text) > SEMANTIC_CSS_LIMITS_V1.maxFunctionDepth) {
return {
program: null,
diagnostics: [
createDiagnostic("RESOURCE_LIMIT", "error", "CSS function nesting exceeds the Semantic CSS limit."),
],
};
}
const registry = JSON.stringify([PROPERTY_REGISTRY_V1, SEMANTIC_NODE_KINDS, SYSTEM_VARIABLE_REGISTRY_V1]);
const cacheKey = stylesheetCacheKey(source.languageVersion, source.text, registry);
const cached = stylesheetCompilationCache.get(cacheKey);
if (cached) return cached;
const stylesheet = parseStylesheet(source.text);
const diagnostics = [...stylesheet.diagnostics];
const versionDirectives = stylesheet.atRules.filter((atRule) => atRule.name === "version");
if (versionDirectives.length === 0 && source.languageVersion === 1) {
diagnostics.push(
createDiagnostic("MISSING_VERSION_DIRECTIVE", "warning", "Version-one stylesheets should start with @version 1;"),
);
}
if (versionDirectives.length > 1) {
for (const directive of versionDirectives.slice(1)) {
diagnostics.push(
createDiagnostic(
"DUPLICATE_VERSION_DIRECTIVE",
"error",
"A stylesheet can contain only one @version directive.",
directive.range,
),
);
}
}
for (const directive of versionDirectives) {
if (directive.hasBlock || !isPositiveInteger(directive.prelude)) {
diagnostics.push(
createDiagnostic(
"INVALID_VERSION",
"error",
"@version must contain one positive integer and no block.",
directive.range,
),
);
continue;
}
const version = Number(directive.prelude);
if (version !== source.languageVersion) {
diagnostics.push(
createDiagnostic(
"VERSION_MISMATCH",
"error",
"@version must match the stylesheet language version.",
directive.range,
),
);
}
}
const compiler = getStylesheetCompiler(source.languageVersion);
if (!compiler) {
diagnostics.push(
createDiagnostic(
"UNSUPPORTED_VERSION",
"error",
`Semantic CSS version ${source.languageVersion} is not supported.`,
),
);
}
if (!compiler || diagnostics.some((diagnostic) => diagnostic.severity === "error")) {
return { program: null, diagnostics };
}
const compiled = compileProgram(stylesheet, source.languageVersion);
diagnostics.push(...compiled.diagnostics);
if (!compiled.program || diagnostics.some(({ severity }) => severity === "error")) {
return { program: null, diagnostics };
}
const result = { program: compiler(compiled.program.rules), diagnostics };
stylesheetCompilationCache.set(cacheKey, result);
return result;
}
@@ -0,0 +1,48 @@
export function escapeCssIdentifier(value: string): string {
let result = "";
for (let index = 0; index < value.length; index++) {
const codePoint = value.codePointAt(index);
if (codePoint === undefined) break;
const character = String.fromCodePoint(codePoint);
const isFirst = index === 0;
const isSecondAfterHyphen = index === 1 && value[0] === "-";
const isControl = codePoint <= 0x1f || codePoint === 0x7f;
const isDigit = codePoint >= 0x30 && codePoint <= 0x39;
const isIdentifierCharacter =
codePoint >= 0x80 ||
character === "-" ||
character === "_" ||
(isDigit && !isFirst && !isSecondAfterHyphen) ||
(codePoint >= 0x41 && codePoint <= 0x5a) ||
(codePoint >= 0x61 && codePoint <= 0x7a);
if (codePoint === 0) result += "";
else if (isControl || (isDigit && (isFirst || isSecondAfterHyphen))) result += `\\${codePoint.toString(16)} `;
else if (isIdentifierCharacter) result += character;
else result += `\\${character}`;
if (codePoint > 0xffff) index++;
}
return result;
}
export function escapeCssComment(value: string): string {
return value.replaceAll("*/", "*\\/");
}
export function escapeCssString(value: string): string {
let result = '"';
for (const character of value) {
const codePoint = character.codePointAt(0);
if (codePoint === undefined) continue;
if (codePoint === 0) result += "";
else if (character === '"' || character === "\\") result += `\\${character}`;
else if (codePoint <= 0x1f || codePoint === 0x7f) result += `\\${codePoint.toString(16)} `;
else result += character;
}
return `${result}"`;
}
+32
View File
@@ -0,0 +1,32 @@
declare module "css-tree" {
export type CssLocation = {
start: { line: number; column: number; offset: number };
end: { line: number; column: number; offset: number };
};
export type CssNode = {
type: string;
name?: string;
prelude?: CssNode | null;
block?: CssNode | null;
children?: Iterable<CssNode>;
loc?: CssLocation | null;
};
export type ParseOptions = {
context?: string;
positions?: boolean;
parseCustomProperty?: boolean;
onParseError?: (error: unknown, node: CssNode) => void;
onComment?: (value: string, location: CssLocation) => void;
onToken?: (...args: unknown[]) => void;
};
export const ident: {
decode(value: string): string;
};
export function generate(node: CssNode): string;
export function parse(source: string, options?: ParseOptions): CssNode;
export function walk(node: CssNode, enter: (node: CssNode) => void): void;
}
@@ -0,0 +1,8 @@
import { expect, it } from "vitest";
import { SEMANTIC_CSS_DIAGNOSTIC_CATALOG_V1 } from "./diagnostics";
it("documents every supported media-query feature in the canonical diagnostic action", () => {
expect(SEMANTIC_CSS_DIAGNOSTIC_CATALOG_V1.INVALID_MEDIA_QUERY.action).toBe(
"Use orientation: portrait|landscape or width, min-width, max-width, height, min-height, or max-height with a Semantic CSS length.",
);
});
@@ -0,0 +1,137 @@
import type { SemanticCssDiagnostic, SourceRange } from "./types";
type DiagnosticReference = {
severity: SemanticCssDiagnostic["severity"];
meaning: string;
action: string;
};
export const SEMANTIC_CSS_DIAGNOSTIC_CATALOG_V1 = {
MISSING_VERSION_DIRECTIVE: {
severity: "warning",
meaning: "The stylesheet omitted @version.",
action: "Add @version 1; as the first statement.",
},
DUPLICATE_VERSION_DIRECTIVE: {
severity: "error",
meaning: "More than one @version directive was found.",
action: "Keep exactly one version directive.",
},
INVALID_VERSION: {
severity: "error",
meaning: "The version directive is not one positive integer without a block.",
action: "Use @version 1;.",
},
VERSION_MISMATCH: {
severity: "error",
meaning: "The directive and stored language version disagree.",
action: "Set both to version 1.",
},
UNSUPPORTED_VERSION: {
severity: "error",
meaning: "The requested Semantic CSS version is not implemented.",
action: "Use @version 1;.",
},
CSS_PARSE_ERROR: {
severity: "error",
meaning: "The stylesheet is not valid parseable CSS syntax.",
action: "Fix the syntax at the reported source range.",
},
CSS_RAW_SYNTAX: {
severity: "error",
meaning: "The parser encountered unsupported raw CSS syntax.",
action: "Rewrite the declaration or selector using documented Semantic CSS syntax.",
},
FORBIDDEN_AT_RULE: {
severity: "error",
meaning: "The at-rule can load resources or execute unsupported CSS behavior.",
action: "Remove the at-rule.",
},
UNSUPPORTED_AT_RULE: {
severity: "error",
meaning: "The at-rule is not part of Semantic CSS version 1.",
action: "Use only @version and documented @media queries.",
},
INVALID_MEDIA_QUERY: {
severity: "error",
meaning: "The PDF dimension query is malformed or unsupported.",
action:
"Use orientation: portrait|landscape or width, min-width, max-width, height, min-height, or max-height with a Semantic CSS length.",
},
MEDIA_PAGE_SIZE: {
severity: "error",
meaning: "A media rule attempts to change the page size it is evaluated against.",
action: "Move the page size declaration outside @media.",
},
INVALID_SELECTOR: {
severity: "error",
meaning: "The selector uses unsupported syntax or exceeds selector limits.",
action: "Rewrite it using documented Semantic CSS selectors and combinators.",
},
UNSUPPORTED_PROPERTY: {
severity: "error",
meaning: "The property is not in the Semantic CSS property registry.",
action: "Choose a property from the property reference.",
},
SYSTEM_VARIABLE_READONLY: {
severity: "error",
meaning: "An author attempted to assign a reserved --resume-* variable.",
action: "Read the system variable or rename the author variable.",
},
FORBIDDEN_CSS_VALUE: {
severity: "error",
meaning: "The value attempts to use an external resource or forbidden CSS capability.",
action: "Use a PDF-safe local value.",
},
INVALID_VALUE: {
severity: "error",
meaning: "The value does not match the supported grammar for the property.",
action: "Use the documented property value form.",
},
VARIABLE_CYCLE: {
severity: "error",
meaning: "Custom properties form a var() reference cycle.",
action: "Break the cycle or provide a non-cyclic fallback.",
},
UNRESOLVED_VARIABLE: {
severity: "error",
meaning: "A var() reference has neither a value nor a usable fallback.",
action: "Define the variable or add a fallback.",
},
EXTREME_VALUE: {
severity: "warning",
meaning: "A value is valid but likely to produce unusable output.",
action: "Reduce the value unless the effect is intentional.",
},
SELECTOR_NO_MATCH: {
severity: "warning",
meaning: "The selector matches no node in the current resume and template.",
action: "Check the ID, attribute value, placement, or template guard.",
},
PROPERTY_NOT_APPLICABLE: {
severity: "warning",
meaning: "The property cannot affect any matched semantic node kind.",
action: "Target a node listed in the property's Applies to column.",
},
RESOURCE_LIMIT: {
severity: "error",
meaning: "Compilation, matching, values, variables, or semantic nodes exceeded a bounded Semantic CSS limit.",
action: "Reduce stylesheet or resume complexity.",
},
} as const satisfies Readonly<Record<string, DiagnosticReference>>;
export type SemanticCssCompilerDiagnosticCode = keyof typeof SEMANTIC_CSS_DIAGNOSTIC_CATALOG_V1;
export const EMPTY_SOURCE_RANGE: SourceRange = {
start: { line: 1, column: 1, offset: 0 },
end: { line: 1, column: 1, offset: 0 },
};
export function createDiagnostic(
code: SemanticCssCompilerDiagnosticCode,
severity: SemanticCssDiagnostic["severity"],
message: string,
range: SourceRange = EMPTY_SOURCE_RANGE,
): SemanticCssDiagnostic {
return { code, severity, message, range };
}
+55
View File
@@ -0,0 +1,55 @@
/// <reference path="./css-tree.d.ts" />
/// <reference path="./specificity.d.ts" />
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 {
AuthoredPageContext,
BaseSettingsSnapshot,
CompiledDeclaration,
CompiledMediaQuery,
CompiledStyleRule,
CompileStylesheetResult,
DiagnosticSeverity,
MediaFeature,
ParsedAtRule,
ParsedStylesheet,
ResolvedNodeStyle,
ResolvedPageDimensions,
ResolvedPageSize,
ResolveStylesheetContext,
ResolveStylesheetInput,
ResolveStylesheetResult,
SemanticCssDiagnostic,
SemanticNode,
SemanticNodeKind,
SourcePosition,
SourceRange,
StructuralPresentation,
StyleProgram,
} from "./types";
export { analyzeStylesheet } from "./analyze";
export { resolveStylesheet } from "./cascade";
export { compileStylesheet } from "./compile";
export { 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";
export {
canContainNode,
SEMANTIC_NODE_KINDS,
SEMANTIC_REGISTRY_V1,
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";
+13
View File
@@ -0,0 +1,13 @@
export const SEMANTIC_CSS_LIMITS_V1 = Object.freeze({
maxSourceBytes: 128 * 1024,
maxRules: 1_024,
maxDeclarations: 8_192,
maxSelectorsPerRule: 64,
maxSelectorCodePoints: 2_048,
maxCombinatorsPerSelector: 16,
maxFunctionDepth: 16,
maxVariableExpansionDepth: 32,
maxMediaNesting: 4,
maxSemanticNodes: 20_000,
maxAbsoluteLengthPt: 100_000,
} as const);
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { parseStylesheet } from "./parse";
describe("parseStylesheet", () => {
it("returns exact ranges for malformed declarations and keeps a recoverable parse tree", () => {
const result = parseStylesheet("@version 1;\nsection { color red; }\nitem { opacity: .5; }");
expect(result.diagnostics[0]?.range).toEqual({
start: { line: 2, column: 17, offset: 31 },
end: { line: 2, column: 17, offset: 31 },
});
expect(result.rules).toHaveLength(2);
});
it("keeps UTF-16 offsets aligned with the original source", () => {
const result = parseStylesheet("/* 😀 */\nsection { color red; }");
expect(result.diagnostics[0]?.range.start).toMatchObject({ line: 2, column: 17, offset: 25 });
});
});
+88
View File
@@ -0,0 +1,88 @@
import type { CssLocation, CssNode } from "css-tree";
import type { ParsedAtRule, ParsedStylesheet, SemanticCssDiagnostic, SourceRange } from "./types";
import * as csstree from "css-tree";
import { createDiagnostic, EMPTY_SOURCE_RANGE } from "./diagnostics";
function rangeFromLocation(location: CssLocation | null | undefined): SourceRange {
if (!location) return EMPTY_SOURCE_RANGE;
return {
start: { ...location.start },
end: { ...location.end },
};
}
function parseErrorRange(error: unknown): SourceRange {
if (error && typeof error === "object" && "line" in error && "column" in error && "offset" in error) {
const { line, column, offset } = error as { line: number; column: number; offset: number };
return { start: { line, column, offset }, end: { line, column, offset } };
}
return EMPTY_SOURCE_RANGE;
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : "Unable to parse stylesheet.";
}
function topLevelNodes(stylesheet: CssNode): CssNode[] {
return stylesheet.children ? [...stylesheet.children] : [];
}
export function parseStylesheet(source: string): ParsedStylesheet {
const diagnostics: SemanticCssDiagnostic[] = [];
let ast: CssNode | null = null;
try {
ast = csstree.parse(source, {
positions: true,
parseCustomProperty: true,
onParseError(error: unknown) {
diagnostics.push(createDiagnostic("CSS_PARSE_ERROR", "error", errorMessage(error), parseErrorRange(error)));
},
onComment: () => undefined,
onToken: () => undefined,
}) as CssNode;
} catch (error) {
diagnostics.push(createDiagnostic("CSS_PARSE_ERROR", "error", errorMessage(error), parseErrorRange(error)));
}
if (!ast) return { ast: null, atRules: [], rules: [], diagnostics };
const rawRanges = new Set<number>();
csstree.walk(ast, function (this: { declaration?: { property?: string } | null }, node: CssNode) {
if (node.type !== "Raw") return;
if (this.declaration?.property?.startsWith("--")) return;
const range = rangeFromLocation(node.loc);
if (rawRanges.has(range.start.offset)) return;
rawRanges.add(range.start.offset);
diagnostics.push(createDiagnostic("CSS_RAW_SYNTAX", "error", "Unsupported CSS syntax.", range));
});
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,
atRules,
rules: nodes.filter((node) => node.type === "Rule"),
diagnostics,
};
}
@@ -0,0 +1,24 @@
/// <reference path="../css-tree.d.ts" />
/// <reference path="../specificity.d.ts" />
export type { SemanticCssCompilerDiagnosticCode } from "../diagnostics";
export type { SemanticCssDiagnostic, SemanticNode } from "../types";
export type { PropertyDefinition, PropertyRegistry } from "./properties";
export type { SemanticNodeDefinition, SemanticRegistry } from "./semantic";
export type { SystemVariableDefinition, SystemVariableRegistry } from "./system-variables";
export { escapeCssIdentifier, escapeCssString } from "../css-escape";
export { SEMANTIC_CSS_DIAGNOSTIC_CATALOG_V1 } from "../diagnostics";
export {
PROPERTY_REGISTRY_V1,
SEMANTIC_CSS_BORDER_STYLE_VALUES_V1,
SEMANTIC_CSS_CSS_WIDE_KEYWORDS_V1,
SEMANTIC_CSS_LENGTH_PROPERTIES_V1,
SEMANTIC_CSS_LENGTH_UNITS_V1,
SEMANTIC_CSS_LENGTH_VALUE_KEYWORDS_V1,
} from "./properties";
export {
SEMANTIC_NODE_KINDS,
SEMANTIC_REGISTRY_V1,
TEMPLATE_PART_CHILD_KINDS_V1,
} from "./semantic";
export { SYSTEM_VARIABLE_REGISTRY_V1 } from "./system-variables";
@@ -0,0 +1,394 @@
import { describe, expect, it } from "vitest";
import { PROPERTY_REGISTRY_V1 } from "./properties";
const borderShorthands = ["border", "border-top", "border-right", "border-bottom", "border-left"] as const;
const borderShorthandHints = [
"inherit",
"initial",
"revert",
"unset",
"1pt dotted",
"1pt dashed",
"1pt solid",
] as const;
const expectedProperties = [
"align-content",
"align-items",
"align-self",
"flex",
"flex-direction",
"flex-wrap",
"flex-flow",
"flex-grow",
"flex-shrink",
"flex-basis",
"justify-content",
"gap",
"row-gap",
"column-gap",
"aspect-ratio",
"bottom",
"display",
"left",
"position",
"right",
"top",
"overflow",
"z-index",
"width",
"height",
"min-width",
"min-height",
"max-width",
"max-height",
"color",
"background-color",
"opacity",
"direction",
"font-size",
"font-style",
"font-weight",
"letter-spacing",
"line-height",
"max-lines",
"text-align",
"text-decoration",
"text-decoration-color",
"text-decoration-style",
"text-indent",
"text-overflow",
"text-transform",
"vertical-align",
"object-fit",
"object-position",
"-resume-shadow-color",
"-resume-shadow-width",
"margin",
"margin-top",
"margin-right",
"margin-bottom",
"margin-left",
"margin-horizontal",
"margin-vertical",
"padding",
"padding-top",
"padding-right",
"padding-bottom",
"padding-left",
"padding-horizontal",
"padding-vertical",
"border",
"border-color",
"border-style",
"border-width",
"border-top",
"border-right",
"border-bottom",
"border-left",
"border-top-color",
"border-top-style",
"border-top-width",
"border-right-color",
"border-right-style",
"border-right-width",
"border-bottom-color",
"border-bottom-style",
"border-bottom-width",
"border-left-color",
"border-left-style",
"border-left-width",
"border-radius",
"border-top-left-radius",
"border-top-right-radius",
"border-bottom-right-radius",
"border-bottom-left-radius",
"transform",
"transform-origin",
"order",
"break-before",
"break-inside",
"orphans",
"widows",
"size",
"-resume-fixed",
"-resume-min-presence-ahead",
];
const containerNodes = [
"page",
"region",
"header",
"contact-list",
"contact-item",
"section",
"section-heading",
"section-items",
"item",
"item-header",
"rich-text",
"list",
"list-item",
"horizontal-rule",
"template-part",
];
const textNodes = [
"name",
"headline",
"section-heading",
"combined-text",
"field",
"rich-heading",
"blockquote",
"paragraph",
"list-item-content",
"list-marker",
"strong",
"emphasis",
"underline",
"strike",
"code",
"text-span",
"mark",
"hard-break",
];
const linkContainerNodes = [...containerNodes, "link"];
const textAndLinkNodes = [...textNodes, "link"];
const colorNodes = [...new Set([...containerNodes, ...textNodes, "link", "icon", "level"])];
const spacingNodes = [...new Set([...containerNodes, ...textNodes, "link", "picture"])];
const structuralNodes = [
"page",
"region",
"header",
"picture",
"name",
"headline",
"contact-list",
"contact-item",
"section",
"section-heading",
"section-items",
"item",
"item-header",
"combined-text",
"field",
"link",
"icon",
"level",
"rich-text",
"rich-heading",
"blockquote",
"paragraph",
"list",
"list-item",
"list-item-content",
"list-marker",
"strong",
"emphasis",
"underline",
"strike",
"code",
"text-span",
"mark",
"hard-break",
"horizontal-rule",
"template-part",
];
const expectedPropertyGroups = [
{
names: [
"align-content",
"align-items",
"align-self",
"flex",
"flex-direction",
"flex-wrap",
"flex-flow",
"flex-grow",
"flex-shrink",
"flex-basis",
"justify-content",
"gap",
"row-gap",
"column-gap",
],
appliesTo: linkContainerNodes,
inheritable: false,
},
{
names: ["aspect-ratio", "bottom"],
appliesTo: linkContainerNodes,
inheritable: false,
},
{
names: ["display"],
appliesTo: [...linkContainerNodes, "list-item-content", "list-marker"],
inheritable: false,
},
{
names: ["left", "position", "right", "top", "overflow", "z-index"],
appliesTo: linkContainerNodes,
inheritable: false,
},
{
names: ["width", "height", "min-width", "min-height", "max-width", "max-height"],
appliesTo: [...linkContainerNodes, "picture"],
inheritable: false,
},
{ names: ["color"], appliesTo: colorNodes, inheritable: true },
{ names: ["background-color"], appliesTo: linkContainerNodes, inheritable: false },
{ names: ["opacity"], appliesTo: colorNodes, inheritable: false },
{ names: ["direction"], appliesTo: textAndLinkNodes, inheritable: true },
{ names: ["font-size"], appliesTo: [...textAndLinkNodes, "icon", "level"], inheritable: true },
{
names: ["font-style", "font-weight", "letter-spacing", "line-height"],
appliesTo: textAndLinkNodes,
inheritable: true,
},
{ names: ["max-lines"], appliesTo: textAndLinkNodes, inheritable: false },
{ names: ["text-align"], appliesTo: textAndLinkNodes, inheritable: true },
{
names: ["text-decoration", "text-decoration-color", "text-decoration-style"],
appliesTo: textAndLinkNodes,
inheritable: false,
},
{ names: ["text-indent"], appliesTo: textAndLinkNodes, inheritable: true },
{ names: ["text-overflow"], appliesTo: textAndLinkNodes, inheritable: false },
{ names: ["text-transform"], appliesTo: textAndLinkNodes, inheritable: true },
{ names: ["vertical-align"], appliesTo: textAndLinkNodes, inheritable: false },
{
names: ["object-fit", "object-position", "-resume-shadow-color", "-resume-shadow-width"],
appliesTo: ["picture"],
inheritable: false,
},
{
names: [
"margin",
"margin-top",
"margin-right",
"margin-bottom",
"margin-left",
"margin-horizontal",
"margin-vertical",
"padding",
"padding-top",
"padding-right",
"padding-bottom",
"padding-left",
"padding-horizontal",
"padding-vertical",
],
appliesTo: spacingNodes,
inheritable: false,
},
{
names: [
"border",
"border-color",
"border-style",
"border-width",
"border-top",
"border-right",
"border-bottom",
"border-left",
"border-top-color",
"border-top-style",
"border-top-width",
"border-right-color",
"border-right-style",
"border-right-width",
"border-bottom-color",
"border-bottom-style",
"border-bottom-width",
"border-left-color",
"border-left-style",
"border-left-width",
"border-radius",
"border-top-left-radius",
"border-top-right-radius",
"border-bottom-right-radius",
"border-bottom-left-radius",
],
appliesTo: [...linkContainerNodes, "picture"],
inheritable: false,
},
{ names: ["transform", "transform-origin"], appliesTo: [...linkContainerNodes, "picture"], inheritable: false },
{
names: ["order", "break-before", "break-inside"],
appliesTo: structuralNodes,
inheritable: false,
},
{ names: ["orphans", "widows"], appliesTo: textNodes, inheritable: false },
{ names: ["size"], appliesTo: ["page"], inheritable: false },
{
names: ["-resume-fixed"],
appliesTo: structuralNodes,
inheritable: false,
},
{
names: ["-resume-min-presence-ahead"],
appliesTo: structuralNodes,
inheritable: false,
},
];
describe("property registry", () => {
it("covers the normative v1 adapter matrix", () => {
expect(Object.keys(PROPERTY_REGISTRY_V1)).toEqual(expectedProperties);
expect(expectedPropertyGroups.flatMap(({ names }) => names)).toEqual(expectedProperties);
});
it("uses the complete v1 applicability and inheritance matrix", () => {
for (const { names, appliesTo, inheritable } of expectedPropertyGroups) {
for (const name of names) {
expect(PROPERTY_REGISTRY_V1[name]?.appliesTo).toEqual(appliesTo);
expect(PROPERTY_REGISTRY_V1[name]?.inheritable).toBe(inheritable);
}
}
});
it("publishes duplicate-free applicability lists", () => {
for (const [property, definition] of Object.entries(PROPERTY_REGISTRY_V1)) {
expect(definition?.appliesTo, property).toEqual([...new Set(definition?.appliesTo)]);
}
});
it("rejects unsupported browser and asset properties", () => {
expect(PROPERTY_REGISTRY_V1["font-family"]).toBeUndefined();
expect(PROPERTY_REGISTRY_V1["background-image"]).toBeUndefined();
expect(PROPERTY_REGISTRY_V1["box-shadow"]).toBeUndefined();
});
it("publishes property-specific fixed value hints", () => {
expect(PROPERTY_REGISTRY_V1["border-style"]?.values).toEqual([
"inherit",
"initial",
"revert",
"unset",
"dotted",
"dashed",
"solid",
]);
expect(PROPERTY_REGISTRY_V1["object-fit"]?.values).toEqual([
"inherit",
"initial",
"revert",
"unset",
"contain",
"cover",
"fill",
"none",
"scale-down",
]);
expect(PROPERTY_REGISTRY_V1["font-size"]?.values).toEqual(["inherit", "initial", "revert", "unset"]);
expect(PROPERTY_REGISTRY_V1.gap?.values).toEqual(["inherit", "initial", "revert", "unset"]);
});
it.each(borderShorthands)("publishes only complete value hints for the %s shorthand", (property) => {
expect(PROPERTY_REGISTRY_V1[property]?.units).toEqual([]);
expect(PROPERTY_REGISTRY_V1[property]?.values).toEqual(borderShorthandHints);
});
});
@@ -0,0 +1,342 @@
import type { SemanticNodeKind } from "../types";
import { SEMANTIC_NODE_KINDS } from "./semantic";
export type PropertyDefinition = {
category:
| "flexbox"
| "layout"
| "dimension"
| "color"
| "text"
| "image"
| "spacing"
| "border"
| "transform"
| "structural";
inheritable: boolean;
appliesTo: readonly SemanticNodeKind[];
values: readonly string[];
units: readonly string[];
};
export type PropertyRegistry = Readonly<Record<string, PropertyDefinition | undefined>>;
export const SEMANTIC_CSS_CSS_WIDE_KEYWORDS_V1 = ["inherit", "initial", "revert", "unset"] as const;
export const SEMANTIC_CSS_LENGTH_UNITS_V1 = ["pt", "px", "in", "mm", "cm", "%", "vw", "vh", "em", "rem"] as const;
export const SEMANTIC_CSS_BORDER_STYLE_VALUES_V1 = ["dotted", "dashed", "solid"] as const;
export const SEMANTIC_CSS_LENGTH_VALUE_KEYWORDS_V1 = [
"auto",
"none",
"normal",
"max-content",
"min-content",
"fit-content",
"thin",
"medium",
"thick",
] as const;
export const SEMANTIC_CSS_LENGTH_PROPERTIES_V1 = [
"bottom",
"border-bottom-left-radius",
"border-bottom-right-radius",
"border-bottom-width",
"border-left-width",
"border-radius",
"border-right-width",
"border-top-left-radius",
"border-top-right-radius",
"border-top-width",
"border-width",
"column-gap",
"flex-basis",
"font-size",
"gap",
"height",
"left",
"letter-spacing",
"margin-bottom",
"margin-left",
"margin-right",
"margin-top",
"max-height",
"max-width",
"min-height",
"min-width",
"padding-bottom",
"padding-left",
"padding-right",
"padding-top",
"right",
"row-gap",
"text-indent",
"top",
"width",
"-resume-min-presence-ahead",
"-resume-shadow-width",
] as const;
const containerNodes = [
"page",
"region",
"header",
"contact-list",
"contact-item",
"section",
"section-heading",
"section-items",
"item",
"item-header",
"rich-text",
"list",
"list-item",
"horizontal-rule",
"template-part",
] as const satisfies readonly SemanticNodeKind[];
const textNodes = [
"name",
"headline",
"section-heading",
"combined-text",
"field",
"rich-heading",
"blockquote",
"paragraph",
"list-item-content",
"list-marker",
"strong",
"emphasis",
"underline",
"strike",
"code",
"text-span",
"mark",
"hard-break",
] as const satisfies readonly SemanticNodeKind[];
const linkContainerNodes = [...containerNodes, "link"] as SemanticNodeKind[];
const textAndLinkNodes = [...textNodes, "link"] as SemanticNodeKind[];
const colorNodes = [...containerNodes, ...textNodes, "link", "icon", "level"] as SemanticNodeKind[];
const spacingNodes = [...containerNodes, ...textNodes, "link", "picture"] as SemanticNodeKind[];
const structuralNodes = [...SEMANTIC_NODE_KINDS.filter((kind) => kind !== "resume")] as SemanticNodeKind[];
const lengthProperties = new Set<string>(SEMANTIC_CSS_LENGTH_PROPERTIES_V1);
const numericLengthUnits = SEMANTIC_CSS_LENGTH_UNITS_V1.filter((unit) => unit !== "%");
const borderStyleProperties = /^(?:border-style|border-(?:top|right|bottom|left)-style)$/;
const borderShorthandProperties = /^(?:border|border-(?:top|right|bottom|left))$/;
const propertyValueHints = {
"align-content": ["flex-start", "flex-end", "center", "stretch", "space-between", "space-around", "space-evenly"],
"align-items": ["flex-start", "flex-end", "center", "stretch", "baseline"],
"align-self": ["auto", "flex-start", "flex-end", "center", "stretch", "baseline"],
flex: ["none", "auto"],
"flex-direction": ["row", "row-reverse", "column", "column-reverse"],
"flex-wrap": ["nowrap", "wrap", "wrap-reverse"],
"flex-flow": ["row", "row-reverse", "column", "column-reverse", "nowrap", "wrap", "wrap-reverse"],
"justify-content": ["flex-start", "flex-end", "center", "space-between", "space-around", "space-evenly"],
display: ["flex", "none"],
position: ["absolute", "relative", "static"],
overflow: ["hidden"],
direction: ["ltr", "rtl"],
"font-style": ["normal", "italic"],
"font-weight": [
"thin",
"hairline",
"ultralight",
"extralight",
"light",
"normal",
"medium",
"semibold",
"demibold",
"bold",
"ultrabold",
"extrabold",
"heavy",
"black",
],
"line-height": ["normal"],
"text-align": ["left", "right", "center", "justify"],
"text-decoration": ["none", "underline", "line-through", "line-through underline", "underline line-through"],
"text-overflow": ["ellipsis"],
"text-transform": ["none", "capitalize", "lowercase", "uppercase", "upperfirst"],
"vertical-align": ["sub", "super"],
"object-fit": ["contain", "cover", "fill", "none", "scale-down"],
"object-position": ["left", "right", "top", "bottom", "center"],
"transform-origin": ["left", "right", "top", "bottom", "center"],
"break-before": ["auto", "page"],
"break-inside": ["auto", "avoid"],
size: ["A4", "letter"],
"-resume-fixed": ["true", "false", "0", "1"],
} as const satisfies Readonly<Record<string, readonly string[]>>;
function propertyValues(name: string): readonly string[] {
const values = borderStyleProperties.test(name)
? SEMANTIC_CSS_BORDER_STYLE_VALUES_V1
: borderShorthandProperties.test(name)
? SEMANTIC_CSS_BORDER_STYLE_VALUES_V1.map((style) => `1pt ${style}`)
: (propertyValueHints[name as keyof typeof propertyValueHints] ?? []);
return [...SEMANTIC_CSS_CSS_WIDE_KEYWORDS_V1, ...values];
}
function propertyUnits(name: string): readonly string[] {
if (name === "size" || name === "-resume-min-presence-ahead") return numericLengthUnits;
return lengthProperties.has(name) || name === "line-height" ? SEMANTIC_CSS_LENGTH_UNITS_V1 : [];
}
function entries(
names: readonly string[],
definition: Omit<PropertyDefinition, "units" | "values">,
): Readonly<Record<string, PropertyDefinition | undefined>> {
return Object.fromEntries(
names.map((name) => [
name,
{
...definition,
appliesTo: [...new Set(definition.appliesTo)],
values: propertyValues(name),
units: propertyUnits(name),
},
]),
);
}
const properties = {
...entries(
[
"align-content",
"align-items",
"align-self",
"flex",
"flex-direction",
"flex-wrap",
"flex-flow",
"flex-grow",
"flex-shrink",
"flex-basis",
"justify-content",
"gap",
"row-gap",
"column-gap",
],
{ category: "flexbox", inheritable: false, appliesTo: linkContainerNodes },
),
...entries(["aspect-ratio", "bottom"], {
category: "layout",
inheritable: false,
appliesTo: linkContainerNodes,
}),
...entries(["display"], {
category: "layout",
inheritable: false,
appliesTo: [...linkContainerNodes, "list-item-content", "list-marker"],
}),
...entries(["left", "position", "right", "top", "overflow", "z-index"], {
category: "layout",
inheritable: false,
appliesTo: linkContainerNodes,
}),
...entries(["width", "height", "min-width", "min-height", "max-width", "max-height"], {
category: "dimension",
inheritable: false,
appliesTo: [...linkContainerNodes, "picture"],
}),
...entries(["color"], { category: "color", inheritable: true, appliesTo: colorNodes }),
...entries(["background-color"], { category: "color", inheritable: false, appliesTo: linkContainerNodes }),
...entries(["opacity"], { category: "color", inheritable: false, appliesTo: colorNodes }),
...entries(["direction"], { category: "text", inheritable: true, appliesTo: textAndLinkNodes }),
...entries(["font-size"], {
category: "text",
inheritable: true,
appliesTo: [...textAndLinkNodes, "icon", "level"],
}),
...entries(["font-style", "font-weight", "letter-spacing", "line-height"], {
category: "text",
inheritable: true,
appliesTo: textAndLinkNodes,
}),
...entries(["max-lines"], { category: "text", inheritable: false, appliesTo: textAndLinkNodes }),
...entries(["text-align"], { category: "text", inheritable: true, appliesTo: textAndLinkNodes }),
...entries(["text-decoration", "text-decoration-color", "text-decoration-style"], {
category: "text",
inheritable: false,
appliesTo: textAndLinkNodes,
}),
...entries(["text-indent"], { category: "text", inheritable: true, appliesTo: textAndLinkNodes }),
...entries(["text-overflow"], { category: "text", inheritable: false, appliesTo: textAndLinkNodes }),
...entries(["text-transform"], { category: "text", inheritable: true, appliesTo: textAndLinkNodes }),
...entries(["vertical-align"], { category: "text", inheritable: false, appliesTo: textAndLinkNodes }),
...entries(["object-fit", "object-position"], { category: "image", inheritable: false, appliesTo: ["picture"] }),
...entries(["-resume-shadow-color", "-resume-shadow-width"], {
category: "image",
inheritable: false,
appliesTo: ["picture"],
}),
...entries(
[
"margin",
"margin-top",
"margin-right",
"margin-bottom",
"margin-left",
"margin-horizontal",
"margin-vertical",
"padding",
"padding-top",
"padding-right",
"padding-bottom",
"padding-left",
"padding-horizontal",
"padding-vertical",
],
{ category: "spacing", inheritable: false, appliesTo: spacingNodes },
),
...entries(
[
"border",
"border-color",
"border-style",
"border-width",
"border-top",
"border-right",
"border-bottom",
"border-left",
"border-top-color",
"border-top-style",
"border-top-width",
"border-right-color",
"border-right-style",
"border-right-width",
"border-bottom-color",
"border-bottom-style",
"border-bottom-width",
"border-left-color",
"border-left-style",
"border-left-width",
"border-radius",
"border-top-left-radius",
"border-top-right-radius",
"border-bottom-right-radius",
"border-bottom-left-radius",
],
{ category: "border", inheritable: false, appliesTo: [...linkContainerNodes, "picture"] },
),
...entries(["transform", "transform-origin"], {
category: "transform",
inheritable: false,
appliesTo: [...linkContainerNodes, "picture"],
}),
...entries(["order", "break-before", "break-inside"], {
category: "structural",
inheritable: false,
appliesTo: structuralNodes,
}),
...entries(["orphans", "widows"], { category: "structural", inheritable: false, appliesTo: textNodes }),
...entries(["size"], { category: "structural", inheritable: false, appliesTo: ["page"] }),
...entries(["-resume-fixed"], { category: "structural", inheritable: false, appliesTo: structuralNodes }),
...entries(["-resume-min-presence-ahead"], {
category: "structural",
inheritable: false,
appliesTo: structuralNodes,
}),
} satisfies PropertyRegistry;
export const PROPERTY_REGISTRY_V1: PropertyRegistry = properties;
@@ -0,0 +1,190 @@
import { describe, expect, it } from "vitest";
import { sectionTypeSchema } from "@reactive-resume/schema/resume/data";
import { templateSchema } from "@reactive-resume/schema/templates";
import * as semanticRegistry from "./semantic";
import { canContainNode, SEMANTIC_NODE_KINDS, SEMANTIC_REGISTRY_V1, TEMPLATE_PART_CHILD_KINDS_V1 } from "./semantic";
const inlineParents = [
"contact-item",
"field",
"link",
"rich-heading",
"blockquote",
"paragraph",
"list-item-content",
"strong",
"emphasis",
"underline",
"strike",
"code",
"text-span",
"mark",
];
const expectedParents = {
resume: [],
page: ["resume"],
region: ["page"],
header: ["region"],
picture: ["header"],
name: ["header"],
headline: ["header"],
"contact-list": ["header"],
"contact-item": ["contact-list"],
section: ["region"],
"section-heading": ["section"],
"section-items": ["section"],
item: ["section-items", "item"],
"item-header": ["item"],
"combined-text": ["item-header", "template-part"],
field: ["contact-item", "item", "item-header", "combined-text"],
link: ["item", "item-header", "rich-text", "template-part", ...inlineParents],
icon: ["contact-item", "section-heading", "item", "item-header", "level"],
level: ["item", "item-header"],
"rich-text": ["item", "field"],
"rich-heading": ["rich-text"],
blockquote: ["rich-text", "list-item-content"],
paragraph: ["rich-text", "list-item-content"],
list: ["rich-text", "list-item-content"],
"list-item": ["list"],
"list-item-content": ["list-item"],
"list-marker": ["list-item"],
strong: inlineParents,
emphasis: inlineParents,
underline: inlineParents,
strike: inlineParents,
code: inlineParents,
"text-span": inlineParents,
mark: inlineParents,
"hard-break": inlineParents,
"horizontal-rule": ["rich-text", "list-item-content"],
"template-part": [
"page",
"region",
"header",
"section",
"section-heading",
"section-items",
"item",
"item-header",
"contact-list",
"contact-item",
"template-part",
],
};
const expectedTemplatePartChildren = {
"timeline-line": [],
"timeline-marker": ["template-part"],
"timeline-dot": [],
"timeline-content": ["item-header", "field", "link", "item", "level"],
"featured-summary": ["section"],
"sidebar-background": [],
"header-band": ["template-part", "name", "headline"],
"picture-anchor": ["picture"],
"contact-offset": [],
"header-intro": ["template-part"],
"header-body": ["picture", "name", "headline", "section"],
"header-contact-band": ["contact-list"],
"inline-item-header-leading": ["field", "combined-text"],
"inline-item-header-middle": ["field", "link"],
"inline-item-header-trailing": ["field"],
"education-grade-row": ["combined-text"],
"header-divider": ["name", "headline"],
"contact-item-content": ["link", "icon", "field"],
"header-name-rule": [],
"contact-row-primary": ["contact-item"],
"contact-row-secondary": ["contact-item"],
} as const;
describe("semantic registry", () => {
it("registers every stable semantic node and parentage contract", () => {
expect(Object.keys(SEMANTIC_REGISTRY_V1)).toEqual(Object.keys(expectedParents));
for (const [kind, parents] of Object.entries(expectedParents)) {
expect(SEMANTIC_REGISTRY_V1[kind as keyof typeof SEMANTIC_REGISTRY_V1].parents).toEqual(parents);
}
});
it("keeps list rows separate from their inner content", () => {
expect(canContainNode("resume", "page")).toBe(true);
expect(canContainNode("section", "item")).toBe(false);
expect(canContainNode("section-items", "item")).toBe(true);
expect(canContainNode("item", "item")).toBe(true);
expect(canContainNode("list-item", "list-item-content")).toBe(true);
expect(canContainNode("list-item-content", "list-marker")).toBe(false);
});
it("permits only the registered level decoration ancestry and state roles", () => {
expect(canContainNode("level", "icon")).toBe(true);
expect(canContainNode("level", "field")).toBe(false);
expect(SEMANTIC_REGISTRY_V1.icon.attributes).toEqual(["type"]);
expect(SEMANTIC_REGISTRY_V1.icon.roles).toEqual(["decoration", "active", "inactive"]);
expect(SEMANTIC_REGISTRY_V1.icon.roles).not.toContain("primary-text");
});
it("registers list content direction without broadening unrelated rich-text attributes", () => {
expect(SEMANTIC_REGISTRY_V1["list-item-content"].attributes).toEqual(["direction"]);
expect(SEMANTIC_REGISTRY_V1["list-item"].attributes).toEqual([]);
});
it("publishes finite semantic attribute domains", () => {
expect(SEMANTIC_REGISTRY_V1.resume.attributeValues?.template).toEqual(templateSchema.options);
expect(SEMANTIC_REGISTRY_V1.section.attributeValues).toMatchObject({
type: sectionTypeSchema.options,
placement: ["main", "sidebar"],
origin: ["main", "sidebar"],
});
});
it("permits truthful contact and nested template parts without broadening unrelated parents", () => {
expect(canContainNode("contact-item", "template-part")).toBe(true);
expect(canContainNode("template-part", "template-part")).toBe(false);
expect(canContainNode("template-part", "template-part", "timeline-marker")).toBe(true);
expect(canContainNode("contact-list", "template-part")).toBe(true);
expect(canContainNode("rich-text", "template-part")).toBe(false);
});
it("registers contact-list template parts and their child contracts", () => {
expect(SEMANTIC_REGISTRY_V1["template-part"].parents).toContain("contact-list");
expect(TEMPLATE_PART_CHILD_KINDS_V1["contact-row-primary"]).toEqual(["contact-item"]);
expect(TEMPLATE_PART_CHILD_KINDS_V1["contact-row-secondary"]).toEqual(["contact-item"]);
});
it("freezes the exact child-kind allowlist for every existing primitive template part", () => {
const registry = semanticRegistry as unknown as {
TEMPLATE_PART_CHILD_KINDS_V1?: Readonly<Record<string, readonly string[]>>;
};
expect(registry.TEMPLATE_PART_CHILD_KINDS_V1).toEqual(expectedTemplatePartChildren);
expect(Object.isFrozen(registry.TEMPLATE_PART_CHILD_KINDS_V1)).toBe(true);
for (const children of Object.values(registry.TEMPLATE_PART_CHILD_KINDS_V1 ?? {})) {
expect(Object.isFrozen(children)).toBe(true);
}
});
it("allows only each named template part's exact child kinds", () => {
for (const [part, allowed] of Object.entries(expectedTemplatePartChildren)) {
for (const child of SEMANTIC_NODE_KINDS) {
expect(canContainNode("template-part", child, part)).toBe((allowed as readonly string[]).includes(child));
}
}
});
it("rejects omitted and unknown template-part names without changing ordinary two-argument containment", () => {
expect(canContainNode("template-part", "picture")).toBe(false);
expect(canContainNode("template-part", "picture", "unknown-part")).toBe(false);
expect(canContainNode("header", "picture")).toBe(true);
expect(canContainNode("header", "field")).toBe(false);
});
it("allows alias tokens only on canonical owner kinds that use them", () => {
expect(SEMANTIC_REGISTRY_V1.region.attributes).toContain("part");
expect(SEMANTIC_REGISTRY_V1.section.attributes).toContain("part");
expect(SEMANTIC_REGISTRY_V1["item-header"].attributes).toContain("part");
expect(SEMANTIC_REGISTRY_V1["contact-item"].attributes).toContain("part");
expect(SEMANTIC_REGISTRY_V1.header.attributes).not.toContain("part");
expect(SEMANTIC_REGISTRY_V1.item.attributes).not.toContain("part");
expect(SEMANTIC_REGISTRY_V1.field.attributes).not.toContain("part");
});
});
@@ -0,0 +1,221 @@
import type { SemanticNodeKind } from "../semantic-types";
import { sectionTypeSchema } from "@reactive-resume/schema/resume/data";
import { templateSchema } from "@reactive-resume/schema/templates";
export type SemanticNodeDefinition = {
parents: readonly SemanticNodeKind[];
attributes: readonly string[];
roles: readonly string[];
attributeValues?: Readonly<Record<string, readonly string[]>>;
};
export type SemanticRegistry = Readonly<Record<SemanticNodeKind, SemanticNodeDefinition>>;
export const SEMANTIC_NODE_KINDS = [
"resume",
"page",
"region",
"header",
"picture",
"name",
"headline",
"contact-list",
"contact-item",
"section",
"section-heading",
"section-items",
"item",
"item-header",
"combined-text",
"field",
"link",
"icon",
"level",
"rich-text",
"rich-heading",
"blockquote",
"paragraph",
"list",
"list-item",
"list-item-content",
"list-marker",
"strong",
"emphasis",
"underline",
"strike",
"code",
"text-span",
"mark",
"hard-break",
"horizontal-rule",
"template-part",
] as const satisfies readonly SemanticNodeKind[];
const inlineParents = [
"contact-item",
"field",
"link",
"rich-heading",
"blockquote",
"paragraph",
"list-item-content",
"strong",
"emphasis",
"underline",
"strike",
"code",
"text-span",
"mark",
] as const satisfies readonly SemanticNodeKind[];
export const SEMANTIC_REGISTRY_V1 = {
resume: {
parents: [],
attributes: ["template"],
roles: [],
attributeValues: { template: templateSchema.options },
},
page: { parents: ["resume"], attributes: ["page-number"], roles: [] },
region: {
parents: ["page"],
attributes: ["placement", "region", "part"],
roles: [],
attributeValues: {
placement: ["main", "sidebar"],
region: ["header", "main", "sidebar", "featured"],
},
},
header: { parents: ["region"], attributes: ["region"], roles: [] },
picture: { parents: ["header"], attributes: [], roles: ["picture"] },
name: { parents: ["header"], attributes: [], roles: ["primary-text"] },
headline: { parents: ["header"], attributes: [], roles: ["secondary-text"] },
"contact-list": { parents: ["header"], attributes: [], roles: [] },
"contact-item": {
parents: ["contact-list"],
attributes: ["name", "part"],
roles: ["primary-text", "secondary-text", "structured-link"],
},
section: {
parents: ["region"],
attributes: ["type", "placement", "origin", "part"],
roles: ["featured-summary"],
attributeValues: {
type: sectionTypeSchema.options,
placement: ["main", "sidebar"],
origin: ["main", "sidebar"],
},
},
"section-heading": { parents: ["section"], attributes: [], roles: ["section-title"] },
"section-items": { parents: ["section"], attributes: [], roles: [] },
item: {
parents: ["section-items", "item"],
attributes: [],
roles: ["experience-role", "nested-role"],
},
"item-header": { parents: ["item"], attributes: ["part"], roles: [] },
"combined-text": {
parents: ["item-header", "template-part"],
attributes: ["name", "owner"],
roles: [],
},
field: {
parents: ["contact-item", "item", "item-header", "combined-text"],
attributes: ["name"],
roles: ["primary-text", "secondary-text", "structured-link"],
},
link: {
parents: ["item", "item-header", "rich-text", "template-part", ...inlineParents],
attributes: [],
roles: ["structured-link"],
},
icon: {
parents: ["contact-item", "section-heading", "item", "item-header", "level"],
attributes: ["type"],
roles: ["decoration", "active", "inactive"],
},
level: { parents: ["item", "item-header"], attributes: [], roles: ["decoration"] },
"rich-text": { parents: ["item", "field"], attributes: [], roles: [] },
"rich-heading": {
parents: ["rich-text"],
attributes: ["level"],
roles: [],
attributeValues: { level: ["1", "2", "3", "4", "5", "6"] },
},
blockquote: { parents: ["rich-text", "list-item-content"], attributes: [], roles: [] },
paragraph: { parents: ["rich-text", "list-item-content"], attributes: [], roles: [] },
list: { parents: ["rich-text", "list-item-content"], attributes: [], roles: [] },
"list-item": { parents: ["list"], attributes: [], roles: [] },
"list-item-content": {
parents: ["list-item"],
attributes: ["direction"],
roles: [],
attributeValues: { direction: ["ltr", "rtl"] },
},
"list-marker": { parents: ["list-item"], attributes: [], roles: ["decoration"] },
strong: { parents: inlineParents, attributes: [], roles: [] },
emphasis: { parents: inlineParents, attributes: [], roles: [] },
underline: { parents: inlineParents, attributes: [], roles: [] },
strike: { parents: inlineParents, attributes: [], roles: [] },
code: { parents: inlineParents, attributes: [], roles: [] },
"text-span": { parents: inlineParents, attributes: [], roles: [] },
mark: { parents: inlineParents, attributes: [], roles: [] },
"hard-break": { parents: inlineParents, attributes: [], roles: [] },
"horizontal-rule": { parents: ["rich-text", "list-item-content"], attributes: [], roles: [] },
"template-part": {
parents: [
"page",
"region",
"header",
"section",
"section-heading",
"section-items",
"item",
"item-header",
"contact-list",
"contact-item",
"template-part",
],
attributes: ["name"],
roles: ["decoration"],
},
} as const satisfies SemanticRegistry;
const templatePartChildKinds = {
"timeline-line": [],
"timeline-marker": ["template-part"],
"timeline-dot": [],
"timeline-content": ["item-header", "field", "link", "item", "level"],
"featured-summary": ["section"],
"sidebar-background": [],
"header-band": ["template-part", "name", "headline"],
"picture-anchor": ["picture"],
"contact-offset": [],
"header-intro": ["template-part"],
"header-body": ["picture", "name", "headline", "section"],
"header-contact-band": ["contact-list"],
"inline-item-header-leading": ["field", "combined-text"],
"inline-item-header-middle": ["field", "link"],
"inline-item-header-trailing": ["field"],
"education-grade-row": ["combined-text"],
"header-divider": ["name", "headline"],
"contact-item-content": ["link", "icon", "field"],
"header-name-rule": [],
"contact-row-primary": ["contact-item"],
"contact-row-secondary": ["contact-item"],
} as const satisfies Readonly<Record<string, readonly SemanticNodeKind[]>>;
for (const childKinds of Object.values(templatePartChildKinds)) Object.freeze(childKinds);
export const TEMPLATE_PART_CHILD_KINDS_V1 = Object.freeze(templatePartChildKinds);
export function canContainNode(parent: SemanticNodeKind, child: SemanticNodeKind, parentPart?: string): boolean {
if (parent === "template-part") {
if (!parentPart || !(parentPart in TEMPLATE_PART_CHILD_KINDS_V1)) return false;
return (
TEMPLATE_PART_CHILD_KINDS_V1[
parentPart as keyof typeof TEMPLATE_PART_CHILD_KINDS_V1
] as readonly SemanticNodeKind[]
).includes(child);
}
return (SEMANTIC_REGISTRY_V1[child].parents as readonly SemanticNodeKind[]).includes(parent);
}
@@ -0,0 +1,69 @@
import { describe, expect, it } from "vitest";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { createSystemVariables, SYSTEM_VARIABLE_REGISTRY_V1 } from "./system-variables";
const baseSettings = {
picture: defaultResumeData.picture,
template: defaultResumeData.metadata.template,
design: defaultResumeData.metadata.design,
typography: defaultResumeData.metadata.typography,
page: defaultResumeData.metadata.page,
layout: defaultResumeData.metadata.layout,
};
describe("system-variable registry", () => {
it("injects builder values without exposing fonts or assets", () => {
const variables = createSystemVariables(baseSettings, { width: 595.28, height: 841.89 });
expect(Object.keys(SYSTEM_VARIABLE_REGISTRY_V1)).toEqual([
"--resume-primary-color",
"--resume-text-color",
"--resume-background-color",
"--resume-body-font-size",
"--resume-body-line-height",
"--resume-heading-font-size",
"--resume-heading-line-height",
"--resume-page-gap-x",
"--resume-page-gap-y",
"--resume-page-margin-x",
"--resume-page-margin-y",
"--resume-page-width",
"--resume-page-height",
"--resume-sidebar-width",
"--resume-picture-size",
"--resume-picture-rotation",
"--resume-picture-aspect-ratio",
"--resume-picture-border-radius",
"--resume-picture-border-width",
"--resume-picture-border-color",
"--resume-picture-shadow-width",
"--resume-picture-shadow-color",
]);
expect(variables).toEqual({
"--resume-primary-color": "rgba(220, 38, 38, 1)",
"--resume-text-color": "rgba(0, 0, 0, 1)",
"--resume-background-color": "rgba(255, 255, 255, 1)",
"--resume-body-font-size": "10pt",
"--resume-body-line-height": "1.5",
"--resume-heading-font-size": "14pt",
"--resume-heading-line-height": "1.5",
"--resume-page-gap-x": "4pt",
"--resume-page-gap-y": "6pt",
"--resume-page-margin-x": "14pt",
"--resume-page-margin-y": "12pt",
"--resume-page-width": "595.28pt",
"--resume-page-height": "841.89pt",
"--resume-sidebar-width": "35%",
"--resume-picture-size": "80pt",
"--resume-picture-rotation": "0deg",
"--resume-picture-aspect-ratio": "1",
"--resume-picture-border-radius": "0pt",
"--resume-picture-border-width": "0pt",
"--resume-picture-border-color": "rgba(0, 0, 0, 0.5)",
"--resume-picture-shadow-width": "0pt",
"--resume-picture-shadow-color": "rgba(0, 0, 0, 0.5)",
});
expect(Object.keys(variables)).not.toContain("--resume-font-family");
expect(Object.keys(variables)).not.toContain("--resume-picture-url");
});
});
@@ -0,0 +1,62 @@
import type { BaseSettingsSnapshot, ResolvedPageDimensions } from "../types";
export type SystemVariableDefinition = {
description: string;
};
export type SystemVariableRegistry = Readonly<Record<string, SystemVariableDefinition>>;
export const SYSTEM_VARIABLE_REGISTRY_V1 = {
"--resume-primary-color": { description: "Builder primary color." },
"--resume-text-color": { description: "Builder text color." },
"--resume-background-color": { description: "Builder background color." },
"--resume-body-font-size": { description: "Builder body font size." },
"--resume-body-line-height": { description: "Builder body line-height multiplier." },
"--resume-heading-font-size": { description: "Builder heading font size." },
"--resume-heading-line-height": { description: "Builder heading line-height multiplier." },
"--resume-page-gap-x": { description: "Builder horizontal page gap." },
"--resume-page-gap-y": { description: "Builder vertical page gap." },
"--resume-page-margin-x": { description: "Builder horizontal page margin." },
"--resume-page-margin-y": { description: "Builder vertical page margin." },
"--resume-page-width": { description: "Resolved authored page width." },
"--resume-page-height": { description: "Resolved authored page height." },
"--resume-sidebar-width": { description: "Builder sidebar width." },
"--resume-picture-size": { description: "Builder picture size." },
"--resume-picture-rotation": { description: "Builder picture rotation." },
"--resume-picture-aspect-ratio": { description: "Builder picture aspect ratio." },
"--resume-picture-border-radius": { description: "Builder picture border radius." },
"--resume-picture-border-width": { description: "Builder picture border width." },
"--resume-picture-border-color": { description: "Builder picture border color." },
"--resume-picture-shadow-width": { description: "Builder picture shadow width." },
"--resume-picture-shadow-color": { description: "Builder picture shadow color." },
} as const satisfies SystemVariableRegistry;
export function createSystemVariables(
base: BaseSettingsSnapshot,
page: ResolvedPageDimensions,
): Readonly<Record<string, string>> {
return {
"--resume-primary-color": base.design.colors.primary,
"--resume-text-color": base.design.colors.text,
"--resume-background-color": base.design.colors.background,
"--resume-body-font-size": `${base.typography.body.fontSize}pt`,
"--resume-body-line-height": `${base.typography.body.lineHeight}`,
"--resume-heading-font-size": `${base.typography.heading.fontSize}pt`,
"--resume-heading-line-height": `${base.typography.heading.lineHeight}`,
"--resume-page-gap-x": `${base.page.gapX}pt`,
"--resume-page-gap-y": `${base.page.gapY}pt`,
"--resume-page-margin-x": `${base.page.marginX}pt`,
"--resume-page-margin-y": `${base.page.marginY}pt`,
"--resume-page-width": `${page.width}pt`,
"--resume-page-height": `${page.height}pt`,
"--resume-sidebar-width": `${base.layout.sidebarWidth}%`,
"--resume-picture-size": `${base.picture.size}pt`,
"--resume-picture-rotation": `${base.picture.rotation}deg`,
"--resume-picture-aspect-ratio": `${base.picture.aspectRatio}`,
"--resume-picture-border-radius": `${base.picture.borderRadius}pt`,
"--resume-picture-border-width": `${base.picture.borderWidth}pt`,
"--resume-picture-border-color": base.picture.borderColor,
"--resume-picture-shadow-width": `${base.picture.shadowWidth}pt`,
"--resume-picture-shadow-color": base.picture.shadowColor,
};
}
@@ -0,0 +1,96 @@
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 }),
);
});
});
@@ -0,0 +1,49 @@
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);
}
@@ -0,0 +1,90 @@
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");
});
});
@@ -0,0 +1,120 @@
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("");
}
@@ -0,0 +1,16 @@
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
describe("semantic selector native ESM compatibility", () => {
it("loads the selector module through Node and tsx", () => {
const entry = new URL("./selector.ts", import.meta.url).href;
const packageRoot = fileURLToPath(new URL("../../..", import.meta.url));
const result = spawnSync("pnpm", ["exec", "tsx", "-e", `import(${JSON.stringify(entry)})`], {
cwd: packageRoot,
encoding: "utf8",
});
expect({ status: result.status, stderr: result.stderr }).toEqual({ status: 0, stderr: "" });
});
});
@@ -0,0 +1,173 @@
import type { SemanticNode } from "./types";
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { compileStylesheet } from "./compile";
import { compileSelector, getSpecificity, matchesSelector } from "./selector";
const experienceId = "229ad766-cb9a-4f16-aaf0-fdd8394a9b95";
const node = (
key: string,
kind: SemanticNode["kind"],
options: Partial<Omit<SemanticNode, "key" | "kind">> = {},
): SemanticNode =>
Object.freeze({
key,
kind,
attributes: Object.freeze(options.attributes ?? {}),
roles: Object.freeze(options.roles ?? []),
children: Object.freeze(options.children ?? []),
...(options.id ? { id: options.id } : {}),
});
const itemFirst = node("item-first", "item", { roles: ["experience-role"] });
const itemSecond = node("item-second", "item", { roles: ["experience-role", "nested-role"] });
const itemThird = node("item-third", "item", { roles: ["experience-role"] });
const experience = node("section-experience", "section", {
id: experienceId,
attributes: { type: "experience", placement: "main", origin: "custom-import" },
children: [
node("heading-experience", "section-heading", { roles: ["section-title"] }),
node("items-experience", "section-items", { children: [itemFirst, itemSecond, itemThird] }),
],
});
const education = node("section-education", "section", {
id: "1abc",
attributes: { type: "education", placement: "main", origin: "native" },
});
const fixtureTree = node("resume", "resume", {
attributes: { template: "rhyhorn" },
children: [
node("page-1", "page", {
attributes: { "page-number": "1" },
children: [
node("region-main", "region", {
attributes: { placement: "main", region: "body" },
children: [experience, education],
}),
node("region-sidebar", "region", {
attributes: { placement: "sidebar", region: "body" },
children: [
node("section-skills-sidebar", "section", {
attributes: { type: "skills", placement: "sidebar", origin: "native" },
}),
],
}),
],
}),
],
});
function matches(source: string, nodeKey: string): boolean {
const result = compileSelector(source);
return result.selector ? matchesSelector(result.selector, fixtureTree, nodeKey) : false;
}
describe("semantic selector compilation", () => {
it.each([
['section[type="experience"] > section-heading', "heading-experience", true],
['region[placement="sidebar"] section', "section-skills-sidebar", true],
['section:is([type="experience"], [type="education"])', "section-education", true],
["item:nth-child(2)", "item-second", true],
["section:hover", "section-experience", false],
["section, item:nth-child(2)", "item-second", true],
["* > page", "page-1", true],
['section[type="experience"] + section', "section-education", true],
['section[type="experience"] ~ section', "section-education", true],
[":root > page", "page-1", true],
['section:not([type="education"])', "section-experience", true],
['section:where([type="education"])', "section-education", true],
["section:is(:where([type='education']))", "section-education", true],
["item:first-child", "item-first", true],
["item:last-child", "item-third", true],
["section:only-child", "section-skills-sidebar", true],
["item:nth-child(2n + 1)", "item-third", true],
["item:nth-of-type(even)", "item-second", true],
])("matches %s against the immutable semantic tree", (selector, nodeKey, expected) => {
expect(matches(selector, nodeKey)).toBe(expected);
});
it("reflects IDs, roles, and lowercase registry attributes with case-sensitive values", () => {
expect(matches(`#${experienceId}`, "section-experience")).toBe(true);
expect(matches(`[id="${experienceId}"]`, "section-experience")).toBe(true);
expect(matches("#\\31 abc", "section-education")).toBe(true);
expect(matches("sect\\69 on[\\74 ype='experience']", "section-experience")).toBe(true);
expect(matches("[role~='nested-role']", "item-second")).toBe(true);
expect(matches('[type="Experience"]', "section-experience")).toBe(false);
expect(matches('[TYPE="experience"]', "section-experience")).toBe(false);
});
it("implements every supported attribute operator", () => {
expect(matches("[type]", "section-experience")).toBe(true);
expect(matches('[type="experience"]', "section-experience")).toBe(true);
expect(matches('[role~="nested-role"]', "item-second")).toBe(true);
expect(matches('[origin|="custom"]', "section-experience")).toBe(true);
expect(matches('[type^="exp"]', "section-experience")).toBe(true);
expect(matches('[type$="ence"]', "section-experience")).toBe(true);
expect(matches('[type*="per"]', "section-experience")).toBe(true);
});
it("gives functional pseudo-classes their Selectors Level 4 specificity", () => {
expect(getSpecificity(":where(#one) section")).toEqual([0, 0, 1]);
expect(getSpecificity(":is(#one, section)")).toEqual([1, 0, 0]);
expect(getSpecificity(":not([type]) section")).toEqual([0, 1, 1]);
expect(getSpecificity("item:nth-child(2 of #one, section)")).toEqual([1, 1, 1]);
});
it.each([
".custom",
"section::before",
"section:hover",
":ROOT",
":IS(section)",
"item:NTH-CHILD(2)",
"section:has(item)",
"unknown-element",
"[unknown]",
"[TYPE]",
'[role~="unknown-role"]',
'section[role~="primary-text"]',
"page[type]",
'[type="experience" i]',
"svg|section",
])("rejects unsupported or unknown selector %s", (selector) => {
expect(compileSelector(selector).selector).toBeNull();
});
it("enforces selector resource limits", () => {
expect(compileSelector(new Array(66).fill("section").join(",")).selector).toBeNull();
expect(compileSelector(new Array(19).fill("resume").join(" > ")).selector).toBeNull();
expect(compileSelector(`${" ".repeat(2_042)}section`).selector).toBeNull();
expect(compileSelector(`${"😀".repeat(2_042)}section`).selector).toBeNull();
expect(compileSelector(`${":is(".repeat(17)}section${")".repeat(17)}`).selector).toBeNull();
});
it("keeps compiled selectors cloneable and leaves source order untouched", () => {
const result = compileSelector("item:nth-child(2)");
expect(result.selector).not.toBeNull();
expect(() => structuredClone(result.selector)).not.toThrow();
expect(matches("item:nth-child(2)", "item-second")).toBe(true);
expect(experience.children[1]?.children).toEqual([itemFirst, itemSecond, itemThird]);
});
it("does not treat the parentless resume root as a structural child", () => {
expect(matches("resume:first-child", "resume")).toBe(false);
expect(matches("resume:last-child", "resume")).toBe(false);
expect(matches("resume:only-child", "resume")).toBe(false);
});
it("validates selectors while compiling a stylesheet", () => {
const fixture = readFileSync(new URL("./__fixtures__/v1/selectors.css", import.meta.url), "utf8");
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.diagnostics).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.diagnostics).toContainEqual(expect.objectContaining({ code: "INVALID_SELECTOR" }));
});
});
+449
View File
@@ -0,0 +1,449 @@
import type { CssNode } from "css-tree";
import type { SemanticNode } from "./types";
import SpecificityCalculator from "@bramus/specificity";
import * as csstree from "css-tree";
import { SEMANTIC_CSS_LIMITS_V1 } from "./limits";
import { SEMANTIC_NODE_KINDS, SEMANTIC_REGISTRY_V1 } from "./registry/semantic";
export type Specificity = readonly [ids: number, classes: number, types: number];
type Combinator = " " | ">" | "+" | "~";
type AttributeMatcher = "=" | "~=" | "|=" | "^=" | "$=" | "*=";
type CompiledSimpleSelector =
| { type: "universal" }
| { type: "type"; name: SemanticNode["kind"] }
| { type: "id"; value: string }
| { type: "attribute"; name: string; matcher: AttributeMatcher | null; value: string | null }
| {
type: "pseudo";
name: "is" | "where" | "not";
selectors: readonly CompiledComplexSelector[];
}
| { type: "pseudo"; name: "root" | "first-child" | "last-child" | "only-child" }
| {
type: "pseudo";
name: "nth-child" | "nth-of-type";
a: number;
b: number;
of?: readonly CompiledComplexSelector[];
};
type CompiledCompoundSelector = {
selectors: readonly CompiledSimpleSelector[];
};
type CompiledComplexSelector = {
compounds: readonly CompiledCompoundSelector[];
combinators: readonly Combinator[];
specificity: Specificity;
};
export type CompiledSelector = {
selectors: readonly CompiledComplexSelector[];
};
export type CompileSelectorResult = {
selector: CompiledSelector | null;
error?: string;
};
type SelectorAst = {
type: string;
name?: string | { name?: string };
children?: Iterable<SelectorAst> | null;
matcher?: string | null;
value?: { name?: string; value?: string } | null;
flags?: string | null;
nth?: SelectorAst;
selector?: SelectorAst | null;
a?: string | null;
b?: string | null;
};
type CompileContext = {
depth: number;
};
type TreeNode = {
node: SemanticNode;
parent: TreeNode | null;
children: TreeNode[];
};
const knownKinds = new Set<string>(SEMANTIC_NODE_KINDS);
const knownAttributes = new Set([
"id",
"role",
...Object.values(SEMANTIC_REGISTRY_V1).flatMap((definition) => definition.attributes),
]);
const knownRoles = new Set(Object.values(SEMANTIC_REGISTRY_V1).flatMap((definition) => definition.roles));
function childrenOf(node: SelectorAst): SelectorAst[] {
return node.children ? [...node.children] : [];
}
function identifier(value: string): string {
return csstree.ident.decode(value);
}
function astName(node: SelectorAst): string {
if (typeof node.name === "string") return identifier(node.name);
if (node.name && typeof node.name.name === "string") return identifier(node.name.name);
throw new Error("Selector name is missing.");
}
function attributeValue(node: SelectorAst): string | null {
if (!node.value) return null;
if (typeof node.value.value === "string") return node.value.value;
if (typeof node.value.name === "string") return identifier(node.value.name);
throw new Error("Attribute value is missing.");
}
function allowedRoles(type: SemanticNode["kind"] | null): ReadonlySet<string> {
return type ? new Set(SEMANTIC_REGISTRY_V1[type].roles) : knownRoles;
}
function roleValueIsKnown(matcher: AttributeMatcher | null, value: string | null, roles: ReadonlySet<string>): boolean {
if (!matcher || value === null) return roles.size > 0;
if (matcher === "~=") return roles.has(value);
if (matcher === "=") {
const tokens = value.split(/\s+/).filter(Boolean);
return tokens.length > 0 && tokens.every((token) => roles.has(token));
}
return [...roles].some((role) => matchesAttribute(role, matcher, value));
}
function validateCompound(selectors: readonly CompiledSimpleSelector[]): void {
const types = selectors.filter(
(selector): selector is Extract<CompiledSimpleSelector, { type: "type" }> => selector.type === "type",
);
if (types.length > 1) throw new Error("A compound selector can contain only one type selector.");
const type = types[0]?.name ?? null;
for (const selector of selectors) {
if (selector.type !== "attribute") continue;
if (selector.name === "id") continue;
if (selector.name === "role") {
if (!roleValueIsKnown(selector.matcher, selector.value, allowedRoles(type))) {
throw new Error("Selector uses an unknown role.");
}
continue;
}
if (type && !(SEMANTIC_REGISTRY_V1[type].attributes as readonly string[]).includes(selector.name)) {
throw new Error(`Attribute ${selector.name} is not available on ${type}.`);
}
}
}
function compileNth(
node: SelectorAst,
name: "nth-child" | "nth-of-type",
context: CompileContext,
): CompiledSimpleSelector {
const nth = childrenOf(node);
if (nth.length !== 1 || nth[0]?.type !== "Nth" || !nth[0].nth) {
throw new Error(`:${name} requires one An+B expression.`);
}
const expression = nth[0].nth;
let a = 0;
let b = 0;
if (expression.type === "Identifier") {
const keyword = astName(expression).toLowerCase();
if (keyword === "odd") {
a = 2;
b = 1;
} else if (keyword === "even") {
a = 2;
} else {
throw new Error(`Unsupported :${name} expression.`);
}
} else if (expression.type === "AnPlusB") {
a = expression.a === null || expression.a === undefined ? 0 : Number(expression.a);
b = expression.b === null || expression.b === undefined ? 0 : Number(expression.b);
if (!Number.isInteger(a) || !Number.isInteger(b)) throw new Error(`Invalid :${name} expression.`);
} else {
throw new Error(`Unsupported :${name} expression.`);
}
if (name === "nth-of-type" && nth[0].selector) throw new Error(":nth-of-type does not accept an of selector.");
const of = nth[0].selector ? compileSelectorList(nth[0].selector, { depth: context.depth + 1 }) : undefined;
return { type: "pseudo", name, a, b, ...(of ? { of } : {}) };
}
function compileSimple(node: SelectorAst, context: CompileContext): CompiledSimpleSelector | null {
switch (node.type) {
case "TypeSelector": {
const name = astName(node);
if (name === "*") return { type: "universal" };
if (!knownKinds.has(name)) throw new Error(`Unknown semantic element ${name}.`);
return { type: "type", name: name as SemanticNode["kind"] };
}
case "IdSelector":
return { type: "id", value: astName(node) };
case "AttributeSelector": {
const name = astName(node);
if (!knownAttributes.has(name)) throw new Error(`Unknown semantic attribute ${name}.`);
if (node.flags) throw new Error("Attribute selector flags are not supported.");
const matcher = node.matcher as AttributeMatcher | null | undefined;
if (matcher !== null && matcher !== undefined && !["=", "~=", "|=", "^=", "$=", "*="].includes(matcher)) {
throw new Error(`Unsupported attribute matcher ${matcher}.`);
}
const value = attributeValue(node);
if ((matcher === null || matcher === undefined) !== (value === null)) {
throw new Error("Attribute selector matcher and value must be used together.");
}
return { type: "attribute", name, matcher: matcher ?? null, value };
}
case "PseudoClassSelector": {
const name = astName(node);
if (["root", "first-child", "last-child", "only-child"].includes(name)) {
if (node.children !== null && node.children !== undefined)
throw new Error(`:${name} does not accept arguments.`);
return { type: "pseudo", name: name as "root" | "first-child" | "last-child" | "only-child" };
}
if (["is", "where", "not"].includes(name)) {
if (context.depth >= SEMANTIC_CSS_LIMITS_V1.maxFunctionDepth) {
throw new Error("Selector function nesting is too deep.");
}
const nested = childrenOf(node);
if (nested.length !== 1 || nested[0]?.type !== "SelectorList") {
throw new Error(`:${name} requires a selector list.`);
}
const selectors = compileSelectorList(nested[0], { depth: context.depth + 1 });
return { type: "pseudo", name: name as "is" | "where" | "not", selectors };
}
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.");
}
return compileNth(node, name, context);
}
throw new Error(`Unsupported pseudo-class :${name}.`);
}
case "ClassSelector":
throw new Error("Custom class selectors are not supported.");
case "PseudoElementSelector":
throw new Error("Pseudo-elements are not supported.");
default:
throw new Error(`Unsupported selector node ${node.type}.`);
}
}
function compileComplex(node: SelectorAst, context: CompileContext): CompiledComplexSelector {
if (node.type !== "Selector") throw new Error("Expected a Selector AST.");
const compounds: CompiledCompoundSelector[] = [];
const combinators: Combinator[] = [];
let selectors: CompiledSimpleSelector[] = [];
for (const child of childrenOf(node)) {
if (child.type === "Combinator") {
const name = astName(child) as Combinator;
if (![" ", ">", "+", "~"].includes(name) || selectors.length === 0) {
throw new Error("Unsupported or misplaced combinator.");
}
validateCompound(selectors);
compounds.push({ selectors });
selectors = [];
combinators.push(name);
if (combinators.length > SEMANTIC_CSS_LIMITS_V1.maxCombinatorsPerSelector) {
throw new Error("Selector has too many combinators.");
}
continue;
}
const selector = compileSimple(child, context);
if (selector) selectors.push(selector);
}
if (selectors.length === 0 && compounds.length > 0) throw new Error("Selector cannot end with a combinator.");
validateCompound(selectors);
compounds.push({ selectors });
const specificity = SpecificityCalculator.calculateForAST(node).toArray();
return { compounds, combinators, specificity: [specificity[0], specificity[1], specificity[2]] };
}
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) {
throw new Error("Selector list has an unsupported number of selectors.");
}
return selectors.map((selector) => compileComplex(selector, context));
}
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.");
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." };
}
}
export function getSpecificity(_source: string): Specificity | null {
return compileSelector(_source).selector?.selectors[0]?.specificity ?? null;
}
function buildTree(root: SemanticNode): Map<string, TreeNode> {
const nodes = new Map<string, TreeNode>();
const rootNode: TreeNode = { node: root, parent: null, children: [] };
const stack = [{ source: root, target: rootNode }];
nodes.set(root.key, rootNode);
while (stack.length > 0) {
const current = stack.pop();
if (!current) break;
current.target.children = current.source.children.map((child) => {
const target: TreeNode = { node: child, parent: current.target, children: [] };
nodes.set(child.key, target);
return target;
});
for (let index = current.source.children.length - 1; index >= 0; index--) {
const source = current.source.children[index];
const target = current.target.children[index];
if (source && target) stack.push({ source, target });
}
}
return nodes;
}
function attribute(node: SemanticNode, name: string): string | undefined {
if (name === "id") return node.id;
if (name === "role") return node.roles.length > 0 ? node.roles.join(" ") : undefined;
return Object.hasOwn(node.attributes, name) ? node.attributes[name] : undefined;
}
function matchesAttribute(actual: string, matcher: AttributeMatcher, expected: string): boolean {
switch (matcher) {
case "=":
return actual === expected;
case "~=":
return expected !== "" && actual.split(/\s+/).includes(expected);
case "|=":
return expected !== "" && (actual === expected || actual.startsWith(`${expected}-`));
case "^=":
return expected !== "" && actual.startsWith(expected);
case "$=":
return expected !== "" && actual.endsWith(expected);
case "*=":
return expected !== "" && actual.includes(expected);
}
}
function nthMatches(index: number, a: number, b: number): boolean {
if (a === 0) return index === b;
const n = (index - b) / a;
return Number.isInteger(n) && n >= 0;
}
function siblings(target: TreeNode): readonly TreeNode[] {
return target.parent?.children ?? [];
}
function matchesSimple(selector: CompiledSimpleSelector, target: TreeNode): boolean {
switch (selector.type) {
case "universal":
return true;
case "type":
return target.node.kind === selector.name;
case "id":
return target.node.id === selector.value;
case "attribute": {
const actual = attribute(target.node, selector.name);
if (actual === undefined) return false;
if (!selector.matcher || selector.value === null) return true;
return matchesAttribute(actual, selector.matcher, selector.value);
}
case "pseudo":
switch (selector.name) {
case "root":
return target.parent === null && target.node.kind === "resume";
case "first-child":
return siblings(target)[0] === target;
case "last-child": {
const values = siblings(target);
return values[values.length - 1] === target;
}
case "only-child":
return siblings(target).length === 1;
case "is":
case "where":
return selector.selectors.some((nested) => matchesComplex(nested, target));
case "not":
return selector.selectors.every((nested) => !matchesComplex(nested, target));
case "nth-child":
case "nth-of-type": {
let values = siblings(target);
if (selector.name === "nth-of-type") {
values = values.filter((sibling) => sibling.node.kind === target.node.kind);
}
if (selector.of)
values = values.filter((sibling) => selector.of?.some((nested) => matchesComplex(nested, sibling)));
const index = values.indexOf(target);
return index >= 0 && nthMatches(index + 1, selector.a, selector.b);
}
}
}
}
function matchesCompound(compound: CompiledCompoundSelector, target: TreeNode): boolean {
return compound.selectors.every((selector) => matchesSimple(selector, target));
}
function matchesComplexAt(selector: CompiledComplexSelector, compoundIndex: number, target: TreeNode): boolean {
const compound = selector.compounds[compoundIndex];
if (!compound || !matchesCompound(compound, target)) return false;
if (compoundIndex === 0) return true;
switch (selector.combinators[compoundIndex - 1]) {
case ">":
return target.parent ? matchesComplexAt(selector, compoundIndex - 1, target.parent) : false;
case " ": {
let ancestor = target.parent;
while (ancestor) {
if (matchesComplexAt(selector, compoundIndex - 1, ancestor)) return true;
ancestor = ancestor.parent;
}
return false;
}
case "+": {
const values = siblings(target);
const index = values.indexOf(target);
return index > 0 ? matchesComplexAt(selector, compoundIndex - 1, values[index - 1] as TreeNode) : false;
}
case "~": {
const values = siblings(target);
const index = values.indexOf(target);
return values.slice(0, index).some((sibling) => matchesComplexAt(selector, compoundIndex - 1, sibling));
}
default:
return false;
}
}
function matchesComplex(selector: CompiledComplexSelector, target: TreeNode): boolean {
return matchesComplexAt(selector, selector.compounds.length - 1, target);
}
export function createSelectorMatcher(root: SemanticNode): (selector: CompiledSelector, nodeKey: string) => boolean {
const nodes = buildTree(root);
return (selector, nodeKey) => {
const target = nodes.get(nodeKey);
return target ? selector.selectors.some((complex) => matchesComplex(complex, target)) : false;
};
}
export function matchesSelector(selector: CompiledSelector, root: SemanticNode, nodeKey: string): boolean {
return createSelectorMatcher(root)(selector, nodeKey);
}
@@ -0,0 +1,47 @@
export type SemanticNodeKind =
| "resume"
| "page"
| "region"
| "header"
| "picture"
| "name"
| "headline"
| "contact-list"
| "contact-item"
| "section"
| "section-heading"
| "section-items"
| "item"
| "item-header"
| "combined-text"
| "field"
| "link"
| "icon"
| "level"
| "rich-text"
| "rich-heading"
| "blockquote"
| "paragraph"
| "list"
| "list-item"
| "list-item-content"
| "list-marker"
| "strong"
| "emphasis"
| "underline"
| "strike"
| "code"
| "text-span"
| "mark"
| "hard-break"
| "horizontal-rule"
| "template-part";
export type SemanticNode = {
key: string;
kind: SemanticNodeKind;
id?: string;
attributes: Readonly<Record<string, string>>;
roles: readonly string[];
children: readonly SemanticNode[];
};
@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import { escapeCssComment, escapeCssString, serializeGeneratedStylesheet } from "./serialize";
describe("generated Semantic CSS serialization", () => {
it("serializes generated Semantic CSS deterministically and safely", () => {
const output = serializeGeneratedStylesheet({
languageVersion: 1,
blocks: [
{
comment: "Bad */ label",
selector: 'section[id="projects"] > section-heading',
declarations: { fontSize: "12pt", color: "#123456" },
},
],
});
expect(output).toBe(
'@version 1;\n\n/* Bad *\\/ label */\nsection[id="projects"] > section-heading {\n\tcolor: #123456;\n\tfont-size: 12pt;\n}\n',
);
});
it("escapes labels and strings without creating CSS delimiters", () => {
expect(escapeCssComment("*/ next")).toBe("*\\/ next");
expect(escapeCssString('"\\\n')).toBe('"\\"\\\\\\a "');
});
it("rejects generated declarations that could escape their block", () => {
expect(() =>
serializeGeneratedStylesheet({
languageVersion: 1,
blocks: [{ selector: "field", declarations: { color: "red; page { color: blue" } }],
}),
).toThrow("unsafe CSS declaration value");
});
});
@@ -0,0 +1,47 @@
import { escapeCssComment, escapeCssIdentifier } from "./css-escape";
import { compileSelector } from "./selector";
export { escapeCssComment, escapeCssString } from "./css-escape";
export type GeneratedStylesheet = {
languageVersion: number;
blocks: readonly GeneratedStylesheetBlock[];
};
export type GeneratedStylesheetBlock = {
comment?: string;
selector: string;
declarations: Readonly<Record<string, string | number>>;
};
function kebabCase(value: string): string {
return value.replace(/[A-Z]/g, (character) => `-${character.toLowerCase()}`);
}
function escapeCssValue(value: string | number): string {
const text = String(value).trim();
if (text.length === 0 || /[\0;{}]|\/\*/.test(text)) throw new Error("unsafe CSS declaration value");
return text;
}
export function serializeGeneratedStylesheet(stylesheet: GeneratedStylesheet): string {
if (!Number.isSafeInteger(stylesheet.languageVersion) || stylesheet.languageVersion < 1) {
throw new Error("Semantic CSS language version must be a positive integer");
}
const blocks = stylesheet.blocks.map((block) => {
const selector = compileSelector(block.selector);
if (!selector.selector) throw new Error(`unsafe generated Semantic CSS selector: ${selector.error}`);
const declarations = Object.entries(block.declarations)
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
.map(([property, value]) => `\t${escapeCssIdentifier(kebabCase(property))}: ${escapeCssValue(value)};`)
.join("\n");
if (declarations.length === 0) throw new Error("generated Semantic CSS blocks require at least one declaration");
const comment = block.comment === undefined ? "" : `/* ${escapeCssComment(block.comment)} */\n`;
return `${comment}${block.selector} {\n${declarations}\n}`;
});
return `@version ${stylesheet.languageVersion};\n${blocks.length > 0 ? `\n${blocks.join("\n\n")}\n` : ""}`;
}
+12
View File
@@ -0,0 +1,12 @@
declare module "@bramus/specificity" {
type CalculatedSpecificity = {
toArray(): [number, number, number];
};
// biome-ignore lint/complexity/noStaticOnlyClass: mirrors the installed dependency's default export
class Specificity {
static calculateForAST(selector: object): CalculatedSpecificity;
}
export default Specificity;
}
+136
View File
@@ -0,0 +1,136 @@
import type { Design, Layout, Page, ResumeData, Typography } from "@reactive-resume/schema/resume/data";
import type { Template } from "@reactive-resume/schema/templates";
import type { SemanticNode } from "./semantic-types";
export type { SemanticNode, SemanticNodeKind } from "./semantic-types";
export type DiagnosticSeverity = "error" | "warning";
export type SourcePosition = {
line: number;
column: number;
offset: number;
};
export type SourceRange = {
start: SourcePosition;
end: SourcePosition;
};
export type SemanticCssDiagnostic = {
code: string;
severity: DiagnosticSeverity;
message: string;
range: SourceRange;
};
export type StyleProgram = {
languageVersion: number;
rules: readonly CompiledStyleRule[];
};
export type CompiledDeclaration = {
property: string;
value: string;
important: boolean;
sourceOrder: number;
range: SourceRange;
};
export type MediaFeature =
| {
name: "width" | "height";
comparison: "equal" | "min" | "max";
value: string;
}
| {
name: "orientation";
value: "landscape" | "portrait";
};
export type CompiledMediaQuery = {
features: readonly MediaFeature[];
};
export type CompiledStyleRule = {
selector: import("./selector").CompiledSelector;
declarations: readonly CompiledDeclaration[];
media: readonly CompiledMediaQuery[];
range: SourceRange;
};
export type BaseSettingsSnapshot = Pick<ResumeData, "picture"> & {
template: Template;
design: Design;
typography: Typography;
page: Page;
layout: Pick<Layout, "sidebarWidth">;
};
export type AuthoredPageContext = {
pageKey: string;
width: number;
height: number;
};
export type ResolvedNodeStyle = {
style: Readonly<Record<string, string | number>>;
specifiedStyleProperties?: readonly string[];
hostBaseStyleProperties?: readonly string[];
structural: StructuralPresentation;
hidden: boolean;
order: number;
};
export type ResolvedPageSize = "A4" | "LETTER" | { width: number; height?: number };
export type StructuralPresentation = {
breakBefore?: "page";
breakInside?: "avoid";
fixed?: boolean;
minPresenceAhead?: number;
orphans?: number;
widows?: number;
pageSize?: ResolvedPageSize;
};
export type ResolveStylesheetInput = {
program: StyleProgram;
tree: SemanticNode;
baseStyles: Readonly<Record<string, ResolvedNodeStyle>>;
baseSettings: BaseSettingsSnapshot;
pages: readonly AuthoredPageContext[];
aliases?: Readonly<Record<string, readonly string[]>>;
};
export type ResolveStylesheetContext = Omit<ResolveStylesheetInput, "program" | "tree">;
export type ResolveStylesheetResult = {
nodes: Readonly<Record<string, ResolvedNodeStyle>>;
renderTree: SemanticNode;
diagnostics: readonly SemanticCssDiagnostic[];
};
export type ResolvedPageDimensions = {
width: number;
height: number;
};
export type ParsedAtRule = {
name: string;
prelude: string;
hasBlock: boolean;
range: SourceRange;
};
export type ParsedStylesheet = {
ast: unknown;
atRules: readonly ParsedAtRule[];
rules: readonly unknown[];
diagnostics: readonly SemanticCssDiagnostic[];
};
export type CompileStylesheetResult = {
program: StyleProgram | null;
diagnostics: readonly SemanticCssDiagnostic[];
};
@@ -0,0 +1,446 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import fc from "fast-check";
import { StylesheetCompilationCache, stylesheetCacheKey } from "./cache";
import { compileStylesheet } from "./compile";
import { SEMANTIC_CSS_LIMITS_V1 } from "./limits";
function escapedIdentifier(identifier: string, escaped: readonly boolean[], uppercase: readonly boolean[]): string {
return [...identifier]
.map((character, index) => {
const cased = uppercase[index] ? character.toUpperCase() : character;
return escaped[index] ? `\\${cased.codePointAt(0)?.toString(16)} ` : cased;
})
.join("");
}
function mediaList(count: number): string {
return Array.from({ length: count }, (_, index) => `(min-width: ${index + 1}pt)`).join(",");
}
function legacyFingerprint(source: string): string {
let hash = 2_166_136_261;
for (let index = 0; index < source.length; index++) {
hash ^= source.charCodeAt(index);
hash = Math.imul(hash, 16_777_619);
}
return `${hash >>> 0}:${source.length}`;
}
function expectedSides(tokens: readonly number[]): readonly [number, number, number, number] {
switch (tokens.length) {
case 1:
return [tokens[0] as number, tokens[0] as number, tokens[0] as number, tokens[0] as number];
case 2:
return [tokens[0] as number, tokens[1] as number, tokens[0] as number, tokens[1] as number];
case 3:
return [tokens[0] as number, tokens[1] as number, tokens[2] as number, tokens[1] as number];
default:
return [tokens[0] as number, tokens[1] as number, tokens[2] as number, tokens[3] as number];
}
}
describe("Semantic CSS value compilation", () => {
it("compiles and caches the portable version-one fixture as plain data", () => {
const text = readFileSync(new URL("./__fixtures__/v1/portable-theme.css", import.meta.url), "utf8");
const first = compileStylesheet({ languageVersion: 1, text });
const second = compileStylesheet({ languageVersion: 1, text });
expect(first.program).not.toBeNull();
expect(second.program).toBe(first.program);
expect(() => structuredClone(first.program)).not.toThrow();
});
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.diagnostics).toContainEqual(
expect.objectContaining({ code: "SYSTEM_VARIABLE_READONLY", severity: "error" }),
);
});
it("revalidates custom-property values so forbidden functions cannot hide", () => {
for (const value of ["url('https://example.com/x')", "URL(x)", "u\\72l(x)"]) {
const result = compileStylesheet({
languageVersion: 1,
text: `@version 1; :root { --asset: ${value}; } picture { background-color: var(--asset); }`,
});
expect(result.program, value).toBeNull();
expect(result.diagnostics, value).toContainEqual(
expect.objectContaining({ code: "FORBIDDEN_CSS_VALUE", severity: "error" }),
);
}
});
it("allows technically renderable values and warns about extreme aesthetics", () => {
const result = compileStylesheet({
languageVersion: 1,
text: "@version 1; field { font-size: 3pt; }",
});
expect(result.program).not.toBeNull();
expect(result.diagnostics).toContainEqual(expect.objectContaining({ code: "EXTREME_VALUE", severity: "warning" }));
});
it("rejects non-finite or technically unrenderable absolute lengths", () => {
for (const value of ["100001pt", "1e309pt"]) {
const result = compileStylesheet({
languageVersion: 1,
text: `@version 1; field { margin-top: ${value}; }`,
});
expect(result.program, value).toBeNull();
expect(result.diagnostics, value).toContainEqual(
expect.objectContaining({ code: "INVALID_VALUE", severity: "error" }),
);
}
});
it.each(["auto", "none", "normal", "max-content", "min-content", "fit-content", "thin", "medium", "thick"])(
"preserves the accepted generic length keyword %s when reference hints are narrower",
(value) => {
const result = compileStylesheet({
languageVersion: 1,
text: `@version 1; section { -resume-min-presence-ahead: ${value}; }`,
});
expect(result.program).not.toBeNull();
expect(result.diagnostics.filter(({ severity }) => severity === "error")).toEqual([]);
},
);
it.each(["none", "hidden", "double", "groove", "ridge", "inset", "outset"])(
"rejects the undocumented border style %s",
(value) => {
const result = compileStylesheet({
languageVersion: 1,
text: `@version 1; section { border-style: ${value}; }`,
});
expect(result.program).toBeNull();
expect(result.diagnostics).toContainEqual(expect.objectContaining({ code: "INVALID_VALUE", severity: "error" }));
},
);
it.each([
{
name: "source bytes",
exact: (() => {
const prefix = "@version 1;";
return prefix + " ".repeat(SEMANTIC_CSS_LIMITS_V1.maxSourceBytes - new TextEncoder().encode(prefix).byteLength);
})(),
oneOver: (() => {
const prefix = "@version 1;";
return `${prefix}${" ".repeat(SEMANTIC_CSS_LIMITS_V1.maxSourceBytes - new TextEncoder().encode(prefix).byteLength)} `;
})(),
expectedRules: 0,
expectedDeclarations: undefined,
},
{
name: "rule count",
exact: `@version 1;${"field{color:red}".repeat(SEMANTIC_CSS_LIMITS_V1.maxRules)}`,
oneOver: `@version 1;${"field{color:red}".repeat(SEMANTIC_CSS_LIMITS_V1.maxRules + 1)}`,
expectedRules: SEMANTIC_CSS_LIMITS_V1.maxRules,
expectedDeclarations: 1,
},
{
name: "declaration count",
exact: `@version 1;field{${"color:red;".repeat(SEMANTIC_CSS_LIMITS_V1.maxDeclarations)}}`,
oneOver: `@version 1;field{${"color:red;".repeat(SEMANTIC_CSS_LIMITS_V1.maxDeclarations + 1)}}`,
expectedRules: 1,
expectedDeclarations: SEMANTIC_CSS_LIMITS_V1.maxDeclarations,
},
{
name: "function nesting",
exact: `@version 1;field{color:${"rgb(".repeat(SEMANTIC_CSS_LIMITS_V1.maxFunctionDepth)}0${")".repeat(SEMANTIC_CSS_LIMITS_V1.maxFunctionDepth)}}`,
oneOver: `@version 1;field{color:${"rgb(".repeat(SEMANTIC_CSS_LIMITS_V1.maxFunctionDepth + 1)}0${")".repeat(SEMANTIC_CSS_LIMITS_V1.maxFunctionDepth + 1)}}`,
expectedRules: 1,
expectedDeclarations: 1,
},
{
name: "media nesting",
exact: `@version 1;${"@media (width: 1pt){".repeat(SEMANTIC_CSS_LIMITS_V1.maxMediaNesting)}field{color:red}${"}".repeat(SEMANTIC_CSS_LIMITS_V1.maxMediaNesting)}`,
oneOver: `@version 1;${"@media (width: 1pt){".repeat(SEMANTIC_CSS_LIMITS_V1.maxMediaNesting + 1)}field{color:red}${"}".repeat(SEMANTIC_CSS_LIMITS_V1.maxMediaNesting + 1)}`,
expectedRules: 1,
expectedDeclarations: 1,
},
])(
"accepts the exact $name limit and rejects one over",
({ exact, oneOver, expectedRules, expectedDeclarations }) => {
const accepted = compileStylesheet({ languageVersion: 1, text: exact });
expect(accepted.program?.rules).toHaveLength(expectedRules);
if (expectedDeclarations !== undefined) {
expect(accepted.program?.rules[0]?.declarations).toHaveLength(expectedDeclarations);
}
const rejected = compileStylesheet({ languageVersion: 1, text: oneOver });
expect(rejected.program).toBeNull();
expect(rejected.diagnostics).toContainEqual(
expect.objectContaining({ code: "RESOURCE_LIMIT", severity: "error" }),
);
},
);
it("bounds the Cartesian product of nested media lists before construction", () => {
fc.assert(
fc.property(fc.integer({ min: 33, max: 40 }), (branchCount) => {
const queries = mediaList(branchCount);
const result = compileStylesheet({
languageVersion: 1,
text: `@version 1;@media ${queries}{@media ${queries}{field{color:red}}}`,
});
expect(result.program).toBeNull();
expect(result.diagnostics).toContainEqual(
expect.objectContaining({ code: "RESOURCE_LIMIT", severity: "error" }),
);
}),
{ numRuns: 8 },
);
});
it("rejects invalid trailing flex tokens instead of silently ignoring them", () => {
fc.assert(
fc.property(fc.integer({ min: 0, max: 10 }), fc.stringMatching(/^[a-z]{1,6}$/), (grow, trailing) => {
const result = compileStylesheet({
languageVersion: 1,
text: `@version 1;section{flex:${grow} auto ${trailing}}`,
});
expect(result.program).toBeNull();
expect(result.diagnostics).toContainEqual(
expect.objectContaining({ code: "INVALID_VALUE", severity: "error" }),
);
}),
{ numRuns: 20 },
);
});
it("accepts CSS-wide keywords only as whole shorthand declarations", () => {
const accepted = compileStylesheet({
languageVersion: 1,
text: "@version 1;section{flex-flow:inherit}",
});
expect(accepted.program).not.toBeNull();
for (const declaration of [
"flex-flow:row inherit",
"flex:1 inherit",
"gap:1pt inherit",
"margin:1pt inherit",
"border:1pt solid inherit",
"border-style:solid inherit",
]) {
const rejected = compileStylesheet({
languageVersion: 1,
text: `@version 1;section{${declaration}}`,
});
expect(rejected.program, declaration).toBeNull();
expect(rejected.diagnostics, declaration).toContainEqual(
expect.objectContaining({ code: "INVALID_VALUE", severity: "error" }),
);
}
});
it("preserves CSS-wide identifiers inside custom-property token streams", () => {
const result = compileStylesheet({
languageVersion: 1,
text: "@version 1;:root{--tokens:row inherit}",
});
expect(result.program).not.toBeNull();
expect(result.program?.rules[0]?.declarations).toContainEqual(
expect.objectContaining({ property: "--tokens", value: "row inherit" }),
);
expect(result.diagnostics.filter(({ severity }) => severity === "error")).toEqual([]);
});
it("expands generated one-to-four-token shorthands with CSS side semantics", () => {
fc.assert(
fc.property(
fc.constantFrom("margin", "padding", "border-width"),
fc.array(fc.integer({ min: 0, max: 100 }), { minLength: 1, maxLength: 4 }),
(property, tokens) => {
const values = tokens.map((token) => `${token}pt`).join(" ");
const result = compileStylesheet({
languageVersion: 1,
text: `@version 1;section{${property}:${values}}`,
});
if (!result.program) throw new Error(result.diagnostics.map(({ code }) => code).join(","));
const [top, right, bottom, left] = expectedSides(tokens);
const expected =
property === "border-width"
? {
"border-top-width": `${top}pt`,
"border-right-width": `${right}pt`,
"border-bottom-width": `${bottom}pt`,
"border-left-width": `${left}pt`,
}
: {
[`${property}-top`]: `${top}pt`,
[`${property}-right`]: `${right}pt`,
[`${property}-bottom`]: `${bottom}pt`,
[`${property}-left`]: `${left}pt`,
};
expect(
Object.fromEntries(
(result.program.rules[0]?.declarations ?? []).map(({ property: name, value }) => [name, value]),
),
).toEqual(expected);
},
),
{ numRuns: 60 },
);
});
it("uses exact source text in cache keys even when the legacy fingerprints collide", () => {
const first = "s0@h,]UQ";
const second = "b(tT0e7(";
expect(legacyFingerprint(first)).toBe(legacyFingerprint(second));
expect(stylesheetCacheKey(1, first, "registry")).not.toBe(stylesheetCacheKey(1, second, "registry"));
});
it("uses a bounded least-recently-used cache by entry count and aggregate bytes", () => {
const cache = new StylesheetCompilationCache();
const result = compileStylesheet({ languageVersion: 1, text: "@version 1;" });
cache.set("first", result);
for (let index = 0; index < 128; index++) cache.set(`next-${index}`, result);
expect(cache.get("first")).toBeUndefined();
expect(cache.get("next-0")).toBe(result);
cache.set("last", result);
expect(cache.get("next-1")).toBeUndefined();
cache.set("oversized", {
program: null,
diagnostics: [
{
code: "LARGE",
severity: "warning",
message: "x".repeat(16 * 1024 * 1024),
range: {
start: { line: 1, column: 1, offset: 0 },
end: { line: 1, column: 1, offset: 0 },
},
},
],
});
expect(cache.get("oversized")).toBeUndefined();
const large = (code: string) => ({
program: null,
diagnostics: [
{
code,
severity: "warning" as const,
message: "x".repeat(9 * 1024 * 1024),
range: {
start: { line: 1, column: 1, offset: 0 },
end: { line: 1, column: 1, offset: 0 },
},
},
],
});
cache.set("large-first", large("FIRST"));
cache.set("large-second", large("SECOND"));
expect(cache.get("large-first")).toBeUndefined();
expect(cache.get("large-second")).toBeDefined();
});
it("never throws for malformed Unicode or case/escape-varied attack values", () => {
fc.assert(
fc.property(
fc.string({ unit: fc.integer({ min: 0, max: 0xffff }).map((codeUnit) => String.fromCharCode(codeUnit)) }),
(body) => {
expect(() => compileStylesheet({ languageVersion: 1, text: `@version 1;${body}` })).not.toThrow();
},
),
{ numRuns: 100 },
);
const forbiddenBody = fc.oneof(
fc
.tuple(
fc.array(fc.boolean(), { minLength: 6, maxLength: 6 }),
fc.array(fc.boolean(), { minLength: 6, maxLength: 6 }),
)
.map(([escaped, uppercase]) => ({
body: `@${escapedIdentifier("import", escaped, uppercase)} 'x';`,
code: "FORBIDDEN_AT_RULE",
})),
fc
.tuple(
fc.array(fc.boolean(), { minLength: 9, maxLength: 9 }),
fc.array(fc.boolean(), { minLength: 9, maxLength: 9 }),
)
.map(([escaped, uppercase]) => ({
body: `@${escapedIdentifier("font-face", escaped, uppercase)}{}`,
code: "FORBIDDEN_AT_RULE",
})),
fc
.tuple(
fc.array(fc.boolean(), { minLength: 3, maxLength: 3 }),
fc.array(fc.boolean(), { minLength: 3, maxLength: 3 }),
)
.map(([escaped, uppercase]) => ({
body: `:root{--x:${escapedIdentifier("url", escaped, uppercase)}(x)}field{color:var(--x)}`,
code: "FORBIDDEN_CSS_VALUE",
})),
fc
.tuple(
fc.array(fc.boolean(), { minLength: 3, maxLength: 3 }),
fc.array(fc.boolean(), { minLength: 3, maxLength: 3 }),
)
.map(([escaped, uppercase]) => ({
body: `field{${escapedIdentifier("src", escaped, uppercase)}:x}`,
code: "FORBIDDEN_CSS_VALUE",
})),
);
fc.assert(
fc.property(forbiddenBody, ({ body, code }) => {
const result = compileStylesheet({ languageVersion: 1, text: `@version 1;${body}` });
expect(result.program).toBeNull();
expect(result.diagnostics).toContainEqual(expect.objectContaining({ code, severity: "error" }));
}),
{ numRuns: 100 },
);
});
it("publishes the exact frozen version-one limits contract", () => {
expect(SEMANTIC_CSS_LIMITS_V1).toEqual({
maxSourceBytes: 128 * 1024,
maxRules: 1_024,
maxDeclarations: 8_192,
maxSelectorsPerRule: 64,
maxSelectorCodePoints: 2_048,
maxCombinatorsPerSelector: 16,
maxFunctionDepth: 16,
maxVariableExpansionDepth: 32,
maxMediaNesting: 4,
maxSemanticNodes: 20_000,
maxAbsoluteLengthPt: 100_000,
});
expect(Object.isFrozen(SEMANTIC_CSS_LIMITS_V1)).toBe(true);
});
it("keeps every successful compiled program structured-clone-safe", () => {
fc.assert(
fc.property(fc.constantFrom("red", "#123456", "rgb(1, 2, 3)", "var(--accent, blue)"), (color) => {
const result = compileStylesheet({
languageVersion: 1,
text: `@version 1; name { color: ${color}; }`,
});
expect(result.program).not.toBeNull();
expect(() => structuredClone(result.program)).not.toThrow();
}),
{ numRuns: 20 },
);
});
});
+657
View File
@@ -0,0 +1,657 @@
import type { CssLocation, CssNode } from "css-tree";
import type { SemanticCssCompilerDiagnosticCode } from "./diagnostics";
import type {
CompiledDeclaration,
CompiledMediaQuery,
CompiledStyleRule,
MediaFeature,
ParsedStylesheet,
SemanticCssDiagnostic,
SourceRange,
StyleProgram,
} from "./types";
import * as csstree from "css-tree";
import { createDiagnostic, EMPTY_SOURCE_RANGE } from "./diagnostics";
import { SEMANTIC_CSS_LIMITS_V1 } from "./limits";
import {
PROPERTY_REGISTRY_V1,
SEMANTIC_CSS_BORDER_STYLE_VALUES_V1,
SEMANTIC_CSS_CSS_WIDE_KEYWORDS_V1,
SEMANTIC_CSS_LENGTH_PROPERTIES_V1,
SEMANTIC_CSS_LENGTH_UNITS_V1,
SEMANTIC_CSS_LENGTH_VALUE_KEYWORDS_V1,
} from "./registry/properties";
import { compileSelector } from "./selector";
export type CompileProgramResult = {
program: StyleProgram | null;
diagnostics: readonly SemanticCssDiagnostic[];
};
type AstNode = CssNode & {
block?: AstNode | null;
children?: Iterable<AstNode>;
important?: boolean | string;
name?: string;
prelude?: AstNode | null;
property?: string;
value?: AstNode | string;
};
const absoluteUnitToPt = {
pt: 1,
px: 72 / 96,
in: 72,
mm: 72 / 25.4,
cm: 72 / 2.54,
} as const;
const spacingShorthands = new Set(["margin", "padding"]);
const sides = ["top", "right", "bottom", "left"] as const;
const borderStyles = new Set<string>(SEMANTIC_CSS_BORDER_STYLE_VALUES_V1);
const cssWideKeywords = new Set<string>(SEMANTIC_CSS_CSS_WIDE_KEYWORDS_V1);
const lengthValueKeywords = new Set<string>(SEMANTIC_CSS_LENGTH_VALUE_KEYWORDS_V1);
const maxMediaQueryBranches = SEMANTIC_CSS_LIMITS_V1.maxRules;
const lengthUnitPattern = SEMANTIC_CSS_LENGTH_UNITS_V1.map((unit) => (unit === "%" ? "%" : unit)).join("|");
const borderWidthPattern = new RegExp(
`^(?:thin|medium|thick|[+-]?(?:\\d+(?:\\.\\d*)?|\\.\\d+)(?:e[+-]?\\d+)?(?:${lengthUnitPattern})?)$`,
"i",
);
function isCssWideKeyword(value: string): boolean {
return cssWideKeywords.has(value.toLowerCase());
}
function isRegisteredPropertyValue(property: string, value: string): boolean {
return (
PROPERTY_REGISTRY_V1[property]?.values.some((candidate) => candidate.toLowerCase() === value.toLowerCase()) ?? false
);
}
function isRegisteredShorthandComponent(property: string, value: string): boolean {
return !isCssWideKeyword(value) && isRegisteredPropertyValue(property, value);
}
export const SEMANTIC_CSS_LENGTH_PROPERTIES = new Set<string>(SEMANTIC_CSS_LENGTH_PROPERTIES_V1);
function range(location: CssLocation | null | undefined): SourceRange {
return location ? { start: { ...location.start }, end: { ...location.end } } : EMPTY_SOURCE_RANGE;
}
export function decodeCssEscapes(value: string): string {
return value.replaceAll(
/\\([0-9a-f]{1,6})[ \t\r\n\f]?|\\(.)/gi,
(_match, hex: string | undefined, escaped: string | undefined) => {
const codePoint = hex ? Number.parseInt(hex, 16) : 0;
return hex ? String.fromCodePoint(codePoint === 0 || codePoint > 0x10ffff ? 0xfffd : codePoint) : (escaped ?? "");
},
);
}
export function cssFunctionDepth(value: string): number {
const stack: boolean[] = [];
let currentDepth = 0;
let maximumDepth = 0;
let identifier = "";
let quote = "";
for (let index = 0; index < value.length; index++) {
const character = value[index] ?? "";
if (quote) {
if (character === quote && value[index - 1] !== "\\") quote = "";
continue;
}
if (character === '"' || character === "'") {
quote = character;
identifier = "";
continue;
}
if (character === "/" && value[index + 1] === "*") {
const end = value.indexOf("*/", index + 2);
index = end < 0 ? value.length : end + 1;
continue;
}
if (/[-_a-zA-Z0-9\\]/.test(character)) {
identifier += character;
continue;
}
if (character === "(") {
const isFunction = identifier.length > 0;
stack.push(isFunction);
if (isFunction) maximumDepth = Math.max(maximumDepth, ++currentDepth);
identifier = "";
continue;
}
if (character === ")") {
if (stack.pop()) currentDepth--;
}
identifier = "";
}
return maximumDepth;
}
function identifier(value: string): string {
return decodeCssEscapes(value);
}
function children(node: AstNode | null | undefined): AstNode[] {
return node?.children ? [...node.children] : [];
}
function diagnostic(
diagnostics: SemanticCssDiagnostic[],
code: SemanticCssCompilerDiagnosticCode,
message: string,
node?: AstNode,
severity: SemanticCssDiagnostic["severity"] = "error",
): void {
diagnostics.push(createDiagnostic(code, severity, message, range(node?.loc)));
}
function splitValue(value: string): string[] {
const parts: string[] = [];
let start = 0;
let depth = 0;
let quote = "";
for (let index = 0; index < value.length; index++) {
const character = value[index] ?? "";
if (quote) {
if (character === quote && value[index - 1] !== "\\") quote = "";
continue;
}
if (character === '"' || character === "'") {
quote = character;
continue;
}
if (character === "(") depth++;
if (character === ")") depth--;
if (/\s/.test(character) && depth === 0) {
if (start < index) parts.push(value.slice(start, index));
start = index + 1;
}
}
if (start < value.length) parts.push(value.slice(start));
return parts.filter(Boolean);
}
function fourSides(property: string, values: readonly string[]): readonly [property: string, value: string][] | null {
if (values.length < 1 || values.length > 4) return null;
const [top, right = top, bottom = top, left = right] =
values.length === 3
? [values[0], values[1], values[2], values[1]]
: values.length === 2
? [values[0], values[1], values[0], values[1]]
: values;
return sides.map((side, index) => [`${property}-${side}`, [top, right, bottom, left][index] as string]);
}
function borderComponents(value: string): readonly [component: string, value: string][] | null {
if (isCssWideKeyword(value)) {
return [
["width", value],
["style", value],
["color", value],
];
}
const values = splitValue(value);
let width = "medium";
let style = "none";
let color = "currentcolor";
for (const part of values) {
if (borderWidthPattern.test(part)) {
if (width !== "medium") return null;
width = part;
} else if (borderStyles.has(part.toLowerCase())) {
if (style !== "none") return null;
style = part;
} else if (color === "currentcolor") color = part;
else return null;
}
return [
["width", width],
["style", style],
["color", color],
];
}
export function expandShorthand(property: string, value: string): readonly [property: string, value: string][] | null {
const valueParts = splitValue(value);
if (PROPERTY_REGISTRY_V1[property] && valueParts.length > 1 && valueParts.some(isCssWideKeyword)) return null;
if (!spacingShorthands.has(property)) {
if (property.endsWith("-horizontal")) {
const prefix = property.slice(0, -"-horizontal".length);
return [
[`${prefix}-left`, value],
[`${prefix}-right`, value],
];
}
if (property.endsWith("-vertical")) {
const prefix = property.slice(0, -"-vertical".length);
return [
[`${prefix}-top`, value],
[`${prefix}-bottom`, value],
];
}
if (property === "gap") {
const values = splitValue(value);
if (values.length < 1 || values.length > 2) return null;
return [
["row-gap", values[0] as string],
["column-gap", (values[1] ?? values[0]) as string],
];
}
if (property === "flex-flow") {
if (isCssWideKeyword(value))
return [
["flex-direction", value],
["flex-wrap", value],
];
const values = splitValue(value);
if (values.length < 1 || values.length > 2) return null;
const direction = values.find((part) => isRegisteredShorthandComponent("flex-direction", part)) ?? "row";
const wrap = values.find((part) => isRegisteredShorthandComponent("flex-wrap", part)) ?? "nowrap";
if (values.some((part) => part !== direction && part !== wrap)) return null;
return [
["flex-direction", direction],
["flex-wrap", wrap],
];
}
if (property === "flex") {
if (isCssWideKeyword(value))
return [
["flex-grow", value],
["flex-shrink", value],
["flex-basis", value],
];
if (value === "none")
return [
["flex-grow", "0"],
["flex-shrink", "0"],
["flex-basis", "auto"],
];
if (value === "auto")
return [
["flex-grow", "1"],
["flex-shrink", "1"],
["flex-basis", "auto"],
];
const values = splitValue(value);
if (values.length < 1 || values.length > 3) return null;
const numeric = (part: string | undefined) => part !== undefined && /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$/.test(part);
if (!numeric(values[0])) {
return values.length === 1
? [
["flex-grow", "1"],
["flex-shrink", "1"],
["flex-basis", values[0] as string],
]
: null;
}
const grow = values[0] as string;
const hasShrink = numeric(values[1]);
if (!hasShrink && values.length > 2) return null;
const shrink = hasShrink ? (values[1] as string) : "1";
const basis = values[hasShrink ? 2 : 1] ?? "0%";
return [
["flex-grow", grow],
["flex-shrink", shrink],
["flex-basis", basis],
];
}
if (property === "border") {
const components = borderComponents(value);
return (
components?.flatMap(([component, componentValue]) =>
sides.map((side) => [`border-${side}-${component}`, componentValue] as const),
) ?? null
);
}
const sideBorder = property.match(/^border-(top|right|bottom|left)$/);
if (sideBorder) {
const components = borderComponents(value);
return (
components?.map(
([component, componentValue]) => [`border-${sideBorder[1]}-${component}`, componentValue] as [string, string],
) ?? null
);
}
if (/^border-(width|style|color)$/.test(property)) {
const component = property.slice("border-".length);
return (
fourSides("border", splitValue(value))?.map(([sideProperty, sideValue]) => [
`${sideProperty}-${component}`,
sideValue,
]) ?? null
);
}
if (property === "border-radius") {
if (value.includes("/")) return null;
const corners = fourSides("border", splitValue(value));
if (!corners) return null;
const cornerNames = ["top-left", "top-right", "bottom-right", "bottom-left"] as const;
return corners.map(([, cornerValue], index) => [`border-${cornerNames[index]}-radius`, cornerValue]);
}
return [[property, value]];
}
const values = splitValue(value);
return fourSides(property, values);
}
function parseAbsoluteLength(value: string): number | null {
const match = value.trim().match(/^([+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?)\s*(pt|px|in|mm|cm)?$/i);
if (!match) return null;
const number = Number(match[1]);
const unit = (match[2]?.toLowerCase() ?? "pt") as keyof typeof absoluteUnitToPt;
return number * absoluteUnitToPt[unit];
}
const lengthPattern = new RegExp(`^[+-]?(?:\\d+(?:\\.\\d*)?|\\.\\d+)(?:e[+-]?\\d+)?(?:${lengthUnitPattern})?$`, "i");
export function valueSyntaxError(property: string, value: string): string | null {
const normalized = value.trim().toLowerCase();
if (!normalized) return "Values cannot be empty.";
if (cssWideKeywords.has(normalized) || /var\s*\(/i.test(decodeCssEscapes(value))) return null;
if (SEMANTIC_CSS_LENGTH_PROPERTIES.has(property)) {
if (lengthPattern.test(normalized)) {
if (property === "font-size" && Number.parseFloat(normalized) < 0) {
return "font-size cannot be negative.";
}
return null;
}
if (lengthValueKeywords.has(normalized)) return null;
return `${property} requires a supported PDF length.`;
}
if (property === "size") {
const parts = splitValue(normalized);
return isRegisteredPropertyValue(property, normalized) ||
(parts.length >= 1 && parts.length <= 2 && parts.every((part) => lengthPattern.test(part)))
? null
: "size requires A4, letter, or one or two PDF lengths.";
}
if (property === "display")
return isRegisteredPropertyValue(property, normalized) ? null : "display supports flex or none.";
if (property === "direction")
return isRegisteredPropertyValue(property, normalized) ? null : "direction supports ltr or rtl.";
if (/^(?:border-style|border-(?:top|right|bottom|left)-style)$/.test(property)) {
return isRegisteredPropertyValue(property, normalized) ? null : "border styles support dotted, dashed, or solid.";
}
if (property === "break-before")
return isRegisteredPropertyValue(property, normalized) ? null : "break-before supports auto or page.";
if (property === "break-inside")
return isRegisteredPropertyValue(property, normalized) ? null : "break-inside supports auto or avoid.";
if (property === "-resume-fixed")
return isRegisteredPropertyValue(property, normalized) ? null : "-resume-fixed requires a boolean.";
if (
property === "order" ||
property === "orphans" ||
property === "widows" ||
property === "z-index" ||
property === "max-lines"
) {
const number = Number(normalized);
return Number.isInteger(number) ? null : `${property} requires an integer.`;
}
if (property === "opacity" || property === "flex-grow" || property === "flex-shrink") {
const number = Number(normalized);
if (!Number.isFinite(number)) return `${property} requires a finite number.`;
if (property === "opacity" && (number < 0 || number > 1)) return "opacity must be between 0 and 1.";
return null;
}
if (property === "line-height") {
return isRegisteredPropertyValue(property, normalized) || lengthPattern.test(normalized)
? null
: "line-height requires a number or PDF length.";
}
return null;
}
function validateValue(property: string, value: string, node: AstNode, diagnostics: SemanticCssDiagnostic[]): void {
const decoded = decodeCssEscapes(value).toLowerCase();
if (property === "src" || /\burl\s*\(/i.test(decoded)) {
diagnostic(diagnostics, "FORBIDDEN_CSS_VALUE", "External CSS resources are not supported.", node);
return;
}
for (const match of decoded.matchAll(
/(^|[\s,(])([+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?)\s*(pt|px|in|mm|cm)(?=$|[\s,)])/gi,
)) {
const points = parseAbsoluteLength(`${match[2]}${match[3]}`);
if (points === null || !Number.isFinite(points) || Math.abs(points) > SEMANTIC_CSS_LIMITS_V1.maxAbsoluteLengthPt) {
diagnostic(
diagnostics,
"INVALID_VALUE",
"Absolute lengths must be finite and within the Semantic CSS limit.",
node,
);
return;
}
if (property === "font-size" && (points < 4 || points > 72)) {
diagnostic(diagnostics, "EXTREME_VALUE", "This font size is renderable but unusually extreme.", node, "warning");
}
}
}
function parseMediaFeature(source: string): MediaFeature | null {
const orientation = source.match(/^\(\s*orientation\s*:\s*(portrait|landscape)\s*\)$/i);
if (orientation) return { name: "orientation", value: orientation[1]?.toLowerCase() as "portrait" | "landscape" };
const dimensions = source.match(/^\(\s*(min-|max-)?(width|height)\s*:\s*([^)]+)\s*\)$/i);
if (!dimensions) return null;
return {
name: dimensions[2]?.toLowerCase() as "width" | "height",
comparison: dimensions[1] ? (dimensions[1].toLowerCase().startsWith("min") ? "min" : "max") : "equal",
value: dimensions[3]?.trim() ?? "",
};
}
function splitOutsideParentheses(value: string, separator: "," | "and"): string[] {
const parts: string[] = [];
let start = 0;
let depth = 0;
for (let index = 0; index < value.length; index++) {
const character = value[index];
if (character === "(") depth++;
if (character === ")") depth--;
if (depth !== 0) continue;
if (separator === "," && character === ",") {
parts.push(value.slice(start, index).trim());
start = index + 1;
} else if (
separator === "and" &&
value.slice(index, index + 3).toLowerCase() === "and" &&
/\s/.test(value[index - 1] ?? " ") &&
/\s/.test(value[index + 3] ?? " ")
) {
parts.push(value.slice(start, index).trim());
start = index + 3;
}
}
parts.push(value.slice(start).trim());
return parts;
}
function parseMedia(node: AstNode, diagnostics: SemanticCssDiagnostic[]): readonly CompiledMediaQuery[] | null {
const source = node.prelude ? csstree.generate(node.prelude) : "";
const querySources = splitOutsideParentheses(source, ",");
if (querySources.length > maxMediaQueryBranches) {
diagnostic(diagnostics, "RESOURCE_LIMIT", "The media query list exceeds the Semantic CSS branch limit.", node);
return null;
}
const queries = querySources.map((query) => {
const features = splitOutsideParentheses(query, "and").map(parseMediaFeature);
return features.every((feature): feature is MediaFeature => feature !== null) ? { features } : null;
});
const invalidValue = queries.some((query) =>
query?.features.some((feature) => {
if (feature.name === "orientation") return false;
const match = feature.value.match(
/^([+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?)(?:pt|px|in|mm|cm|vw|vh|em|rem)?$/i,
);
if (!match) {
return true;
}
if (!Number.isFinite(Number(match[1]))) return true;
const absolute = parseAbsoluteLength(feature.value);
return (
absolute !== null &&
(!Number.isFinite(absolute) || Math.abs(absolute) > SEMANTIC_CSS_LIMITS_V1.maxAbsoluteLengthPt)
);
}),
);
if (queries.length === 0 || queries.some((query) => query === null) || invalidValue) {
diagnostic(
diagnostics,
"INVALID_MEDIA_QUERY",
"Semantic CSS media queries support only width, height, and orientation.",
node,
);
return null;
}
return queries as readonly CompiledMediaQuery[];
}
function combineMedia(
parent: readonly CompiledMediaQuery[],
child: readonly CompiledMediaQuery[],
): readonly CompiledMediaQuery[] | null {
const parentBranches = Math.max(parent.length, 1);
if (child.length > Math.floor(maxMediaQueryBranches / parentBranches)) return null;
if (parent.length === 0) return child;
return parent.flatMap((left) => child.map((right) => ({ features: [...left.features, ...right.features] })));
}
export function compileProgram(stylesheet: ParsedStylesheet, languageVersion: number): CompileProgramResult {
const diagnostics: SemanticCssDiagnostic[] = [];
const rules: CompiledStyleRule[] = [];
let ruleCount = 0;
let declarationCount = 0;
let sourceOrder = 0;
const compileRule = (node: AstNode, media: readonly CompiledMediaQuery[]) => {
if (++ruleCount > SEMANTIC_CSS_LIMITS_V1.maxRules) {
if (ruleCount === SEMANTIC_CSS_LIMITS_V1.maxRules + 1) {
diagnostic(diagnostics, "RESOURCE_LIMIT", "The stylesheet has too many rules.", node);
}
return;
}
const selectorResult = node.prelude
? 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";
diagnostic(diagnostics, code, selectorResult.error ?? "Invalid selector.", node.prelude ?? node);
return;
}
const declarations: CompiledDeclaration[] = [];
for (const declaration of children(node.block)) {
if (declaration.type !== "Declaration" || !declaration.property) continue;
if (++declarationCount > SEMANTIC_CSS_LIMITS_V1.maxDeclarations) {
if (declarationCount === SEMANTIC_CSS_LIMITS_V1.maxDeclarations + 1) {
diagnostic(diagnostics, "RESOURCE_LIMIT", "The stylesheet has too many declarations.", declaration);
}
continue;
}
const decodedProperty = identifier(declaration.property);
const property = decodedProperty.startsWith("--") ? decodedProperty : decodedProperty.toLowerCase();
const lowerProperty = property.toLowerCase();
if (lowerProperty.startsWith("--resume-")) {
diagnostic(
diagnostics,
"SYSTEM_VARIABLE_READONLY",
"System variables beginning with --resume- are read-only.",
declaration,
);
continue;
}
if (!property.startsWith("--") && !PROPERTY_REGISTRY_V1[property]) {
diagnostic(
diagnostics,
property === "src" ? "FORBIDDEN_CSS_VALUE" : "UNSUPPORTED_PROPERTY",
`The ${property} property is not supported by Semantic CSS.`,
declaration,
);
continue;
}
if (media.length > 0 && property === "size") {
diagnostic(diagnostics, "MEDIA_PAGE_SIZE", "The size property is not allowed inside @media.", declaration);
continue;
}
const value =
typeof declaration.value === "string" ? declaration.value : csstree.generate(declaration.value as CssNode);
const trimmedValue = value.trim();
const expanded = /var\s*\(/i.test(decodeCssEscapes(trimmedValue))
? ([[property, trimmedValue]] as const)
: expandShorthand(property, trimmedValue);
if (!expanded) {
diagnostic(diagnostics, "INVALID_VALUE", `Invalid ${property} shorthand.`, declaration);
continue;
}
for (const [expandedProperty, expandedValue] of expanded) {
validateValue(expandedProperty, expandedValue, declaration, diagnostics);
const syntaxError = property.startsWith("--") ? null : valueSyntaxError(expandedProperty, expandedValue);
if (syntaxError) diagnostic(diagnostics, "INVALID_VALUE", syntaxError, declaration);
declarations.push({
property: expandedProperty,
value: expandedValue,
important: declaration.important === true || declaration.important === "important",
sourceOrder: sourceOrder++,
range: range(declaration.loc),
});
}
}
rules.push({
selector: selectorResult.selector,
declarations,
media,
range: range(node.loc),
});
};
const visit = (nodes: readonly AstNode[], media: readonly CompiledMediaQuery[], mediaDepth: number): void => {
for (const node of nodes) {
if (node.type === "Rule") {
compileRule(node, media);
continue;
}
if (node.type !== "Atrule" || !node.name) continue;
const name = identifier(node.name).toLowerCase();
if (name === "version") continue;
if (name !== "media") {
diagnostic(
diagnostics,
name === "import" || name === "font-face" ? "FORBIDDEN_AT_RULE" : "UNSUPPORTED_AT_RULE",
`@${name} is not supported by Semantic CSS.`,
node,
);
continue;
}
if (mediaDepth >= SEMANTIC_CSS_LIMITS_V1.maxMediaNesting) {
diagnostic(diagnostics, "RESOURCE_LIMIT", "Media nesting exceeds the Semantic CSS limit.", node);
continue;
}
const compiledMedia = parseMedia(node, diagnostics);
if (!compiledMedia) continue;
const combinedMedia = combineMedia(media, compiledMedia);
if (!combinedMedia) {
diagnostic(diagnostics, "RESOURCE_LIMIT", "Nested media queries exceed the Semantic CSS branch limit.", node);
continue;
}
visit(children(node.block), combinedMedia, mediaDepth + 1);
}
};
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")
? { program: null, diagnostics }
: { program: structuredClone(program), diagnostics };
}
@@ -0,0 +1,68 @@
import { describe, expect, it } from "vitest";
import { compileStylesheet } from "./compile";
describe("compileStylesheet", () => {
it("compiles canonical version-one source", () => {
const result = compileStylesheet({
languageVersion: 1,
text: "@version 1;\nsection { color: #123456; }\n",
});
expect(result.program?.languageVersion).toBe(1);
expect(result.diagnostics).toEqual([]);
});
it("warns when version-one source omits the directive", () => {
const result = compileStylesheet({ languageVersion: 1, text: "section { color: red; }" });
expect(result.diagnostics).toContainEqual(
expect.objectContaining({ code: "MISSING_VERSION_DIRECTIVE", severity: "warning" }),
);
});
it("rejects a directive that disagrees with persisted metadata", () => {
const result = compileStylesheet({ languageVersion: 1, text: "@version 2;" });
expect(result.program).toBeNull();
expect(result.diagnostics).toContainEqual(expect.objectContaining({ code: "VERSION_MISMATCH", severity: "error" }));
});
it("rejects duplicate directives", () => {
const result = compileStylesheet({ languageVersion: 1, text: "@version 1;\n@version 1;" });
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;" });
expect(result.program).toBeNull();
expect(result.diagnostics).toContainEqual(expect.objectContaining({ code: "INVALID_VERSION", severity: "error" }));
});
it("rejects non-positive directives", () => {
const result = compileStylesheet({ languageVersion: 1, text: "@version 0;" });
expect(result.program).toBeNull();
expect(result.diagnostics).toContainEqual(expect.objectContaining({ code: "INVALID_VERSION", severity: "error" }));
});
it("rejects unsupported persisted language versions", () => {
const result = compileStylesheet({ languageVersion: 2, text: "@version 2;" });
expect(result.program).toBeNull();
expect(result.diagnostics).toContainEqual(
expect.objectContaining({ code: "UNSUPPORTED_VERSION", severity: "error" }),
);
});
it("does not compile a recovered CSS error", () => {
const result = compileStylesheet({ languageVersion: 1, text: "@version 1;\nsection { color red; }" });
expect(result.program).toBeNull();
expect(result.diagnostics).toContainEqual(expect.objectContaining({ code: "CSS_PARSE_ERROR", severity: "error" }));
});
});
+18
View File
@@ -0,0 +1,18 @@
import type { CompiledStyleRule, StyleProgram } from "./types";
export const SUPPORTED_SEMANTIC_CSS_VERSIONS = Object.freeze([1] as const);
type StylesheetCompiler = (rules: readonly CompiledStyleRule[]) => StyleProgram;
function compileVersionOne(rules: readonly CompiledStyleRule[]): StyleProgram {
return Object.freeze({ languageVersion: 1, rules: Object.freeze([...rules]) });
}
const COMPILERS = Object.freeze({ 1: compileVersionOne } satisfies Record<
(typeof SUPPORTED_SEMANTIC_CSS_VERSIONS)[number],
StylesheetCompiler
>);
export function getStylesheetCompiler(version: number): StylesheetCompiler | undefined {
return COMPILERS[version as keyof typeof COMPILERS];
}