export interface CssDeclaration { property: string; value: string; } export interface CssRule { kind: "rule"; selector: string; declarations: CssDeclaration[]; children: CssNode[]; } export interface CssAtRule { kind: "at-rule"; name: string; params: string; children: CssNode[]; } /** A node of the stylesheet. */ export type CssNode = CssRule | CssAtRule; export interface CssVariable { name: string; value: string; } export interface CssMixin { name: string; params: { name: string; value?: string }[]; children: CssNode[]; } export interface CssStylesheet { variables: CssVariable[]; mixins: CssMixin[]; rules: CssNode[]; } const COMMENT = /\/\*[\s\S]*?\*\/|\/\/[^\n]*/g; const PUG_PROPERTIES = new Set([ "accent-color", "align-content", "align-items", "align-self", "animation", "appearance", "aspect-ratio", "backdrop-filter", "background", "background-color", "background-image", "background-size", "block-size", "border", "border-radius", "border-color", "border-width", "bottom", "box-shadow", "box-sizing", "color", "columns", "contain", "content", "cursor", "display", "filter", "flex", "flex-basis", "flex-direction", "flex-grow", "flex-shrink", "flex-wrap", "float", "font", "font-family", "font-size", "font-weight", "gap", "grid", "grid-area", "grid-column", "grid-row", "grid-template-columns", "grid-template-rows", "height", "inline-size", "inset", "justify-content", "justify-items", "justify-self", "left", "letter-spacing", "line-height", "list-style", "margin", "max-height", "max-width", "min-height", "min-width", "object-fit", "opacity", "order", "outline", "overflow", "overflow-x", "overflow-y", "padding", "perspective", "place-content", "place-items", "pointer-events", "position", "right", "rotate", "scale", "scroll-behavior", "scroll-margin-top", "text-align", "text-decoration", "text-transform", "top", "transform", "transform-origin", "transition", "translate", "user-select", "visibility", "white-space", "width", "will-change", "word-break", "z-index", ]); function indentation(line: string): number { let width = 0; for (const char of line) { if (char === " ") width += 1; else if (char === "\t") width += 2; else break; } return width; } function pugDeclaration(line: string): string | undefined { if (/^\$[\w-]+\s*:\s*.+$/.test(line)) return line; const variable = /^(\$[\w-]+)\s+(.+)$/.exec(line); if (variable) return `${variable[1]}: ${variable[2]}`; if (/^(?:--[\w-]+|[a-zA-Z][\w-]*)\s*:\s+.+$/.test(line)) return line; const match = /^(--[\w-]+|[a-zA-Z][\w-]*)\s+(.+)$/.exec(line); if (match && PUG_PROPERTIES.has(match[1] as string)) return `${match[1]}: ${match[2]}`; return undefined; } function isPugStatement(line: string): boolean { return /^@(include|measure|predict|concentric)\b/.test(line); } /** Converts indentation-delimited STCSS into the brace form used by the core parser. */ function normalizePugSyntax(source: string): string { const withoutInterpolations = source.replace(/#\{[^}]*\}/g, ""); if (/[{}]/.test(withoutInterpolations)) return source; const lines = source.split("\n"); const significant = lines.filter((line) => line.trim().length > 0); if (!significant.some((line) => /^\s+/.test(line))) return source; const output: string[] = []; const stack: number[] = []; for (const raw of lines) { const content = raw.trim(); if (!content) continue; const level = indentation(raw); while (stack.length > 0 && level <= (stack[stack.length - 1] as number)) { output.push("}"); stack.pop(); } const declaration = pugDeclaration(content); if (declaration) { output.push(`${declaration};`); continue; } if (isPugStatement(content)) { output.push(`${content};`); continue; } const block = content.endsWith(":") ? content.slice(0, -1).trim() : content; output.push(`${block} {`); stack.push(level); } while (stack.length > 0) { output.push("}"); stack.pop(); } return output.join("\n"); } class StcssParser { private index = 0; constructor(private readonly source: string) {} parse(): CssStylesheet { const stylesheet: CssStylesheet = { variables: [], mixins: [], rules: [], }; const nodes = this.parseNodes(stylesheet.variables); for (const node of nodes) { if (node.kind === "at-rule" && node.name === "mixin") { stylesheet.mixins.push({ name: node.params.split(/[\s(]/)[0] ?? "", params: parseMixinParams(node.params), children: node.children, }); continue; } if (node.kind === "at-rule" && node.name === "each") { // the variable of the loop is handled by the compiler } stylesheet.rules.push(node); } return stylesheet; } private parseNodes( variables?: CssVariable[], declarations?: CssDeclaration[], ): CssNode[] { const nodes: CssNode[] = []; while (this.index < this.source.length) { this.skipWhitespace(); if (this.index >= this.source.length) break; if (this.source[this.index] === "}") { this.index += 1; break; } const chunk = this.readUntil(["{", ";", "}"]); if (chunk.trim().length === 0) { this.index += 1; continue; } const delimiter = this.source[this.index]; if (delimiter === "{") { this.index += 1; const selector = chunk.trim(); if (selector.startsWith("@")) { const match = /^@([\w-]+)\s*([\s\S]*)$/.exec(selector); const name = match?.[1] ?? ""; const params = (match?.[2] ?? "").trim(); const body: CssDeclaration[] = []; const children = this.parseNodes(undefined, body); if (body.length > 0) { children.unshift({ kind: "rule", selector: "", declarations: body, children: [], }); } nodes.push({ kind: "at-rule", name, params, children }); continue; } const rule: CssRule = { kind: "rule", selector, declarations: [], children: [], }; this.parseBody(rule); nodes.push(rule); continue; } // declaration or variable this.index += 1; const declaration = chunk.trim(); if (declaration.length === 0) continue; if (declaration.startsWith("@")) { const match = /^@([\w-]+)\s*([\s\S]*)$/.exec(declaration); nodes.push({ kind: "at-rule", name: match?.[1] ?? "", params: (match?.[2] ?? "").trim(), children: [], }); continue; } if (!declaration.startsWith("$")) { const separator = declaration.indexOf(":"); if (separator !== -1 && declarations) { declarations.push({ property: declaration.slice(0, separator).trim(), value: declaration.slice(separator + 1).trim(), }); } } if (declaration.startsWith("$")) { const separator = declaration.indexOf(":"); if (separator !== -1 && variables) { variables.push({ name: declaration.slice(0, separator).trim(), value: declaration.slice(separator + 1).trim(), }); } } } return nodes; } private parseBody(rule: CssRule): void { while (this.index < this.source.length) { this.skipWhitespace(); if (this.index >= this.source.length) return; if (this.source[this.index] === "}") { this.index += 1; return; } const chunk = this.readUntil(["{", ";", "}"]); const delimiter = this.source[this.index]; if (delimiter === "{") { this.index += 1; const selector = chunk.trim(); if (selector.startsWith("@")) { const match = /^@([\w-]+)\s*([\s\S]*)$/.exec(selector); const body: CssDeclaration[] = []; const children = this.parseNodes(undefined, body); if (body.length > 0) { children.unshift({ kind: "rule", selector: "", declarations: body, children: [], }); } rule.children.push({ kind: "at-rule", name: match?.[1] ?? "", params: (match?.[2] ?? "").trim(), children, }); continue; } const nested: CssRule = { kind: "rule", selector, declarations: [], children: [], }; this.parseBody(nested); rule.children.push(nested); continue; } this.index += 1; const declaration = chunk.trim(); if (declaration.length === 0) continue; if (declaration.startsWith("@")) { const match = /^@([\w-]+)\s*([\s\S]*)$/.exec(declaration); rule.children.push({ kind: "at-rule", name: match?.[1] ?? "", params: (match?.[2] ?? "").trim(), children: [], }); continue; } const separator = declaration.indexOf(":"); if (separator === -1) continue; rule.declarations.push({ property: declaration.slice(0, separator).trim(), value: declaration.slice(separator + 1).trim(), }); } } private readUntil(delimiters: string[]): string { const start = this.index; let quote: string | undefined; while (this.index < this.source.length) { const char = this.source[this.index] as string; if (quote) { if (char === quote) quote = undefined; this.index += 1; continue; } if (char === '"' || char === "'") { quote = char; this.index += 1; continue; } if (char === "#" && this.source[this.index + 1] === "{") { // an interpolation, the braces belong to the expression const end = this.source.indexOf("}", this.index); this.index = end === -1 ? this.source.length : end + 1; continue; } if (delimiters.includes(char)) break; this.index += 1; } return this.source.slice(start, this.index); } private skipWhitespace(): void { while (this.index < this.source.length) { const char = this.source[this.index] as string; if (/\s/.test(char)) { this.index += 1; continue; } if (char === "/" && this.source[this.index + 1] === "*") { const end = this.source.indexOf("*/", this.index); this.index = end === -1 ? this.source.length : end + 2; continue; } break; } } } /** Parses the parameters of a mixin, e.g. `$size: 1rem`. */ export function parseMixinParams( params: string, ): { name: string; value?: string }[] { const open = params.indexOf("("); const close = params.lastIndexOf(")"); if (open === -1 || close === -1) return []; const inner = params.slice(open + 1, close); return inner .split(",") .map((entry) => entry.trim()) .filter((entry) => entry.length > 0) .map((entry) => { const separator = entry.indexOf(":"); if (separator === -1) return { name: entry }; return { name: entry.slice(0, separator).trim(), value: entry.slice(separator + 1).trim(), }; }); } /** Parses a stylesheet. */ export function parseCss(source: string): CssStylesheet { return new StcssParser(normalizePugSyntax(source.replace(COMMENT, ""))).parse(); }