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; 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(SEMANTIC_CSS_BORDER_STYLE_VALUES_V1); const cssWideKeywords = new Set(SEMANTIC_CSS_CSS_WIDE_KEYWORDS_V1); const lengthValueKeywords = new Set(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(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 }; }