Add style object support
This commit is contained in:
289
src/css-to-js.ts
Executable file
289
src/css-to-js.ts
Executable 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;
|
||||
}
|
Reference in New Issue
Block a user