Add style object support

This commit is contained in:
Alex
2024-07-02 21:09:10 -04:00
parent 835c5b7810
commit 398f2a7c69
17 changed files with 896 additions and 435 deletions

289
src/css-to-js.ts Executable file
View File

@ -0,0 +1,289 @@
// MIT License. Copyright (c) 2017 Brice BERNARD
// https://github.com/brikou/CSS-in-JS-generator/commit/2a887d0d96f1d5044039d0e0457001f0fde0def0
import {
type AtRule,
type Builder,
type ChildNode,
type Node,
type Root,
type Rule,
parse,
} from "postcss";
import parseSelector from "postcss-selector-parser";
import Stringifier from "postcss/lib/stringifier";
import { camelCase } from "lodash";
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 (!cssIndexedByScope.has(scope)) {
cssIndexedByScope.set(scope, "");
}
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;
}
function convertSelectorForEmotion(
selector: string,
scope: string,
knownScopes: Set<string>,
mapClassNames: (className: string) => string
): string {
return 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);
}
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"
);
function convertScopedCssForEmotion(
scopedCss: string,
scope: string,
knownScopes: Set<string>,
mapClassNames: (className: string) => string
): string {
let scopedCssForEmotion = "";
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 =>
convertSelectorForEmotion(selector, scope, knownScopes, mapClassNames)
)
);
// TODO remove join usage once https://github.com/prettier/prettier/issues/2883 is resolved
output = `${[...selectors].join(", ")} {`;
}
}
scopedCssForEmotion += output;
});
return scopedCssForEmotion.replace(/\\/g, "\\\\").replace(/`/g, "\\`");
}
export function convertCssToJS(
css: string,
mapClassNames: (className: string) => string = convertScopeToModuleName
): string {
let cssForEmotion = "";
const cssIndexedByScope = getCssIndexedByScope(css);
if (cssIndexedByScope.has("root") && cssIndexedByScope.get("root")!.trim()) {
cssForEmotion += 'import { injectGlobal } from "@emotion/css";\n';
}
const knownScopes = new Set(cssIndexedByScope.keys());
const collator = new Intl.Collator(undefined, {
numeric: true,
sensitivity: "base",
});
const sortedKnownScopes = [...knownScopes]
.sort((scopeA, scopeB) => (scopeA === "root" ? -1 : collator.compare(scopeA, scopeB)))
.reduce((previousSortedKnownScopes, knownScope) => {
for (const requiredScope of getRequiredScopes(
cssIndexedByScope.get(knownScope)!,
knownScope,
knownScopes
)) {
previousSortedKnownScopes.add(requiredScope);
}
previousSortedKnownScopes.add(knownScope);
return previousSortedKnownScopes;
}, new Set<string>());
for (const scope of sortedKnownScopes) {
cssForEmotion += "\n";
const convertedScopedCssForEmotion = convertScopedCssForEmotion(
cssIndexedByScope.get(scope)!,
scope,
knownScopes,
mapClassNames
).trimEnd();
if (!convertedScopedCssForEmotion.trim()) continue;
cssForEmotion +=
scope === "root"
? `injectGlobal\`${convertedScopedCssForEmotion}\n\`;\n`
: `\nexport const ${mapClassNames(
scope
)} = ${JSON.stringify(asJSObject(convertedScopedCssForEmotion), null, 2)};\n`;
}
return cssForEmotion.trim();
}
const candidates = new Set(["fontSize"]);
function simplifyValue(propName: string, value: string) {
const num = value.match(/^(\d+(\.\d+)?)px$/)?.[1];
if (num != null && candidates.has(propName)) {
return parseFloat(num);
}
return value;
}
function asJSObject(inputCssText: string) {
const css = parse(`a{${inputCssText}}`);
const result: Record<string, string> = {};
if (css.nodes.length !== 1) {
throw new Error("Expected exactly one root node");
}
const node = css.first!;
if (node.type !== "rule") return;
function walk(collect: Record<string, any>, node: ChildNode) {
switch (node.type) {
case "atrule":
const obj = (collect[`@${node.name} ${node.params}`] ??= {});
node.each(child => {
walk(obj, child);
});
break;
case "decl":
const propName = node.prop.replace(/(-.)/g, v => v[1].toUpperCase());
collect[propName] = simplifyValue(propName, node.value);
break;
case "rule":
node.each(declaration => {
walk(collect, declaration);
});
break;
case "comment":
break;
}
}
walk(result, node);
return result as React.CSSProperties;
}