Files
Reactive-Resume/packages/resume/src/stylesheet/values.ts
T
2026-07-30 12:39:15 +02:00

658 lines
22 KiB
TypeScript

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 };
}