muddle/views/css/parser.ts
2026-10-23 09:12:49 -04:00

464 lines
10 KiB
TypeScript

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