mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-08-23 23:02:17 +10:00
feat: add semantic CSS stylesheets (#3274)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor Agent
parent
4ac19f81b3
commit
d2ffbf9618
@@ -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: "1842–1843",
|
||||
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" },
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
@@ -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 };
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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" }));
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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" }));
|
||||
});
|
||||
});
|
||||
@@ -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];
|
||||
}
|
||||
Reference in New Issue
Block a user