mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-08-24 07:12:18 +10:00
935 lines
32 KiB
TypeScript
935 lines
32 KiB
TypeScript
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 };
|
|
}
|