mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-08-24 15:22:20 +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
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user