381 lines
10 KiB
TypeScript
381 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();
|
|
}
|