lightningcss icon indicating copy to clipboard operation
lightningcss copied to clipboard

Support the CSS Functions and Mixins draft spec

Open samhh opened this issue 1 year ago • 3 comments

Here's the draft spec: https://drafts.csswg.org/css-mixins/

And the CSSWG issue where there have been positive signals: https://github.com/w3c/csswg-drafts/issues/9350

Is there any interest in supporting it now or in the future, possibly under drafts?

samhh avatar Aug 02 '24 10:08 samhh

I was looking for a way to try this and did some research, then stumbled onto this issue. So I'll do a write-up of my findings :

Since this issue was opened, there's been a lot of working happening behind the scenes on the spec. The editor draft now has mixins, while the public draft only has functions for now.

There's now a couple MDN pages, here and here.

As of now, caniuse shows 58% for @function, and @mixin doesn't have a page yet.

Afaik there's no tooling support, as postcss doesn't support it either.

Hebilicious avatar Nov 07 '25 01:11 Hebilicious

PostCSS polyfill tracking issue: https://github.com/csstools/postcss-plugins/issues/1666

thesmartwon avatar Nov 21 '25 19:11 thesmartwon

I just wrote a janky polyfill. Doesn't support argument or return types, nor functions calling functions (let alone with preserved context).

Oh, and when it fails, it does so silently. It's good enough for my case of simple functions.

code
import type {
	CustomAtRules,
	TokenOrValue,
	Variable,
	Visitor,
} from "lightningcss";
import { composeVisitors } from "lightningcss";
import type { LightningCSSOptions } from "vite";

function defineVisitor(
	visitor: Visitor<CustomAtRules> | (() => Visitor<CustomAtRules>),
): Visitor<CustomAtRules> {
	return typeof visitor === "function" ? visitor() : visitor;
}

function* findVars(token: TokenOrValue | TokenOrValue[]): Generator<{
	type: "var";
	value: Variable;
}> {
	if (Array.isArray(token)) {
		for (const e of token) yield* findVars(e);
		return;
	}
	switch (token.type) {
		case "var": {
			yield token;
			break;
		}
		case "function": {
			for (const e of token.value.arguments) yield* findVars(e);
			break;
		}
		case "unresolved-color": {
			if ("alpha" in token.value) {
				for (const a of token.value.alpha) yield* findVars(a);
			} else {
				for (const a of token.value.dark) yield* findVars(a);
				for (const a of token.value.light) yield* findVars(a);
			}
			break;
		}
		case "env": {
			for (const f of token.value.fallback ?? []) yield* findVars(f);
			break;
		}
	}
}

class CssFunction {
	constructor(
		public args: Record<string, true /* needs more */>,
		public body: TokenOrValue[],
	) {}

	eval(args: TokenOrValue[]): TokenOrValue[] {
		const body: TokenOrValue[] = JSON.parse(JSON.stringify(this.body));
		const argsMap = args
			.filter((a) => !(a.type === "token" && a.value.type === "comma"))
			.reduce((acc, cur, i) => {
				acc[Object.keys(this.args)[i]] = cur.value;
				return acc;
			}, {});

		for (const ident of findVars(body)) {
			const key = ident.value.name.ident;
			if (argsMap[key] && this.args[key]) {
				Object.assign(ident, {
					type: "token",
					value: argsMap[key],
				});
			}
		}

		return body;
	}
}

const AtFunctionVisitor = defineVisitor(() => {
	const fns: Record<string, CssFunction> = {};

	return {
		Rule: {
			custom: {
				function(rule) {
					if (rule.prelude.type !== "token-list") return;
					if (rule.prelude.value[0].type !== "function") return;
					const preludeFn = rule.prelude.value[0].value;

					if (rule.body.type !== "declaration-list") return;
					const bodyBlock = rule.body.value;
					const result = bodyBlock.declarations?.[0];
					if (
						!(result?.property === "custom" && result?.value?.name === "result")
					)
						return;

					fns[preludeFn.name] = new CssFunction(
						preludeFn.arguments
							.filter((a) => a.type === "dashed-ident")
							.map((a) => a.value)
							.reduce(
								(acc, cur) => {
									acc[cur] = true;
									return acc;
								},
								{} as Record<string, true>,
							),
						result.value.value,
					);

					return { type: "ignored", value: null };
				},
			},
		},
		Function(fn) {
			const match = fns[fn.name];
			if (match)
				return {
					type: "function", // hack since we cannot return a token-list
					value: {
						name: "var",
						arguments: [
							{ type: "token", value: { type: "ident", value: "--undefined" } },
							{ type: "token", value: { type: "comma" } },
							...match.eval(fn.arguments),
						],
					},
				};
		},
	};
});

const options: LightningCSSOptions = {
	customAtRules: {
		function: {
			// https://drafts.csswg.org/css-mixins/#function-rule
			prelude: "*", // lightingcss doesn't have syntax for this yet
			body: "declaration-list",
		},
	},
	visitor: composeVisitors([AtFunctionVisitor]),
};
export default options;

thesmartwon avatar Nov 21 '25 19:11 thesmartwon