344 lines
8.9 KiB
TypeScript
Executable File
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;
|
|
}
|