Files
babel-tailwind/src/css-to-js.ts
2025-01-24 01:29:15 -05:00

344 lines
8.9 KiB
TypeScript
Executable File

// MIT License. Copyright (c) 2017 Brice BERNARD
// https://github.com/brikou/CSS-in-JS-generator/commit/2a887d0d96f1d5044039d0e0457001f0fde0def0
import JSON5 from "json5";
import { camelCase } from "lodash-es";
import {
type AtRule,
type Builder,
type Node,
type Root,
type Rule,
parse,
} from "postcss";
import Stringifier from "postcss/lib/stringifier";
import parseSelector from "postcss-selector-parser";
import { type Element, compile } from "stylis";
function getSelectorScope(selector: string): string {
let selectorScope = "root";
parseSelector(nodes => {
for (const node of nodes.first.nodes) {
if (node.type === "class") {
selectorScope = node.toString();
break;
}
}
}).processSync(selector);
return selectorScope;
}
function getRequiredScopes(css: string, scope: string, knownScopes: Set<string>) {
const requiredScopes = new Set<string>();
parse(css).walkRules(rule => {
parseSelector(nodes => {
nodes.walkClasses(node => {
const selectorScope = getSelectorScope(node.toString());
if (selectorScope === scope) return;
if (knownScopes.has(selectorScope)) {
requiredScopes.add(selectorScope);
}
});
}).processSync(rule.selector);
});
return requiredScopes;
}
function isNode(node: Node | undefined, type: "rule"): node is Rule;
function isNode(node: Node | undefined, type: "atrule"): node is AtRule;
function isNode(node: Node | undefined, type: "root"): node is Root;
function isNode(node: Node | undefined, type: string): boolean {
return node?.type === type;
}
function addAll<T>(set: Set<T>, values: Iterable<T>) {
for (const value of values) {
set.add(value);
}
}
function getNodeScopes(node: Node) {
const nodeScopes = new Set<string>();
if (
isNode(node, "rule") &&
(!isNode(node.parent, "atrule") || !/keyframes/.test(node.parent.name))
) {
addAll(nodeScopes, node.selectors.map(getSelectorScope));
} else if (isNode(node, "atrule") && !node.name.endsWith("keyframes")) {
node.walkRules(rule => {
addAll(nodeScopes, rule.selectors.map(getSelectorScope));
});
}
if (!nodeScopes.size) {
nodeScopes.add("root");
}
return nodeScopes;
}
function stringify(css: string, builder: Builder): void {
new Stringifier(builder).stringify(parse(css));
}
function getCssIndexedByScope(css: string) {
const cssIndexedByScope = new Map<string, string>();
const scopesStack = [new Set(["root"])];
stringify(css, (output, node, flag) => {
if (flag === "start" && node) {
scopesStack.push(getNodeScopes(node));
}
if (flag === "end") {
output += "\n";
}
for (const scope of scopesStack.at(-1)!) {
if (
flag === "start" &&
isNode(node, "rule") &&
(!isNode(node.parent, "atrule") || !node.parent.name.endsWith("keyframes"))
) {
output = `${node.selectors
.filter(selector => getSelectorScope(selector) === scope)
.join(", ")} {`;
}
cssIndexedByScope.set(scope, (cssIndexedByScope.get(scope) || "") + output);
}
if (flag === "end") {
scopesStack.pop();
}
});
return cssIndexedByScope;
}
const convertScopeToModuleName = (scope: string) =>
camelCase(scope)
.replace(/^(\d)/, "_$1")
.replace(
/^(break|case|catch|continue|debugger|default|delete|do|else|finally|for|function|if|in|instanceof|new|return|switch|this|throw|try|typeof|var|void|while|with)$/,
"_$1"
);
/** @internal */
export function toJSCode(
css: string,
mapClassNames: (className: string) => string = convertScopeToModuleName
): string {
let res = "";
const map = getCssIndexedByScope(css);
if (map.has("root") && map.get("root")!.trim()) {
res += 'import { injectGlobal } from "@emotion/css";\n';
}
const knownScopes = new Set(map.keys());
const collator = new Intl.Collator(undefined, {
numeric: true,
sensitivity: "base",
});
function convertScopedCss(scope: string): string {
let scopedCssText = "";
const scopedCss = map.get(scope)!;
stringify(scopedCss, (output, node, flag) => {
if ((flag === "start" || flag === "end") && isNode(node, "rule")) {
if (node.selector === scope) {
if (isNode(node.parent, "root")) {
return;
} else if (flag === "start") {
output = "& {";
}
} else if (flag === "start") {
const selectors = new Set(
node.selectors.map(selector =>
parseSelector(nodes => {
nodes.first.walkClasses(node => {
if (node.toString() === scope) {
node.toString = () => "&";
} else if (knownScopes.has(node.toString())) {
node.toString = () => `.\${${mapClassNames(node.value)}}`;
}
});
}).processSync(selector)
)
);
// TODO remove join usage once https://github.com/prettier/prettier/issues/2883 is resolved
output = `${[...selectors].join(", ")} {`;
}
}
scopedCssText += output;
if (node?.type === "decl" && output.at(-1) !== ";") {
scopedCssText += ";";
}
});
return scopedCssText.replace(/\\/g, "\\\\").replace(/`/g, "\\`");
}
const sortedKnownScopes = [...knownScopes]
.sort((scopeA, scopeB) => (scopeA === "root" ? -1 : collator.compare(scopeA, scopeB)))
.reduce((accum, knownScope) => {
for (const requiredScope of getRequiredScopes(
map.get(knownScope)!,
knownScope,
knownScopes
)) {
accum.add(requiredScope);
}
accum.add(knownScope);
return accum;
}, new Set<string>());
for (const scope of sortedKnownScopes) {
const style = convertScopedCss(scope).trimEnd();
if (!style.trim()) continue;
res +=
scope === "root"
? `injectGlobal\`${style}\n\`;\n`
: `\nexport const ${mapClassNames(
scope
)} = ${JSON5.stringify(cssToJS(style), null, 2)};\n`;
}
return res.trim();
}
// https://github.com/facebook/react/blob/3db98c917701d59f62cf1fbe45cbf01b0b61c704/packages/react-dom-bindings/src/shared/isUnitlessNumber.js#L13
const unitlessNumbers = new Set([
"animationIterationCount",
"aspectRatio",
"borderImageOutset",
"borderImageSlice",
"borderImageWidth",
"boxFlex",
"boxFlexGroup",
"boxOrdinalGroup",
"columnCount",
"columns",
"flex",
"flexGrow",
"flexPositive",
"flexShrink",
"flexNegative",
"flexOrder",
"gridArea",
"gridRow",
"gridRowEnd",
"gridRowSpan",
"gridRowStart",
"gridColumn",
"gridColumnEnd",
"gridColumnSpan",
"gridColumnStart",
"fontWeight",
"lineClamp",
"lineHeight",
"opacity",
"order",
"orphans",
"scale",
"tabSize",
"widows",
"zIndex",
"zoom",
"fillOpacity", // SVG-related properties
"floodOpacity",
"stopOpacity",
"strokeDasharray",
"strokeDashoffset",
"strokeMiterlimit",
"strokeOpacity",
"strokeWidth",
"MozAnimationIterationCount", // Known Prefixed Properties
"MozBoxFlex", // TODO: Remove these since they shouldn't be used in modern code
"MozBoxFlexGroup",
"MozLineClamp",
"msAnimationIterationCount",
"msFlex",
"msZoom",
"msFlexGrow",
"msFlexNegative",
"msFlexOrder",
"msFlexPositive",
"msFlexShrink",
"msGridColumn",
"msGridColumnSpan",
"msGridRow",
"msGridRowSpan",
"WebkitAnimationIterationCount",
"WebkitBoxFlex",
"WebKitBoxFlexGroup",
"WebkitBoxOrdinalGroup",
"WebkitColumnCount",
"WebkitColumns",
"WebkitFlex",
"WebkitFlexGrow",
"WebkitFlexPositive",
"WebkitFlexShrink",
"WebkitLineClamp",
]);
function simplifyValue(propName: string, value: string) {
const num = value.match(/^(\d+(\.\d+)?)px$/)?.[1];
if (num != null && unitlessNumbers.has(propName)) {
return parseFloat(num);
}
return value;
}
export function cssToJS(inputCssText: string): Record<string, any> {
const css = compile(inputCssText);
const result: Record<string, string> = {};
function walk(collect: Record<string, any>, node: Element) {
switch (node.type) {
case "decl": {
const prop = node.props as string;
const propName = prop.startsWith("--")
? prop
: prop.replace(/(-.)/g, v => v[1].toUpperCase());
collect[propName] = simplifyValue(propName, node.children as string);
break;
}
case "rule": {
const ruleName = node.value.replaceAll("\f", "");
const obj = ruleName === "&" ? collect : (collect[ruleName] ??= {});
for (const child of node.children as Element[]) {
walk(obj, child);
}
break;
}
case "comm":
break;
case "@media":
const media = (collect[node.value] ??= {});
for (const child of node.children as Element[]) {
walk(media, child);
}
break;
}
}
for (const node of css) {
walk(result, node);
}
return result;
}