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