339 lines
9.9 KiB
TypeScript
339 lines
9.9 KiB
TypeScript
// import { relative, resolve } from "path";
|
|
import type { PluginBuild } from "esbuild"
|
|
import type * as babel from "@babel/core"
|
|
import type { NodePath, types } from "@babel/core"
|
|
import { expression } from "@babel/template"
|
|
import hash from "@emotion/hash"
|
|
import { compile, serialize, stringify } from "stylis"
|
|
import { dropRightWhile } from "lodash"
|
|
|
|
const NS = "extract-css"
|
|
|
|
const runtimeArgs = ["Tag", "className", "vars"] as const
|
|
// const root = resolve(__dirname, "../..");
|
|
|
|
const shared = /* jsx */ `
|
|
({ as = Tag, className: cls, style, ...rest }, ref) => {
|
|
return _jsx(as, {
|
|
...rest,
|
|
ref,
|
|
style: vars != null ? getStyle({ ...style }, className, vars) : style,
|
|
className: cx([className, cls])
|
|
})
|
|
}
|
|
`
|
|
|
|
const runtime = /* jsx */ `
|
|
import { jsx } from "react/jsx-runtime";
|
|
import { forwardRef, memo } from "react";
|
|
import { cx } from "@emotion/css";
|
|
|
|
const supportsAs = new WeakSet();
|
|
const _jsx = jsx;
|
|
|
|
function getStyle(style, className, vars) {
|
|
for (let i = 0; i < vars.length; i++) {
|
|
const name = "--" + className + "-" + i;
|
|
const variable = vars[i];
|
|
if (variable !== null) {
|
|
style[name] = typeof variable === "function" ? variable(props) : variable;
|
|
}
|
|
}
|
|
return style;
|
|
}
|
|
|
|
export default function create(${runtimeArgs.join(", ")}) {
|
|
const Component = memo(
|
|
forwardRef(
|
|
supportsAs.has(Tag)
|
|
? ${shared.replace("as, {", "Tag, { as,")}
|
|
: ${shared}
|
|
)
|
|
);
|
|
|
|
Component.withComponent = function (Tag) {
|
|
return create(${runtimeArgs.join(", ")});
|
|
};
|
|
|
|
supportsAs.add(Component);
|
|
|
|
return Component;
|
|
}
|
|
`
|
|
|
|
const flattenArgs = (args: { [key in (typeof runtimeArgs)[number]]: string }) =>
|
|
runtimeArgs.map(key => args[key]).join(", ")
|
|
|
|
const styledComponent = expression({
|
|
// plugins: ["jsx"],
|
|
syntacticPlaceholders: true,
|
|
})(
|
|
"%%create%%(" +
|
|
flattenArgs({
|
|
className: "%%className%%",
|
|
Tag: "%%tag%%",
|
|
vars: "%%vars%%",
|
|
}) +
|
|
")",
|
|
)
|
|
|
|
const validParentTypes = new Set<types.Node["type"]>([
|
|
"ArrayExpression",
|
|
"BinaryExpression",
|
|
"CallExpression",
|
|
"JSXExpressionContainer",
|
|
"LogicalExpression",
|
|
"ObjectProperty",
|
|
"VariableDeclarator",
|
|
])
|
|
|
|
interface State extends babel.PluginPass {
|
|
styles: string[]
|
|
program: types.Program
|
|
runtimeHelper?: types.Identifier
|
|
}
|
|
|
|
export function extractCSS(build: PluginBuild, { className }: { className: string }) {
|
|
const RUNTIME = /^@extract-css\/runtime$/
|
|
|
|
build.onResolve({ filter: RUNTIME }, ({ path }) => ({ namespace: NS, path }))
|
|
|
|
build.onLoad({ filter: RUNTIME, namespace: NS }, () => ({
|
|
contents: runtime,
|
|
loader: "jsx",
|
|
resolveDir: __dirname,
|
|
}))
|
|
|
|
const plugin = ({ types: t }: typeof babel): babel.PluginObj<State> => {
|
|
const classNameMap = new WeakMap<types.Expression, string>()
|
|
|
|
function isImportedSpecifierFrom(path: NodePath, name: string, module: string) {
|
|
const declPath = path.scope.getBinding(name)!.path
|
|
return (
|
|
t.isImportSpecifier(declPath.node) &&
|
|
t.isImportDeclaration(declPath.parent) &&
|
|
declPath.parent.source.value === module
|
|
)
|
|
}
|
|
|
|
function isDefaultImportedFrom(path: NodePath, name: string, module: string) {
|
|
const binding = path.scope.getBinding(name)
|
|
if (!binding) return false
|
|
const declPath = binding.path
|
|
return (
|
|
t.isImportDefaultSpecifier(declPath.node) &&
|
|
t.isImportDeclaration(declPath.parent) &&
|
|
declPath.parent.source.value === module
|
|
)
|
|
}
|
|
|
|
function buildClassName(hash: string, componentName = "styled") {
|
|
return className.replaceAll("[hash]", hash).replaceAll("[name]", componentName)
|
|
}
|
|
|
|
function getComponentName(path: NodePath): string | undefined {
|
|
if (t.isVariableDeclarator(path.parent) && t.isIdentifier(path.parent.id)) {
|
|
return path.parent.id.name
|
|
}
|
|
}
|
|
|
|
const visitor: babel.Visitor<State> = {
|
|
TaggedTemplateExpression(path, state) {
|
|
const { styles } = state
|
|
const quasiExps = path
|
|
.get("quasi")
|
|
.get("expressions") as NodePath<types.Expression>[]
|
|
|
|
const { tag, quasi } = path.node
|
|
|
|
if (!validParentTypes.has(path.parent.type)) {
|
|
return
|
|
}
|
|
|
|
function extract(getPrefix: (className: string) => string) {
|
|
let bailout = false
|
|
const raws = quasi.quasis.map(q => q.value.raw)
|
|
const className = buildClassName(
|
|
hash(JSON.stringify(raws)),
|
|
getComponentName(path),
|
|
)
|
|
const skipInterpolations = new Set<number>()
|
|
|
|
const cssText = raws
|
|
.map((left, i) => {
|
|
if (bailout) {
|
|
return null!
|
|
}
|
|
|
|
if (i === raws.length - 1) {
|
|
return left
|
|
}
|
|
|
|
const exp = quasi.expressions[i]
|
|
const evaluated = quasiExps[i].evaluate()
|
|
if (evaluated.confident) {
|
|
if (
|
|
typeof evaluated.value === "string" ||
|
|
typeof evaluated.value === "number"
|
|
) {
|
|
skipInterpolations.add(i)
|
|
return left + String(evaluated.value)
|
|
}
|
|
}
|
|
|
|
if (t.isIdentifier(exp)) {
|
|
const binding = path.scope.getBinding(exp.name)
|
|
if (binding) {
|
|
const { node } = binding.path
|
|
if (t.isVariableDeclarator(node)) {
|
|
const cls = classNameMap.get(node.init!)
|
|
if (cls) {
|
|
skipInterpolations.add(i)
|
|
return left + cls
|
|
}
|
|
if (t.isStringLiteral(node.init)) {
|
|
skipInterpolations.add(i)
|
|
return left + node.init.value
|
|
}
|
|
}
|
|
|
|
bailout = true
|
|
}
|
|
} else if (t.isStringLiteral(exp)) {
|
|
skipInterpolations.add(i)
|
|
return left + exp.value
|
|
}
|
|
|
|
return `${left}var(--${className}-${i})`
|
|
})
|
|
.join("")
|
|
|
|
const compiled = compile(`${getPrefix(className)}{${cssText}}}`)
|
|
|
|
return {
|
|
className,
|
|
skipInterpolations,
|
|
bailout,
|
|
style: serialize(compiled, stringify),
|
|
}
|
|
}
|
|
|
|
function processStyled(tag: types.Expression) {
|
|
const { className, skipInterpolations, style, bailout } = extract(c => `.${c}`)
|
|
if (bailout) {
|
|
return
|
|
}
|
|
|
|
styles.push(style)
|
|
|
|
if (!state.runtimeHelper) {
|
|
state.runtimeHelper = path.scope.generateUidIdentifier("create")
|
|
state.program.body.unshift(
|
|
t.importDeclaration(
|
|
[t.importDefaultSpecifier(state.runtimeHelper)],
|
|
t.stringLiteral("@extract-css/runtime"),
|
|
),
|
|
)
|
|
}
|
|
|
|
const fn = styledComponent({
|
|
create: state.runtimeHelper,
|
|
className: t.stringLiteral(className),
|
|
tag:
|
|
t.isIdentifier(tag) && /^[a-z]/.test(tag.name)
|
|
? t.stringLiteral(tag.name)
|
|
: tag,
|
|
vars: quasi.expressions.length
|
|
? t.arrayExpression(
|
|
dropRightWhile(
|
|
quasi.expressions.map((e, i) =>
|
|
skipInterpolations.has(i)
|
|
? t.nullLiteral()
|
|
: (e as types.Expression),
|
|
),
|
|
n => t.isNullLiteral(n),
|
|
),
|
|
)
|
|
: t.nullLiteral(),
|
|
})
|
|
classNameMap.set(fn, className)
|
|
const [newPath] = path.replaceWith(fn)
|
|
newPath.addComment("leading", " @__PURE__ ")
|
|
}
|
|
|
|
if (t.isIdentifier(tag) && tag.name === "keyframes") {
|
|
if (!isImportedSpecifierFrom(path, tag.name, "@emotion/css")) {
|
|
return
|
|
}
|
|
|
|
const { className, style, bailout } = extract(c => `@keyframes ${c}`)
|
|
if (bailout) {
|
|
return
|
|
}
|
|
styles.push(style)
|
|
path.replaceWith(t.stringLiteral(className))
|
|
} else if (t.isIdentifier(tag) && tag.name === "css") {
|
|
if (!isImportedSpecifierFrom(path, tag.name, "@emotion/css")) {
|
|
return
|
|
}
|
|
|
|
const { className, style, bailout } = extract(c => `.${c}`)
|
|
if (bailout) {
|
|
return
|
|
}
|
|
styles.push(style)
|
|
path.replaceWith(t.stringLiteral(className))
|
|
} else if (
|
|
t.isMemberExpression(tag) &&
|
|
t.isIdentifier(tag.object, { name: "styled" }) &&
|
|
!t.isPrivateName(tag.property)
|
|
) {
|
|
if (!isDefaultImportedFrom(path, tag.object.name, "@emotion/styled")) {
|
|
return
|
|
}
|
|
processStyled(tag.property)
|
|
} else if (
|
|
t.isCallExpression(tag) &&
|
|
t.isIdentifier(tag.callee, { name: "styled" }) &&
|
|
tag.arguments.length === 1 &&
|
|
t.isExpression(tag.arguments[0])
|
|
) {
|
|
if (!isDefaultImportedFrom(path, tag.callee.name, "@emotion/styled")) {
|
|
return
|
|
}
|
|
processStyled(tag.arguments[0])
|
|
}
|
|
},
|
|
}
|
|
|
|
return {
|
|
name: "plugin",
|
|
visitor: {
|
|
Program: {
|
|
enter(program, state) {
|
|
state.styles = []
|
|
state.program = program.node
|
|
},
|
|
exit(program, state) {
|
|
program.traverse(visitor, state)
|
|
|
|
if (state.styles.length) {
|
|
const css =
|
|
// `/*./${relative(root, state.filename!)}*/` +
|
|
state.styles.join("\n")
|
|
program.node.body.unshift(
|
|
t.importDeclaration(
|
|
[],
|
|
t.stringLiteral(`data:text/css,${encodeURI(css)}`),
|
|
),
|
|
)
|
|
}
|
|
},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
return plugin
|
|
}
|