From 6431b4edde173d4b03731b3ba7d19b38e05afe0d Mon Sep 17 00:00:00 2001 From: Jiyuan Zheng Date: Wed, 25 Feb 2026 12:02:37 +0800 Subject: [PATCH 01/13] feat: scaffold extract runtime mode for static styles --- src/core/index.ts | 1 + src/core/styleEngine.ts | 55 +++++++++++++++++++++++ src/factories/createStaticStyles/index.ts | 35 ++++++++++++--- src/factories/createStaticStyles/types.ts | 9 ++++ src/functions/index.ts | 17 ++++++- 5 files changed, 111 insertions(+), 6 deletions(-) create mode 100644 src/core/styleEngine.ts diff --git a/src/core/index.ts b/src/core/index.ts index a2543fb0..f6940ff3 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -2,5 +2,6 @@ export * from './CacheManager'; export * from './createCSS'; export * from './createEmotion'; export { serializeCSS, type SerializeCSS } from './createSerializeStyles'; +export * from './styleEngine'; export const DEFAULT_CSS_PREFIX_KEY = 'acss'; diff --git a/src/core/styleEngine.ts b/src/core/styleEngine.ts new file mode 100644 index 00000000..4f242ade --- /dev/null +++ b/src/core/styleEngine.ts @@ -0,0 +1,55 @@ +import type { BaseReturnType } from '@/types'; + +/** + * Runtime mode for style generation. + * + * - runtime: keep current emotion insertion behavior + * - extract: consume pre-extracted className map (zero-runtime path) + */ +export type StyleRuntimeMode = 'runtime' | 'extract'; + +/** + * Internal extracted style payload. + * + * Note: this is a minimal scaffold for the upcoming extractor plugin. + */ +export interface ExtractedStylePayload { + /** + * Style id generated by compile-time plugin. + */ + styleId: string; + /** + * className map aligned with createStaticStyles return shape. + */ + styles: T; +} + +interface StyleEngineStore { + mode: StyleRuntimeMode; + extracted: Map; +} + +const store: StyleEngineStore = { + mode: 'runtime', + extracted: new Map(), +}; + +export const getStyleRuntimeMode = (): StyleRuntimeMode => store.mode; + +export const setStyleRuntimeMode = (mode: StyleRuntimeMode) => { + store.mode = mode; +}; + +export const registerExtractedStyles = ( + payload: ExtractedStylePayload, +) => { + store.extracted.set(payload.styleId, payload.styles); +}; + +export const getExtractedStyles = (styleId: string): T | undefined => { + return store.extracted.get(styleId) as T | undefined; +}; + +export const clearExtractedStyles = () => { + store.extracted.clear(); +}; diff --git a/src/factories/createStaticStyles/index.ts b/src/factories/createStaticStyles/index.ts index c5f11d24..374afbfa 100644 --- a/src/factories/createStaticStyles/index.ts +++ b/src/factories/createStaticStyles/index.ts @@ -1,11 +1,17 @@ -import { createCSS, createEmotion, DEFAULT_CSS_PREFIX_KEY } from '@/core'; +import { + createCSS, + createEmotion, + DEFAULT_CSS_PREFIX_KEY, + getExtractedStyles, + getStyleRuntimeMode, +} from '@/core'; import type { EmotionCache } from '@emotion/css/create-instance'; import type { BaseReturnType, HashPriority } from '@/types'; import { cssVar, CSSVarMap, generateCSSVarMap } from './cssVar'; import { responsive, StaticResponsiveMap } from './responsive'; -import type { StaticStylesInput, StaticStyleUtils } from './types'; +import type { StaticStylesInput, StaticStylesOptions, StaticStyleUtils } from './types'; /** * createStaticStyles 的配置选项 @@ -32,7 +38,10 @@ export interface CreateStaticStylesOptions { * 工厂函数返回类型 */ export interface StaticStylesInstance { - createStaticStyles: (stylesFn: StaticStylesInput) => T; + createStaticStyles: ( + stylesFn: StaticStylesInput, + options?: StaticStylesOptions, + ) => T; cssVar: CSSVarMap; responsive: StaticResponsiveMap; } @@ -74,7 +83,17 @@ export const createStaticStylesFactory = ( // 创建 css 和 cx 函数 const { css, cx } = createCSS(emotionCache, { hashPriority }); - const createStaticStyles = (stylesFn: StaticStylesInput): T => { + const createStaticStyles = ( + stylesFn: StaticStylesInput, + options?: StaticStylesOptions, + ): T => { + // Extract mode: consume pre-compiled className map first. + // If no extracted payload found, gracefully fall back to runtime path. + if (getStyleRuntimeMode() === 'extract' && options?.styleId) { + const extracted = getExtractedStyles(options.styleId); + if (extracted) return extracted; + } + const utils: StaticStyleUtils = { css, cx, @@ -136,4 +155,10 @@ export const createStaticStyles = defaultInstance.createStaticStyles; // 导出类型和工具 export { cssVar, generateCSSVarMap, responsive }; -export type { CSSVarMap, StaticResponsiveMap, StaticStylesInput, StaticStyleUtils }; +export type { + CSSVarMap, + StaticResponsiveMap, + StaticStylesInput, + StaticStylesOptions, + StaticStyleUtils, +}; diff --git a/src/factories/createStaticStyles/types.ts b/src/factories/createStaticStyles/types.ts index 250b9a14..b841100d 100644 --- a/src/factories/createStaticStyles/types.ts +++ b/src/factories/createStaticStyles/types.ts @@ -29,3 +29,12 @@ export interface StaticStyleUtils { * createStaticStyles 的输入函数类型 */ export type StaticStylesInput = (utils: StaticStyleUtils) => T; + +/** + * Internal options for createStaticStyles. + * + * `styleId` is reserved for compile-time extractor to inject stable ids. + */ +export interface StaticStylesOptions { + styleId?: string; +} diff --git a/src/functions/index.ts b/src/functions/index.ts index 0064fcb0..9ae3b673 100644 --- a/src/functions/index.ts +++ b/src/functions/index.ts @@ -1,10 +1,25 @@ -import { DEFAULT_CSS_PREFIX_KEY } from '@/core'; +import { + clearExtractedStyles, + DEFAULT_CSS_PREFIX_KEY, + getStyleRuntimeMode, + registerExtractedStyles, + setStyleRuntimeMode, +} from '@/core'; import { createInstance } from './createInstance'; export { extractStaticStyle } from './extractStaticStyle'; export { setupStyled } from './setupStyled'; export { createInstance }; +// experimental: zero-runtime extract controls +export { + clearExtractedStyles, + getStyleRuntimeMode, + registerExtractedStyles, + setStyleRuntimeMode, +}; +export type { ExtractedStylePayload, StyleRuntimeMode } from '@/core'; + // 静态样式工厂函数(用于创建自定义实例) export { createStaticStylesFactory } from '@/factories/createStaticStyles'; From 76e29aa2ffc341970f23b165e1e243bad6ca2e68 Mon Sep 17 00:00:00 2001 From: Jiyuan Zheng Date: Wed, 25 Feb 2026 12:32:21 +0800 Subject: [PATCH 02/13] feat: add extracted-style manifest hydration API --- src/extract/hydrateExtractedStyles.ts | 25 +++++++++++++++++++++++++ src/extract/index.ts | 2 ++ src/extract/types.ts | 26 ++++++++++++++++++++++++++ src/index.ts | 1 + 4 files changed, 54 insertions(+) create mode 100644 src/extract/hydrateExtractedStyles.ts create mode 100644 src/extract/index.ts create mode 100644 src/extract/types.ts diff --git a/src/extract/hydrateExtractedStyles.ts b/src/extract/hydrateExtractedStyles.ts new file mode 100644 index 00000000..58546bdd --- /dev/null +++ b/src/extract/hydrateExtractedStyles.ts @@ -0,0 +1,25 @@ +import { registerExtractedStyles, setStyleRuntimeMode } from '@/core'; + +import type { ExtractStyleManifest } from './types'; + +/** + * Hydrate compile-time extracted style map into antd-style runtime. + * + * Typical usage (app bootstrap): + * + * ```ts + * import manifest from './__antd-style.extract.manifest.json'; + * import { hydrateExtractedStyles } from 'antd-style'; + * + * hydrateExtractedStyles(manifest); + * ``` + */ +export const hydrateExtractedStyles = (manifest: ExtractStyleManifest) => { + if (!manifest || manifest.version !== 1) return; + + setStyleRuntimeMode('extract'); + + for (const entry of manifest.entries) { + registerExtractedStyles({ styleId: entry.styleId, styles: entry.styles }); + } +}; diff --git a/src/extract/index.ts b/src/extract/index.ts new file mode 100644 index 00000000..966b6e38 --- /dev/null +++ b/src/extract/index.ts @@ -0,0 +1,2 @@ +export { hydrateExtractedStyles } from './hydrateExtractedStyles'; +export type { ExtractStyleEntry, ExtractStyleManifest } from './types'; diff --git a/src/extract/types.ts b/src/extract/types.ts new file mode 100644 index 00000000..31f022b2 --- /dev/null +++ b/src/extract/types.ts @@ -0,0 +1,26 @@ +import type { BaseReturnType } from '@/types'; + +/** + * Single extracted style entry for one createStaticStyles call. + */ +export interface ExtractStyleEntry { + /** + * Stable id injected at compile time. + */ + styleId: string; + /** + * Final className map consumed by createStaticStyles in extract mode. + */ + styles: T; +} + +/** + * Bundle-level extracted style manifest. + * + * CSS asset emission is handled by bundler plugins. + * This manifest only carries runtime class maps. + */ +export interface ExtractStyleManifest { + version: 1; + entries: ExtractStyleEntry[]; +} diff --git a/src/index.ts b/src/index.ts index 90045b26..b982dd5a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ export type { CacheManager } from './core/CacheManager'; +export * from './extract'; export * from './factories/createStaticStyles/types'; export * from './factories/createStyles/types'; export * from './factories/createThemeProvider/type'; From f5e26985cb90afef43c46cfdd71627671fec672b Mon Sep 17 00:00:00 2001 From: Jiyuan Zheng Date: Wed, 25 Feb 2026 12:35:41 +0800 Subject: [PATCH 03/13] feat: add styleId injection scaffold for extract pipeline --- src/extract/ROADMAP.md | 27 +++++++++++++ src/extract/babelInjectStyleId.ts | 64 +++++++++++++++++++++++++++++++ src/extract/index.ts | 2 + src/extract/styleId.ts | 13 +++++++ 4 files changed, 106 insertions(+) create mode 100644 src/extract/ROADMAP.md create mode 100644 src/extract/babelInjectStyleId.ts create mode 100644 src/extract/styleId.ts diff --git a/src/extract/ROADMAP.md b/src/extract/ROADMAP.md new file mode 100644 index 00000000..0426311f --- /dev/null +++ b/src/extract/ROADMAP.md @@ -0,0 +1,27 @@ +# Extract Pipeline Roadmap (MVP) + +This folder contains the building blocks for zero-runtime extraction. + +## Implemented + +- Runtime extract-mode registry (`core/styleEngine.ts`) +- Manifest hydration API (`hydrateExtractedStyles`) +- Experimental Babel pass to inject stable `styleId` for `createStaticStyles` + +## Next + +1. Build plugin + - Collect transformed modules with `styleId` + - Evaluate/serialize static CSS + - Emit CSS assets + JSON manifest +2. Next/Webpack integration + - Auto-import emitted CSS + - Auto-hydrate manifest during app bootstrap +3. Fallback policy + - Missing manifest entry => runtime mode fallback + - Diagnostics in dev for unextracted callsites + +## Notes + +The Babel plugin in this folder only injects callsite metadata. +It does **not** emit CSS assets by itself. diff --git a/src/extract/babelInjectStyleId.ts b/src/extract/babelInjectStyleId.ts new file mode 100644 index 00000000..629bfb02 --- /dev/null +++ b/src/extract/babelInjectStyleId.ts @@ -0,0 +1,64 @@ +import { createStyleId } from './styleId'; + +interface BabelState { + file?: { + opts?: { + filename?: string; + }; + }; +} + +/** + * Experimental Babel plugin: + * injects `{ styleId: "..." }` as second argument for + * `createStaticStyles(stylesFn)` calls imported from `antd-style`. + * + * NOTE: This plugin only annotates callsites; CSS extraction/emit is handled + * by bundler-side plugins in later stages. + */ +export const babelInjectStyleId = ({ types: t }: any) => ({ + name: 'antd-style-babel-inject-style-id', + visitor: { + Program(path: any, state: BabelState) { + const filename = state.file?.opts?.filename || 'unknown'; + const localNames = new Set(); + let callIndex = 0; + + for (const nodePath of path.get('body')) { + if (!nodePath.isImportDeclaration()) continue; + if (nodePath.node.source.value !== 'antd-style') continue; + + for (const specifier of nodePath.node.specifiers) { + if ( + t.isImportSpecifier(specifier) && + t.isIdentifier(specifier.imported) && + specifier.imported.name === 'createStaticStyles' + ) { + localNames.add(specifier.local.name); + } + } + } + + if (localNames.size === 0) return; + + path.traverse({ + CallExpression(callPath: any) { + const callee = callPath.node.callee; + if (!t.isIdentifier(callee) || !localNames.has(callee.name)) return; + + // skip if options already provided + if (callPath.node.arguments.length >= 2) return; + + const styleId = createStyleId(`${filename}#${callIndex++}`); + callPath.node.arguments.push( + t.objectExpression([ + t.objectProperty(t.identifier('styleId'), t.stringLiteral(styleId)), + ]), + ); + }, + }); + }, + }, +}); + +export default babelInjectStyleId; diff --git a/src/extract/index.ts b/src/extract/index.ts index 966b6e38..15ad1a61 100644 --- a/src/extract/index.ts +++ b/src/extract/index.ts @@ -1,2 +1,4 @@ +export { babelInjectStyleId } from './babelInjectStyleId'; export { hydrateExtractedStyles } from './hydrateExtractedStyles'; +export { createStyleId } from './styleId'; export type { ExtractStyleEntry, ExtractStyleManifest } from './types'; diff --git a/src/extract/styleId.ts b/src/extract/styleId.ts new file mode 100644 index 00000000..896c664a --- /dev/null +++ b/src/extract/styleId.ts @@ -0,0 +1,13 @@ +/** + * Generate stable style id for createStaticStyles call sites. + * + * This id is designed to be injected by compile-time transforms. + */ +export const createStyleId = (input: string) => { + let hash = 5381; + for (let i = 0; i < input.length; i++) { + hash = (hash * 33) ^ input.charCodeAt(i); + } + // keep positive 32-bit and compact base36 + return `as-${(hash >>> 0).toString(36)}`; +}; From d95f62d48644247a88915a8a27bafcbe60ccdb1a Mon Sep 17 00:00:00 2001 From: Jiyuan Zheng Date: Wed, 25 Feb 2026 13:25:15 +0800 Subject: [PATCH 04/13] feat: add webpack emit plugin scaffold for extract assets --- src/extract/ROADMAP.md | 1 + src/extract/index.ts | 7 +++- src/extract/types.ts | 11 ++++++ src/extract/webpackEmitPlugin.ts | 68 ++++++++++++++++++++++++++++++++ 4 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 src/extract/webpackEmitPlugin.ts diff --git a/src/extract/ROADMAP.md b/src/extract/ROADMAP.md index 0426311f..ab37b8c6 100644 --- a/src/extract/ROADMAP.md +++ b/src/extract/ROADMAP.md @@ -7,6 +7,7 @@ This folder contains the building blocks for zero-runtime extraction. - Runtime extract-mode registry (`core/styleEngine.ts`) - Manifest hydration API (`hydrateExtractedStyles`) - Experimental Babel pass to inject stable `styleId` for `createStaticStyles` +- Experimental webpack emit plugin scaffold (`AntdStyleExtractWebpackPlugin`) ## Next diff --git a/src/extract/index.ts b/src/extract/index.ts index 15ad1a61..20c557ad 100644 --- a/src/extract/index.ts +++ b/src/extract/index.ts @@ -1,4 +1,9 @@ export { babelInjectStyleId } from './babelInjectStyleId'; export { hydrateExtractedStyles } from './hydrateExtractedStyles'; export { createStyleId } from './styleId'; -export type { ExtractStyleEntry, ExtractStyleManifest } from './types'; +export { AntdStyleExtractWebpackPlugin } from './webpackEmitPlugin'; +export type { + ExtractStyleEntry, + ExtractStyleManifest, + AntdStyleExtractWebpackPluginOptions, +} from './types'; diff --git a/src/extract/types.ts b/src/extract/types.ts index 31f022b2..08db722d 100644 --- a/src/extract/types.ts +++ b/src/extract/types.ts @@ -24,3 +24,14 @@ export interface ExtractStyleManifest { version: 1; entries: ExtractStyleEntry[]; } + +export interface AntdStyleExtractWebpackPluginOptions { + /** Output manifest file name */ + manifestFile?: string; + /** Output css file name (placeholder scaffold) */ + cssFile?: string; + /** + * Extracted entries provided by external transform step. + */ + getEntries?: () => ExtractStyleManifest['entries']; +} diff --git a/src/extract/webpackEmitPlugin.ts b/src/extract/webpackEmitPlugin.ts new file mode 100644 index 00000000..1619ed46 --- /dev/null +++ b/src/extract/webpackEmitPlugin.ts @@ -0,0 +1,68 @@ +import type { Compiler } from 'webpack'; + +import type { + AntdStyleExtractWebpackPluginOptions, + ExtractStyleManifest, +} from './types'; + +/** + * Experimental webpack plugin for zero-runtime pipeline. + * + * Current capability: + * - emits manifest json + * - emits placeholder css file + * + * Next phase: + * - collect css text + class map from transformed modules + * - emit real css assets per chunk + */ +export class AntdStyleExtractWebpackPlugin { + private options: Required< + Pick + > & + Omit; + + constructor(options: AntdStyleExtractWebpackPluginOptions = {}) { + this.options = { + manifestFile: options.manifestFile || '__antd-style.extract.manifest.json', + cssFile: options.cssFile || '__antd-style.extract.css', + getEntries: options.getEntries, + }; + } + + apply(compiler: Compiler) { + const pluginName = 'AntdStyleExtractWebpackPlugin'; + + compiler.hooks.thisCompilation.tap(pluginName, (compilation: any) => { + const { Compilation, sources } = compiler.webpack as any; + + compilation.hooks.processAssets.tap( + { + name: pluginName, + stage: Compilation.PROCESS_ASSETS_STAGE_ADDITIONS, + }, + () => { + const entries = this.options.getEntries?.() || []; + + const manifest: ExtractStyleManifest = { + version: 1, + entries, + }; + + compilation.emitAsset( + this.options.manifestFile, + new sources.RawSource(JSON.stringify(manifest, null, 2)), + ); + + // Placeholder stylesheet. Real css extraction lands in next phase. + compilation.emitAsset( + this.options.cssFile, + new sources.RawSource('/* antd-style extract css (placeholder) */\n'), + ); + }, + ); + }); + } +} + +export default AntdStyleExtractWebpackPlugin; From 15a672a9625f11fff90d15852ba728d7414a6a87 Mon Sep 17 00:00:00 2001 From: Jiyuan Zheng Date: Wed, 25 Feb 2026 13:59:07 +0800 Subject: [PATCH 05/13] feat: wire extract collector into webpack asset emission --- src/extract/collector.ts | 23 +++++++++++++++++++++++ src/extract/index.ts | 1 + src/extract/webpackEmitPlugin.ts | 14 +++++++++++--- 3 files changed, 35 insertions(+), 3 deletions(-) create mode 100644 src/extract/collector.ts diff --git a/src/extract/collector.ts b/src/extract/collector.ts new file mode 100644 index 00000000..4de8d7b0 --- /dev/null +++ b/src/extract/collector.ts @@ -0,0 +1,23 @@ +import type { BaseReturnType } from '@/types'; + +interface ExtractedChunk { + styleId: string; + styles: BaseReturnType; + cssText: string; +} + +const chunks = new Map(); + +export const pushExtractedChunk = (chunk: ExtractedChunk) => { + chunks.set(chunk.styleId, chunk); +}; + +export const pullExtractedChunks = (): ExtractedChunk[] => { + return Array.from(chunks.values()); +}; + +export const clearExtractedChunks = () => { + chunks.clear(); +}; + +export type { ExtractedChunk }; diff --git a/src/extract/index.ts b/src/extract/index.ts index 20c557ad..75a6a2d1 100644 --- a/src/extract/index.ts +++ b/src/extract/index.ts @@ -1,4 +1,5 @@ export { babelInjectStyleId } from './babelInjectStyleId'; +export { clearExtractedChunks, pullExtractedChunks, pushExtractedChunk } from './collector'; export { hydrateExtractedStyles } from './hydrateExtractedStyles'; export { createStyleId } from './styleId'; export { AntdStyleExtractWebpackPlugin } from './webpackEmitPlugin'; diff --git a/src/extract/webpackEmitPlugin.ts b/src/extract/webpackEmitPlugin.ts index 1619ed46..b491b8d5 100644 --- a/src/extract/webpackEmitPlugin.ts +++ b/src/extract/webpackEmitPlugin.ts @@ -1,5 +1,6 @@ import type { Compiler } from 'webpack'; +import { pullExtractedChunks } from './collector'; import type { AntdStyleExtractWebpackPluginOptions, ExtractStyleManifest, @@ -42,7 +43,13 @@ export class AntdStyleExtractWebpackPlugin { stage: Compilation.PROCESS_ASSETS_STAGE_ADDITIONS, }, () => { - const entries = this.options.getEntries?.() || []; + const collected = pullExtractedChunks(); + const entries = + this.options.getEntries?.() || + collected.map(({ styleId, styles }) => ({ + styleId, + styles, + })); const manifest: ExtractStyleManifest = { version: 1, @@ -54,10 +61,11 @@ export class AntdStyleExtractWebpackPlugin { new sources.RawSource(JSON.stringify(manifest, null, 2)), ); - // Placeholder stylesheet. Real css extraction lands in next phase. + const cssText = collected.map((c) => c.cssText).filter(Boolean).join('\n'); + compilation.emitAsset( this.options.cssFile, - new sources.RawSource('/* antd-style extract css (placeholder) */\n'), + new sources.RawSource(cssText || '/* antd-style extract css (empty) */\n'), ); }, ); From 432baed815932290cb25099634ff2a3b9e11c7f7 Mon Sep 17 00:00:00 2001 From: Jiyuan Zheng Date: Wed, 25 Feb 2026 16:03:45 +0800 Subject: [PATCH 06/13] feat: add runtime collector bridge for extract prototype --- src/extract/ROADMAP.md | 1 + src/factories/createStaticStyles/index.ts | 38 ++++++++++++++++++++++- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/src/extract/ROADMAP.md b/src/extract/ROADMAP.md index ab37b8c6..21586849 100644 --- a/src/extract/ROADMAP.md +++ b/src/extract/ROADMAP.md @@ -8,6 +8,7 @@ This folder contains the building blocks for zero-runtime extraction. - Manifest hydration API (`hydrateExtractedStyles`) - Experimental Babel pass to inject stable `styleId` for `createStaticStyles` - Experimental webpack emit plugin scaffold (`AntdStyleExtractWebpackPlugin`) +- Experimental runtime collector bridge (`ANTD_STYLE_EXTRACT_COLLECT=1`) ## Next diff --git a/src/factories/createStaticStyles/index.ts b/src/factories/createStaticStyles/index.ts index 374afbfa..1959cc91 100644 --- a/src/factories/createStaticStyles/index.ts +++ b/src/factories/createStaticStyles/index.ts @@ -101,7 +101,43 @@ export const createStaticStylesFactory = ( responsive, }; - return stylesFn(utils); + const res = stylesFn(utils); + + // Experimental runtime collector bridge: + // when enabled, collect generated class map + css text into extract collector. + if (options?.styleId && process.env.ANTD_STYLE_EXTRACT_COLLECT === '1') { + try { + // lazy import to avoid hard coupling and potential circular init + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { pushExtractedChunk } = require('@/extract/collector') as typeof import('@/extract/collector'); + + const classNames = new Set(); + for (const value of Object.values(res as any)) { + if (typeof value !== 'string') continue; + for (const cls of value.split(/\s+/).filter(Boolean)) classNames.add(cls); + } + + const cssParts: string[] = []; + for (const cls of classNames) { + const hash = cls.startsWith(`${emotionCache.key}-`) + ? cls.slice(emotionCache.key.length + 1) + : undefined; + if (!hash) continue; + const inserted = (emotionCache.inserted as any)?.[hash]; + if (typeof inserted === 'string') cssParts.push(inserted); + } + + pushExtractedChunk({ + styleId: options.styleId, + styles: res, + cssText: cssParts.join('\n'), + }); + } catch { + // ignore collector errors in runtime path + } + } + + return res; }; return { From 9140d7520136e22a769232a7a71bcb5c9e195a68 Mon Sep 17 00:00:00 2001 From: Jiyuan Zheng Date: Thu, 26 Feb 2026 17:48:06 +0800 Subject: [PATCH 07/13] docs: add branch changelog for zero-runtime prototype --- BRANCH_CHANGELOG.md | 68 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 BRANCH_CHANGELOG.md diff --git a/BRANCH_CHANGELOG.md b/BRANCH_CHANGELOG.md new file mode 100644 index 00000000..c615e901 --- /dev/null +++ b/BRANCH_CHANGELOG.md @@ -0,0 +1,68 @@ +# Branch Changelog - feat/zero-runtime-prototype + +This file tracks prototype-only changes made on branch `feat/zero-runtime-prototype`. + +## Summary + +Goal: validate a zero-runtime extraction architecture for `antd-style` while keeping existing APIs largely intact. + +## Included commits + +- `6431b4e` feat: scaffold extract runtime mode for static styles +- `76e29aa` feat: add extracted-style manifest hydration API +- `f5e2698` feat: add styleId injection scaffold for extract pipeline +- `d95f62d` feat: add webpack emit plugin scaffold for extract assets +- `15a672a` feat: wire extract collector into webpack asset emission +- `432baed` feat: add runtime collector bridge for extract prototype + +## Key changes + +### 1) Extract runtime mode scaffold +- Added runtime mode switch: `runtime | extract` +- Added extracted class-map registry APIs: + - `setStyleRuntimeMode` + - `registerExtractedStyles` + - `getExtractedStyles` + - `clearExtractedStyles` + +### 2) `createStaticStyles` extract path +- `createStaticStyles(stylesFn, { styleId })` now supports extract-mode lookup by `styleId` +- Falls back to existing runtime behavior when no extracted entry exists + +### 3) Manifest hydration API +- Added `hydrateExtractedStyles(manifest)` +- Loads `styleId -> styles` map and switches runtime mode to `extract` + +### 4) styleId injection scaffold +- Added `createStyleId` +- Added experimental Babel plugin `babelInjectStyleId` to annotate `createStaticStyles` calls + +### 5) Webpack emit scaffold +- Added `AntdStyleExtractWebpackPlugin` +- Emits: + - `__antd-style.extract.manifest.json` + - `__antd-style.extract.css` + +### 6) Collector bridge (prototype) +- Added collector APIs: + - `pushExtractedChunk` + - `pullExtractedChunks` + - `clearExtractedChunks` +- Added experimental runtime bridge in `createStaticStyles` behind env flag: + - `ANTD_STYLE_EXTRACT_COLLECT=1` + +## Current prototype status + +Validated: +- style-id based extract protocol and runtime consumption path +- manifest hydration flow +- webpack asset emit scaffold and collector integration + +Not yet production-ready: +- full static compile-time extraction (SWC/loader evaluator) +- chunk-level ordering/dedup guarantees +- full Next/Turbopack integration and DX polish + +## Suggested next step + +Replace runtime collector bridge with true transform-time static extraction and wire emitted CSS per chunk. From cc008502ca9e96111344a68385752ffcd9af092e Mon Sep 17 00:00:00 2001 From: Jiyuan Zheng Date: Fri, 27 Feb 2026 11:23:37 +0800 Subject: [PATCH 08/13] feat: implement zero-runtime extract pipeline and ci verification --- .github/workflows/test.yml | 5 + ZERO_RUNTIME_TASKS.md | 169 +++++++++ package.json | 3 +- scripts/verify-extract-prototype.cjs | 383 ++++++++++++++++++++ src/extract/ROADMAP.md | 15 +- src/extract/babelInjectStyleId.ts | 342 ++++++++++++++++- src/extract/collector.ts | 6 +- src/extract/compiledCollector.ts | 15 + src/extract/core.ts | 62 ++++ src/extract/index.ts | 20 +- src/extract/styleId.ts | 17 +- src/extract/types.ts | 92 ++++- src/extract/viteEmitPlugin.ts | 170 +++++++++ src/extract/webpackEmitPlugin.ts | 211 +++++++++-- src/factories/createStaticStyles/index.ts | 9 +- src/factories/createStyleProvider/index.tsx | 4 +- src/functions/createInstance.ts | 4 +- src/functions/extractStaticStyle.tsx | 5 +- 18 files changed, 1470 insertions(+), 62 deletions(-) create mode 100644 ZERO_RUNTIME_TASKS.md create mode 100644 scripts/verify-extract-prototype.cjs create mode 100644 src/extract/compiledCollector.ts create mode 100644 src/extract/core.ts create mode 100644 src/extract/viteEmitPlugin.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a8e1a8a0..5660e2d8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -26,5 +26,10 @@ jobs: - name: Test and coverage run: pnpm run test:coverage + - name: Build and verify extract prototype + run: | + pnpm run build + pnpm run verify:extract-prototype + - name: Upload coverage to Codecov uses: codecov/codecov-action@v3 diff --git a/ZERO_RUNTIME_TASKS.md b/ZERO_RUNTIME_TASKS.md new file mode 100644 index 00000000..b636ecc7 --- /dev/null +++ b/ZERO_RUNTIME_TASKS.md @@ -0,0 +1,169 @@ +# antd-style 零运行时提取(执行任务单) + +> 基于 `feat/zero-runtime-prototype`,目标是把当前 runtime collector 原型桥接替换为纯编译期提取,并在 LobeHub(Next + Vite 混合)最小接线验证。 + +## 0. 当前基线(必须先完成) + +- [ ] 清理临时产物:`.tmp-extract-webpack/` +- [ ] 保持当前原型能力可回归: + - `hydrateExtractedStyles` + - `AntdStyleExtractWebpackPlugin` + - `createStaticStyles(..., { styleId })` +- [ ] 固化验证入口(Node 脚本)用于回归: + - collector 行为 + - extract 命中 / fallback + - webpack emit 产物 + +建议命令: + +```bash +npm run build +node scripts/verify-extract-prototype.cjs +``` + +--- + +## 1. P0 - 纯编译期提取替换 runtime collector + +### 1.1 styleId 生成规则(稳定性) + +**目标**:跨机器、跨时间、跨 CI 稳定。 + +- [ ] 规范 `styleId` 组成:`hash(filePath + exportName/callsite + salt)` +- [ ] 增加可配置 salt(如 `process.env.ANTD_STYLE_EXTRACT_SALT`) +- [ ] 文档化 styleId 冲突处理 + +涉及文件(建议): + +- `src/extract/styleId.ts` +- `src/extract/types.ts` +- `src/extract/ROADMAP.md` + +验收:同一源码在两次构建 `styleId` 一致。 + +--- + +### 1.2 编译期提取器(Webpack 路径) + +**目标**:不依赖 `ANTD_STYLE_EXTRACT_COLLECT`,直接从模块源码/AST 提取。 + +- [ ] 新增 webpack loader/plugin transform: + - 识别 `createStaticStyles` 调用 + - 注入/校验 `styleId` + - 收集 `styleId -> classMap + cssText` 到 compilation 上下文 +- [ ] 替换当前 `collector.ts` runtime bridge 依赖 +- [ ] 无法静态求值的表达式打标 fallback,不阻塞构建 + +涉及文件(建议): + +- `src/extract/webpackEmitPlugin.ts` +- `src/extract/collector.ts`(改为编译期收集容器或废弃) +- `src/extract/babelInjectStyleId.ts`(复用 AST 逻辑) +- `src/factories/createStaticStyles/index.ts`(移除 runtime collector bridge) + +验收:关闭 `ANTD_STYLE_EXTRACT_COLLECT` 后仍能生成 manifest/css。 + +--- + +### 1.3 静态求值边界(先白名单) + +**目标**:先可控、再扩展。 + +- [ ] 支持: + - 字面量模板字符串 + - `cssVar` 映射 + - 简单常量拼接 +- [ ] 不支持时 fallback: + - 打 dev warning(含 file + styleId + reason) + - 运行时仍可正确渲染 + +涉及文件(建议): + +- `src/extract/types.ts` +- `src/extract/ROADMAP.md` + +验收:复杂动态表达式不会导致白屏/构建失败。 + +--- + +## 2. P1 - 接线(Next + Vite 双路径) + +## 2.1 LobeHub Next 路径(auth/backend) + +- [ ] 在 `next.config.ts` 注入提取插件(按开关启用) +- [ ] 根注水:`hydrateExtractedStyles(manifest)` +- [ ] CSS 资产确保进入页面 + +涉及文件(lobehub): + +- `next.config.ts` +- `src/layout/GlobalProvider/StyleRegistry.tsx` +- `src/layout/GlobalProvider/AppTheme.tsx` + +--- + +## 2.2 LobeHub Vite SPA 路径(主流量) + +- [ ] 提供 Vite/Rollup 等价 emit 插件: + - 输出 `__antd-style.extract.manifest.json` + - 输出 `__antd-style.extract.css` +- [ ] 在 SPA 启动注水(`SPAGlobalProvider`) +- [ ] 将 extract.css 注入 HTML(`transformIndexHtml` 或 runtime link) + +涉及文件(lobehub-vite4): + +- `vite.config.ts` +- `src/layout/SPAGlobalProvider/index.tsx` +- `scripts/generateSpaTemplates.mts` +- `src/app/spa/[variants]/[[...path]]/route.ts` + +验收:SPA 构建产物包含 extract 资产,且首屏样式正常。 + +--- + +## 3. P2 - 验证与指标 + +### 3.1 功能验证 + +- [ ] 产物存在:manifest + css +- [ ] manifest 包含 styleId 条目 +- [ ] 命中 styleId 使用 extract classMap +- [ ] 缺条目 fallback 正常 +- [ ] SSR/CSR className 一致,无明显 FOUC +- [ ] 深浅色切换正确 + +### 3.2 性能验证(粗测) + +- [ ] style tag 注入次数下降 +- [ ] hydration 样式相关 JS 时间下降 +- [ ] 首屏稳定性提升(Chrome Performance 对比) + +建议输出: + +- `docs/extract-benchmark.md`(前后对比截图 + 数据) + +--- + +## 4. P3 - 迁移与治理 + +- [ ] 先覆盖 `createStaticStyles` +- [ ] `createStyles` 维持 fallback,后续拆分静态/动态子集 +- [ ] 提供 lint 规则:缺失 styleId 提示 +- [ ] 提供 codemod:批量补 styleId(可选) + +--- + +## 5. 风险与防线(必须落地) + +- [ ] 跨 chunk 顺序和去重策略 +- [ ] Turbopack / webpack / Vite 行为差异隔离 +- [ ] source map 与调试体验 +- [ ] 观测:fallback 计数与缺失 styleId 日志 + +--- + +## 6. 建议里程碑 + +- **M1(本周)**:Webpack 纯编译期提取替代 runtime collector +- **M2(下周)**:Vite 等价链路 + SPA 注水 +- **M3(下周)**:性能对比与迁移文档 diff --git a/package.json b/package.json index a6d3d848..ad9d79d5 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,8 @@ "test": "vitest", "test:coverage": "vitest run --coverage", "test:update": "vitest -u", - "type-check": "tsc -p tsconfig-check.json" + "type-check": "tsc -p tsconfig-check.json", + "verify:extract-prototype": "node scripts/verify-extract-prototype.cjs" }, "lint-staged": { "*.{md,json}": [ diff --git a/scripts/verify-extract-prototype.cjs b/scripts/verify-extract-prototype.cjs new file mode 100644 index 00000000..b9fa09e3 --- /dev/null +++ b/scripts/verify-extract-prototype.cjs @@ -0,0 +1,383 @@ +/* eslint-disable no-console */ +const fs = require('node:fs'); +const path = require('node:path'); +const assert = require('node:assert/strict'); + +const pkgRoot = process.cwd(); +const outDir = path.join(pkgRoot, '.tmp-extract-webpack'); + +const m = require('../lib'); + +function verifyStyleIdStability() { + const idA1 = m.createStyleId('src/a.ts#0', { salt: 'x' }); + const idA2 = m.createStyleId('src/a.ts#0', { salt: 'x' }); + const idB = m.createStyleId('src/a.ts#0', { salt: 'y' }); + + assert.equal(idA1, idA2, 'same input + same salt should be stable'); + assert.notEqual(idA1, idB, 'different salt should produce different id'); + + console.log('✅ styleId stability verified'); +} + +function verifyExtractCore() { + const { manifest, cssText } = m.buildExtractAssets([ + { + styleId: 'b-style', + styles: { root: 'b' }, + cssText: '.b{color:blue;}', + order: 2, + }, + { + styleId: 'a-style', + styles: { root: 'a' }, + cssText: '.a{color:red;}', + order: 1, + }, + { + styleId: 'c-style', + styles: { root: 'c' }, + cssText: '.a{color:red;}', + order: 3, + }, + ]); + + assert.deepEqual( + manifest.entries.map((e) => e.styleId), + ['a-style', 'b-style', 'c-style'], + 'manifest entries should be sorted by styleId', + ); + assert.ok( + cssText.indexOf('.a{color:red;}') < cssText.indexOf('.b{color:blue;}'), + 'css should respect order in chunks', + ); + assert.equal(cssText.match(/\.a\{color:red;\}/g)?.length, 1, 'css should be deduped by default'); + + console.log('✅ extract core build verified'); +} + +function verifyBabelStaticCollection() { + let babel; + try { + babel = require('@babel/core'); + } catch { + console.log('⚠️ @babel/core not found, skip babel static collection verification'); + return; + } + + m.clearCompiledExtractChunks(); + m.resetStaticCollectState(); + + const source = ` + import { createStaticStyles } from 'antd-style'; + + const styles = createStaticStyles(({ css, cssVar }) => ({ + a: css\`color: red;\`, + b: css\`margin: 0; &:hover { margin: 1px; }\`, + c: css\`border-color: \${cssVar.colorText};\`, + })); + + export { styles }; + `; + + babel.transformSync(source, { + babelrc: false, + configFile: false, + filename: `${process.cwd().replace(/\\/g, '/')}/src/__verify__/sample.ts`, + plugins: [[m.babelInjectStyleId, { collectStatic: true, rootDir: process.cwd() }]], + }); + + const compiledChunks = m.pullCompiledExtractChunks(); + + assert.equal(compiledChunks.length, 1, 'babel static collection should push one compiled chunk'); + assert.ok(compiledChunks[0].styleId.startsWith('as-')); + assert.deepEqual(Object.keys(compiledChunks[0].styles), ['a', 'b', 'c']); + assert.ok(compiledChunks[0].cssText.includes('.acss-')); + assert.ok( + compiledChunks[0].cssText.includes('var(--ant-color-text)'), + 'babel static collection should resolve cssVar interpolation', + ); + + console.log('✅ babel static collection verified'); +} + +function verifyViteEmitPlugin() { + m.clearCompiledExtractChunks(); + m.resetStaticCollectState(); + + const plugin = m.AntdStyleExtractVitePlugin({ + experimentalStaticCollect: true, + staticCollect: { + include: /sample\.tsx$/, + rootDir: process.cwd(), + }, + }); + + plugin.buildStart?.(); + + const transformed = plugin.transform?.( + ` + import { createStaticStyles } from 'antd-style'; + export const styles = createStaticStyles(({ css, cssVar }) => ({ + item: css\`padding: 8px; border-color: \${cssVar.colorBorder};\`, + })); + `, + `${process.cwd().replace(/\\/g, '/')}/src/__verify__/sample.tsx`, + ); + + assert.ok( + transformed && typeof transformed === 'object' && transformed.code.includes('styleId'), + 'vite transform should inject styleId when static collect enabled', + ); + + const emitted = []; + plugin.generateBundle?.call( + { + emitFile(asset) { + emitted.push(asset); + }, + }, + {}, + {}, + ); + + const manifestAsset = emitted.find((asset) => asset.fileName.endsWith('.manifest.json')); + const cssAsset = emitted.find((asset) => asset.fileName.endsWith('.css')); + + assert.ok(manifestAsset, 'vite plugin should emit manifest asset'); + assert.ok(cssAsset, 'vite plugin should emit css asset'); + + const manifest = JSON.parse(manifestAsset.source); + assert.equal(manifest.version, 1); + assert.equal(manifest.entries.length, 1); + assert.ok(manifest.entries[0].styleId.startsWith('as-')); + assert.ok(typeof cssAsset.source === 'string' && cssAsset.source.includes('.acss-')); + assert.ok( + typeof cssAsset.source === 'string' && cssAsset.source.includes('var(--ant-color-border)'), + 'vite static collect should resolve cssVar interpolation', + ); + + console.log('✅ vite emit plugin verified'); +} + +function verifyCollectorAndHydration() { + process.env.ANTD_STYLE_EXTRACT_COLLECT = '1'; + + m.clearExtractedChunks(); + + const runtimeStyles = m.createStaticStyles( + ({ css }) => ({ + btn: css` + color: red; + `, + }), + { styleId: 'verify-style-id' }, + ); + + const chunks = m.pullExtractedChunks(); + + assert.equal(chunks.length, 1, 'collector should collect exactly one chunk'); + assert.equal(chunks[0].styleId, 'verify-style-id'); + assert.ok( + chunks[0].cssText && chunks[0].cssText.length > 0, + 'collector cssText should be non-empty', + ); + + m.hydrateExtractedStyles({ + version: 1, + entries: [{ styleId: 'verify-style-id', styles: { btn: 'from-manifest' } }], + }); + + const extractedStyles = m.createStaticStyles( + ({ css }) => ({ + btn: css` + color: blue; + `, + }), + { styleId: 'verify-style-id' }, + ); + + const fallbackStyles = m.createStaticStyles( + ({ css }) => ({ + box: css` + margin: 4px; + `, + }), + { styleId: 'verify-unknown-style-id' }, + ); + + assert.equal(m.getStyleRuntimeMode(), 'extract', 'runtime mode should switch to extract'); + assert.equal(extractedStyles.btn, 'from-manifest', 'known styleId should come from manifest'); + assert.notEqual( + fallbackStyles.box, + 'from-manifest', + 'unknown styleId should fallback to runtime generated class', + ); + + assert.ok(runtimeStyles.btn && runtimeStyles.btn.length > 0, 'runtime style should be generated'); + + console.log('✅ collector + hydration + fallback verified'); +} + +async function runWebpackBuild(webpack, config) { + const { + outputDirName, + plugins, + entrySource = 'module.exports = {}\n', + externals, + resolve, + } = config; + + const buildDir = path.join(outDir, outputDirName); + fs.rmSync(buildDir, { force: true, recursive: true }); + fs.mkdirSync(buildDir, { recursive: true }); + + const entryFile = path.join(buildDir, 'entry.js'); + fs.writeFileSync(entryFile, entrySource, 'utf8'); + + const compiler = webpack({ + entry: entryFile, + mode: 'production', + output: { + filename: 'bundle.js', + path: buildDir, + }, + externals, + plugins, + resolve, + }); + + await new Promise((resolve, reject) => { + compiler.run((err, stats) => { + if (err) return reject(err); + if (stats && stats.hasErrors()) { + return reject(new Error(stats.toString('errors-only'))); + } + return resolve(); + }); + }); + + await new Promise((resolve) => compiler.close(() => resolve())); + + return buildDir; +} + +function readExtractAssets(buildDir) { + const manifestPath = path.join(buildDir, '__antd-style.extract.manifest.json'); + const cssPath = path.join(buildDir, '__antd-style.extract.css'); + + assert.ok(fs.existsSync(manifestPath), 'manifest file should exist'); + assert.ok(fs.existsSync(cssPath), 'css file should exist'); + + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + const css = fs.readFileSync(cssPath, 'utf8'); + + assert.equal(manifest.version, 1, 'manifest version should be 1'); + assert.ok(Array.isArray(manifest.entries), 'manifest entries should be array'); + + return { manifest, css }; +} + +function assertExtractAssets(buildDir, expectedStyleId, expectedCssPart) { + const { manifest, css } = readExtractAssets(buildDir); + + assert.equal(manifest.entries[0].styleId, expectedStyleId); + assert.ok(css.includes(expectedCssPart), 'css file should include expected extracted rule'); +} + +async function verifyWebpackEmit() { + let webpack; + try { + webpack = require('webpack'); + } catch { + console.log('⚠️ webpack not found, skip webpack emit verification'); + return; + } + + fs.rmSync(outDir, { force: true, recursive: true }); + fs.mkdirSync(outDir, { recursive: true }); + + // default path: compiled collector + m.clearCompiledExtractChunks(); + m.pushCompiledExtractChunk({ + styleId: 'verify-compiled-style-id', + styles: { root: 'acss-compiled' }, + cssText: '.acss-compiled{color:green;}', + }); + + const compiledBuildDir = await runWebpackBuild(webpack, { + outputDirName: 'compiled', + plugins: [new m.AntdStyleExtractWebpackPlugin()], + }); + assertExtractAssets(compiledBuildDir, 'verify-compiled-style-id', '.acss-compiled'); + + // legacy path: runtime collector (explicit opt-in) + m.clearCompiledExtractChunks(); + m.clearExtractedChunks(); + m.pushExtractedChunk({ + styleId: 'verify-runtime-style-id', + styles: { root: 'acss-runtime' }, + cssText: '.acss-runtime{color:red;}', + }); + + const runtimeBuildDir = await runWebpackBuild(webpack, { + outputDirName: 'runtime', + plugins: [new m.AntdStyleExtractWebpackPlugin({ useRuntimeCollector: true })], + }); + assertExtractAssets(runtimeBuildDir, 'verify-runtime-style-id', '.acss-runtime'); + + // compilation static collect path + m.clearCompiledExtractChunks(); + + const staticCollectBuildDir = await runWebpackBuild(webpack, { + entrySource: ` + import { createStaticStyles } from 'antd-style'; + const styles = createStaticStyles(({ css }) => ({ + btn: css\`color: purple;\`, + })); + console.log(styles); + `, + externals: { + 'antd-style': 'commonjs antd-style', + }, + outputDirName: 'static-collect', + plugins: [ + new m.AntdStyleExtractWebpackPlugin({ + experimentalStaticCollect: true, + staticCollect: { + include: /entry\.js$/, + rootDir: process.cwd(), + }, + }), + ], + }); + + const { manifest, css } = readExtractAssets(staticCollectBuildDir); + assert.equal( + manifest.entries.length, + 1, + 'experimental static collect should produce one manifest entry', + ); + assert.ok( + manifest.entries[0].styleId.startsWith('as-'), + 'experimental static collect should inject stable styleId', + ); + assert.ok(css.includes('.acss-'), 'experimental static collect should emit emotion css'); + + console.log('✅ webpack emit verified'); +} + +(async () => { + try { + verifyStyleIdStability(); + verifyExtractCore(); + verifyBabelStaticCollection(); + verifyViteEmitPlugin(); + verifyCollectorAndHydration(); + await verifyWebpackEmit(); + console.log('🎉 extract prototype verification passed'); + } catch (error) { + console.error('❌ extract prototype verification failed'); + console.error(error); + process.exit(1); + } +})(); diff --git a/src/extract/ROADMAP.md b/src/extract/ROADMAP.md index 21586849..8d3f2b02 100644 --- a/src/extract/ROADMAP.md +++ b/src/extract/ROADMAP.md @@ -7,16 +7,25 @@ This folder contains the building blocks for zero-runtime extraction. - Runtime extract-mode registry (`core/styleEngine.ts`) - Manifest hydration API (`hydrateExtractedStyles`) - Experimental Babel pass to inject stable `styleId` for `createStaticStyles` + - supports optional `salt` (or `ANTD_STYLE_EXTRACT_SALT`) to avoid cross-project collisions + - supports strict static collection mode (`collectStatic`) for template-literal `css` rules + - supports limited interpolation static evaluation for `cssVar.xxx` expressions +- Extract asset assembly core (`buildExtractAssets`) for manifest/css generation - Experimental webpack emit plugin scaffold (`AntdStyleExtractWebpackPlugin`) -- Experimental runtime collector bridge (`ANTD_STYLE_EXTRACT_COLLECT=1`) + - supports compilation static collect mode (`experimentalStaticCollect`) +- Experimental vite emit plugin scaffold (`AntdStyleExtractVitePlugin`) + - supports transform static collect mode (`experimentalStaticCollect`) +- Compiled collector bridge for bundler transforms (`compiledCollector.ts`) +- Experimental runtime collector bridge (`ANTD_STYLE_EXTRACT_COLLECT=1`, legacy fallback) ## Next -1. Build plugin +1. Build adapters - Collect transformed modules with `styleId` - Evaluate/serialize static CSS + - Feed chunks into extract core (`buildExtractAssets`) - Emit CSS assets + JSON manifest -2. Next/Webpack integration +2. Bundler integration - Auto-import emitted CSS - Auto-hydrate manifest during app bootstrap 3. Fallback policy diff --git a/src/extract/babelInjectStyleId.ts b/src/extract/babelInjectStyleId.ts index 629bfb02..69067471 100644 --- a/src/extract/babelInjectStyleId.ts +++ b/src/extract/babelInjectStyleId.ts @@ -1,30 +1,334 @@ +import createEmotion from '@emotion/css/create-instance'; + +import { pushCompiledExtractChunk } from './compiledCollector'; import { createStyleId } from './styleId'; +interface BabelPluginOptions { + collectStatic?: boolean; + emotionKey?: string; + /** + * Prefix used to resolve cssVar.xxx interpolations in static collection. + * @default 'ant' + */ + cssVarPrefix?: string; + rootDir?: string; + salt?: string; +} + interface BabelState { file?: { opts?: { filename?: string; }; }; + opts?: BabelPluginOptions; +} + +interface StaticCollectContext { + cssTagIdentifier: string; + cssVarIdentifier?: string; + cssVarPrefix: string; } +let staticChunkOrder = 0; + +const emotionCollectors = new Map>(); + +export const resetStaticCollectState = () => { + staticChunkOrder = 0; + emotionCollectors.clear(); +}; + +const getEmotionCollector = (key: string) => { + const cached = emotionCollectors.get(key); + if (cached) return cached; + + const created = createEmotion({ key }); + emotionCollectors.set(key, created); + return created; +}; + +const toKebabCase = (str: string): string => + str + .replace(/([a-z])([A-Z])/g, '$1-$2') + .replace(/([a-z])(\d)/g, '$1-$2') + .replace(/(\d)([A-Z])/g, '$1-$2') + .replace(/([A-Z]+)([A-Z][a-z])/g, '$1-$2') + .toLowerCase(); + +const resolveCssVarInterpolation = (tokenName: string, prefix = 'ant') => { + const kebab = toKebabCase(tokenName); + if (!kebab) return undefined; + + if (prefix !== 'ant') { + return `var(--${prefix}-${kebab}, var(--ant-${kebab}))`; + } + + return `var(--${prefix}-${kebab})`; +}; + +const getObjectPatternBindingName = (t: any, stylesFnNode: any, propertyName: string) => { + const firstParam = stylesFnNode?.params?.[0]; + if (!firstParam || !t.isObjectPattern(firstParam)) return undefined; + + for (const property of firstParam.properties) { + if (!t.isObjectProperty(property)) continue; + + const keyName = + t.isIdentifier(property.key) || t.isStringLiteral(property.key) + ? property.key.name || property.key.value + : undefined; + + if (keyName !== propertyName) continue; + + if (t.isIdentifier(property.value)) return property.value.name; + + if (t.isAssignmentPattern(property.value) && t.isIdentifier(property.value.left)) { + return property.value.left.name; + } + } + + return undefined; +}; + +const getStaticCollectContext = ( + t: any, + stylesFnNode: any, + cssVarPrefix: string, +): StaticCollectContext | undefined => { + const cssTagIdentifier = getObjectPatternBindingName(t, stylesFnNode, 'css'); + if (!cssTagIdentifier) return undefined; + + const cssVarIdentifier = getObjectPatternBindingName(t, stylesFnNode, 'cssVar'); + + return { + cssTagIdentifier, + cssVarIdentifier, + cssVarPrefix, + }; +}; + +const getObjectExpressionFromReturn = (t: any, stylesFnNode: any) => { + if ( + (t.isArrowFunctionExpression(stylesFnNode) || t.isFunctionExpression(stylesFnNode)) && + t.isObjectExpression(stylesFnNode.body) + ) { + return stylesFnNode.body; + } + + if ( + (t.isArrowFunctionExpression(stylesFnNode) || t.isFunctionExpression(stylesFnNode)) && + t.isBlockStatement(stylesFnNode.body) + ) { + for (const statement of stylesFnNode.body.body) { + if (!t.isReturnStatement(statement)) continue; + if (!statement.argument || !t.isObjectExpression(statement.argument)) continue; + return statement.argument; + } + } + + return undefined; +}; + +const getMemberPropertyName = (t: any, expression: any): string | undefined => { + if (t.isIdentifier(expression.property) && !expression.computed) { + return expression.property.name; + } + + if (t.isStringLiteral(expression.property)) { + return expression.property.value; + } + + if (expression.computed && t.isTemplateLiteral(expression.property)) { + if (expression.property.expressions.length > 0 || expression.property.quasis.length !== 1) { + return undefined; + } + + return expression.property.quasis[0].value.cooked ?? expression.property.quasis[0].value.raw; + } + + return undefined; +}; + +const resolveTemplateExpression = ( + t: any, + expression: any, + context: StaticCollectContext, +): string | undefined => { + if (t.isStringLiteral(expression)) return expression.value; + if (t.isNumericLiteral(expression)) return String(expression.value); + if (t.isBooleanLiteral(expression)) return String(expression.value); + if (t.isNullLiteral(expression)) return 'null'; + + if (t.isTemplateLiteral(expression)) { + if (expression.expressions.length > 0) return undefined; + + return expression.quasis.map((quasi: any) => quasi.value.cooked ?? quasi.value.raw).join(''); + } + + const isOptionalMemberExpression = + typeof t.isOptionalMemberExpression === 'function' && t.isOptionalMemberExpression(expression); + + if (t.isMemberExpression(expression) || isOptionalMemberExpression) { + if (!context.cssVarIdentifier) return undefined; + + const objectName = t.isIdentifier(expression.object) ? expression.object.name : undefined; + if (objectName !== context.cssVarIdentifier) return undefined; + + const propertyName = getMemberPropertyName(t, expression); + if (!propertyName) return undefined; + + return resolveCssVarInterpolation(propertyName, context.cssVarPrefix); + } + + return undefined; +}; + +const buildCssSourceFromTemplate = ( + t: any, + quasi: any, + context: StaticCollectContext, +): string | undefined => { + let cssSource = ''; + + for (let i = 0; i < quasi.quasis.length; i += 1) { + const quasiNode = quasi.quasis[i]; + cssSource += quasiNode.value.cooked ?? quasiNode.value.raw; + + if (i >= quasi.expressions.length) continue; + + const expression = quasi.expressions[i]; + const resolved = resolveTemplateExpression(t, expression, context); + if (typeof resolved !== 'string') return undefined; + + cssSource += resolved; + } + + return cssSource; +}; + +const getStyleIdFromOptions = (t: any, optionsNode: any): string | undefined => { + if (!optionsNode || !t.isObjectExpression(optionsNode)) return undefined; + + for (const property of optionsNode.properties) { + if (!t.isObjectProperty(property)) continue; + if (!t.isIdentifier(property.key) || property.key.name !== 'styleId') continue; + if (t.isStringLiteral(property.value)) return property.value.value; + } + + return undefined; +}; + +const collectStaticChunk = ( + t: any, + callPath: any, + styleId: string, + emotionKey: string, + cssVarPrefix: string, +): boolean => { + const stylesFnNode = callPath.node.arguments[0]; + if (!stylesFnNode) return false; + + const context = getStaticCollectContext(t, stylesFnNode, cssVarPrefix); + if (!context) return false; + + const returnedObject = getObjectExpressionFromReturn(t, stylesFnNode); + if (!returnedObject) return false; + + const collector = getEmotionCollector(emotionKey); + + const styles: Record = {}; + const cssParts: string[] = []; + const cssSeen = new Set(); + + for (const property of returnedObject.properties) { + if (!t.isObjectProperty(property)) return false; + + const key = + t.isIdentifier(property.key) || t.isStringLiteral(property.key) + ? property.key.name || property.key.value + : undefined; + if (!key) return false; + + if (!t.isTaggedTemplateExpression(property.value)) return false; + if ( + !t.isIdentifier(property.value.tag) || + property.value.tag.name !== context.cssTagIdentifier + ) { + return false; + } + + const cssSource = buildCssSourceFromTemplate(t, property.value.quasi, context); + if (!cssSource || !cssSource.trim()) return false; + + const className = collector.css(cssSource); + const hash = className.startsWith(`${emotionKey}-`) + ? className.slice(emotionKey.length + 1) + : undefined; + if (!hash) return false; + + const cssText = collector.cache.inserted?.[hash]; + if (typeof cssText !== 'string' || !cssText.trim()) return false; + + styles[key] = className; + + if (!cssSeen.has(cssText)) { + cssSeen.add(cssText); + cssParts.push(cssText); + } + } + + if (Object.keys(styles).length === 0) return false; + + pushCompiledExtractChunk({ + cssText: cssParts.join('\n'), + order: staticChunkOrder++, + styleId, + styles, + }); + + return true; +}; + /** * Experimental Babel plugin: * injects `{ styleId: "..." }` as second argument for * `createStaticStyles(stylesFn)` calls imported from `antd-style`. * - * NOTE: This plugin only annotates callsites; CSS extraction/emit is handled - * by bundler-side plugins in later stages. + * With `collectStatic: true`, it also performs a strict static collection pass: + * - only supports object-literal returns + * - supports plain template literals and limited `cssVar.xxx` interpolations + * - unsupported callsites are skipped and should fallback at runtime */ export const babelInjectStyleId = ({ types: t }: any) => ({ name: 'antd-style-babel-inject-style-id', visitor: { - Program(path: any, state: BabelState) { + Program(pathNode: any, state: BabelState) { const filename = state.file?.opts?.filename || 'unknown'; + const { + collectStatic = false, + emotionKey = 'acss', + cssVarPrefix = 'ant', + rootDir, + salt, + } = state.opts || {}; + + const normalizedFile = filename.replace(/\\/g, '/'); + const normalizedRoot = rootDir?.replace(/\\/g, '/'); + const normalizedRootWithSlash = normalizedRoot?.endsWith('/') + ? normalizedRoot + : normalizedRoot + ? `${normalizedRoot}/` + : undefined; + const relativeFile = + normalizedRootWithSlash && normalizedFile.startsWith(normalizedRootWithSlash) + ? normalizedFile.slice(normalizedRootWithSlash.length) + : normalizedFile; + const localNames = new Set(); let callIndex = 0; - for (const nodePath of path.get('body')) { + for (const nodePath of pathNode.get('body')) { if (!nodePath.isImportDeclaration()) continue; if (nodePath.node.source.value !== 'antd-style') continue; @@ -41,20 +345,32 @@ export const babelInjectStyleId = ({ types: t }: any) => ({ if (localNames.size === 0) return; - path.traverse({ + pathNode.traverse({ CallExpression(callPath: any) { const callee = callPath.node.callee; if (!t.isIdentifier(callee) || !localNames.has(callee.name)) return; - // skip if options already provided - if (callPath.node.arguments.length >= 2) return; + if (callPath.node.arguments.length === 0) return; - const styleId = createStyleId(`${filename}#${callIndex++}`); - callPath.node.arguments.push( - t.objectExpression([ - t.objectProperty(t.identifier('styleId'), t.stringLiteral(styleId)), - ]), - ); + if (callPath.node.arguments.length < 2) { + const injectedStyleId = createStyleId(`${relativeFile}#${callIndex++}`, { salt }); + callPath.node.arguments.push( + t.objectExpression([ + t.objectProperty(t.identifier('styleId'), t.stringLiteral(injectedStyleId)), + ]), + ); + } + + if (!collectStatic) return; + + const styleId = getStyleIdFromOptions(t, callPath.node.arguments[1]); + if (!styleId) return; + + try { + collectStaticChunk(t, callPath, styleId, emotionKey, cssVarPrefix); + } catch { + // static collection is best-effort; fallback path remains runtime safe + } }, }); }, diff --git a/src/extract/collector.ts b/src/extract/collector.ts index 4de8d7b0..153c6661 100644 --- a/src/extract/collector.ts +++ b/src/extract/collector.ts @@ -1,8 +1,6 @@ -import type { BaseReturnType } from '@/types'; +import type { ExtractStyleChunk } from './types'; -interface ExtractedChunk { - styleId: string; - styles: BaseReturnType; +interface ExtractedChunk extends ExtractStyleChunk { cssText: string; } diff --git a/src/extract/compiledCollector.ts b/src/extract/compiledCollector.ts new file mode 100644 index 00000000..5c448d1d --- /dev/null +++ b/src/extract/compiledCollector.ts @@ -0,0 +1,15 @@ +import type { ExtractStyleChunk } from './types'; + +const compiledChunks = new Map(); + +export const pushCompiledExtractChunk = (chunk: ExtractStyleChunk) => { + compiledChunks.set(chunk.styleId, chunk); +}; + +export const pullCompiledExtractChunks = (): ExtractStyleChunk[] => { + return Array.from(compiledChunks.values()); +}; + +export const clearCompiledExtractChunks = () => { + compiledChunks.clear(); +}; diff --git a/src/extract/core.ts b/src/extract/core.ts new file mode 100644 index 00000000..8bfaf840 --- /dev/null +++ b/src/extract/core.ts @@ -0,0 +1,62 @@ +import type { ExtractAssetBuildOptions, ExtractAssetBuildResult, ExtractStyleChunk } from './types'; + +const DEFAULT_EMPTY_CSS_TEXT = '/* antd-style extract css (empty) */\n'; + +export const buildExtractAssets = ( + chunks: ExtractStyleChunk[], + options: ExtractAssetBuildOptions = {}, +): ExtractAssetBuildResult => { + const { + dedupeCss = true, + sortManifestEntries = true, + emptyCssText = DEFAULT_EMPTY_CSS_TEXT, + } = options; + + const normalized = chunks + .map((chunk, index) => ({ + ...chunk, + _index: index, + _order: chunk.order ?? index, + })) + .sort((a, b) => { + if (a._order !== b._order) return a._order - b._order; + return a._index - b._index; + }); + + const entriesByStyleId = new Map(); + const cssParts: string[] = []; + const cssSeen = new Set(); + + for (const chunk of normalized) { + entriesByStyleId.set(chunk.styleId, chunk); + + if (!chunk.cssText) continue; + + const cssText = chunk.cssText.trim(); + if (!cssText) continue; + + if (dedupeCss) { + if (cssSeen.has(cssText)) continue; + cssSeen.add(cssText); + } + + cssParts.push(cssText); + } + + const entries = Array.from(entriesByStyleId.values()).map(({ styleId, styles }) => ({ + styleId, + styles, + })); + + if (sortManifestEntries) { + entries.sort((a, b) => a.styleId.localeCompare(b.styleId)); + } + + return { + cssText: cssParts.length > 0 ? `${cssParts.join('\n')}\n` : emptyCssText, + manifest: { + version: 1, + entries, + }, + }; +}; diff --git a/src/extract/index.ts b/src/extract/index.ts index 75a6a2d1..57ec4691 100644 --- a/src/extract/index.ts +++ b/src/extract/index.ts @@ -1,10 +1,24 @@ -export { babelInjectStyleId } from './babelInjectStyleId'; +export { babelInjectStyleId, resetStaticCollectState } from './babelInjectStyleId'; export { clearExtractedChunks, pullExtractedChunks, pushExtractedChunk } from './collector'; +export { + clearCompiledExtractChunks, + pullCompiledExtractChunks, + pushCompiledExtractChunk, +} from './compiledCollector'; +export { buildExtractAssets } from './core'; export { hydrateExtractedStyles } from './hydrateExtractedStyles'; export { createStyleId } from './styleId'; -export { AntdStyleExtractWebpackPlugin } from './webpackEmitPlugin'; export type { + AntdStyleExtractPluginCommonOptions, + AntdStyleExtractVitePluginOptions, + AntdStyleExtractWebpackPluginOptions, + ExtractAssetBuildOptions, + ExtractAssetBuildResult, + ExtractStyleChunk, ExtractStyleEntry, ExtractStyleManifest, - AntdStyleExtractWebpackPluginOptions, + StaticCollectOptions, + StaticCollectRule, } from './types'; +export { AntdStyleExtractVitePlugin } from './viteEmitPlugin'; +export { AntdStyleExtractWebpackPlugin } from './webpackEmitPlugin'; diff --git a/src/extract/styleId.ts b/src/extract/styleId.ts index 896c664a..2fb4371a 100644 --- a/src/extract/styleId.ts +++ b/src/extract/styleId.ts @@ -1,13 +1,22 @@ +export interface CreateStyleIdOptions { + salt?: string; +} + /** * Generate stable style id for createStaticStyles call sites. * - * This id is designed to be injected by compile-time transforms. + * Stability source = transform input + optional salt. + * Salt priority: explicit options.salt > ANTD_STYLE_EXTRACT_SALT env. */ -export const createStyleId = (input: string) => { +export const createStyleId = (input: string, options?: CreateStyleIdOptions) => { + const salt = options?.salt ?? process.env.ANTD_STYLE_EXTRACT_SALT ?? ''; + const source = `${salt}::${input}`; + let hash = 5381; - for (let i = 0; i < input.length; i++) { - hash = (hash * 33) ^ input.charCodeAt(i); + for (let i = 0; i < source.length; i++) { + hash = (hash * 33) ^ source.charCodeAt(i); } + // keep positive 32-bit and compact base36 return `as-${(hash >>> 0).toString(36)}`; }; diff --git a/src/extract/types.ts b/src/extract/types.ts index 08db722d..dbbfd1b6 100644 --- a/src/extract/types.ts +++ b/src/extract/types.ts @@ -14,6 +14,21 @@ export interface ExtractStyleEntry { styles: T; } +/** + * Extended entry used during build emission. + */ +export interface ExtractStyleChunk + extends ExtractStyleEntry { + /** + * Extracted CSS text for this style entry. + */ + cssText?: string; + /** + * Optional stable order for deterministic CSS concatenation. + */ + order?: number; +} + /** * Bundle-level extracted style manifest. * @@ -25,13 +40,86 @@ export interface ExtractStyleManifest { entries: ExtractStyleEntry[]; } -export interface AntdStyleExtractWebpackPluginOptions { +export interface ExtractAssetBuildOptions { + /** + * Sort manifest entries by styleId for deterministic output. + * @default true + */ + sortManifestEntries?: boolean; + /** + * Dedupe identical css chunks. + * @default true + */ + dedupeCss?: boolean; + /** + * Placeholder content when no css extracted. + * @default '/* antd-style extract css (empty) *\/\n' + */ + emptyCssText?: string; +} + +export interface ExtractAssetBuildResult { + manifest: ExtractStyleManifest; + cssText: string; +} + +export interface StaticCollectRule { + include?: RegExp | ((resourcePath: string) => boolean); + exclude?: RegExp | ((resourcePath: string) => boolean); +} + +export interface StaticCollectOptions extends StaticCollectRule { + emotionKey?: string; + /** + * Prefix used to resolve cssVar.xxx interpolations during static collection. + * @default 'ant' + */ + cssVarPrefix?: string; + rootDir?: string; + salt?: string; +} + +export interface AntdStyleExtractPluginCommonOptions { /** Output manifest file name */ manifestFile?: string; - /** Output css file name (placeholder scaffold) */ + /** Output css file name */ cssFile?: string; /** * Extracted entries provided by external transform step. + * This is backward-compatible and will be converted to chunks without cssText. */ getEntries?: () => ExtractStyleManifest['entries']; + /** + * Extracted chunks provided by external transform step. + */ + getChunks?: () => ExtractStyleChunk[]; + /** + * Build options for manifest/css assembly. + */ + buildOptions?: ExtractAssetBuildOptions; + /** + * Static collection options. + */ + staticCollect?: StaticCollectOptions; + /** + * Legacy fallback: allow runtime collector as chunk source. + * + * Default is false to avoid depending on runtime side-effects + * in production extraction pipelines. + */ + useRuntimeCollector?: boolean; +} + +export interface AntdStyleExtractWebpackPluginOptions extends AntdStyleExtractPluginCommonOptions { + /** + * Enable experimental static collection from webpack compilation modules. + */ + experimentalStaticCollect?: boolean; +} + +export interface AntdStyleExtractVitePluginOptions extends AntdStyleExtractPluginCommonOptions { + /** + * Enable experimental static collection from vite transform hook. + */ + experimentalStaticCollect?: boolean; } diff --git a/src/extract/viteEmitPlugin.ts b/src/extract/viteEmitPlugin.ts new file mode 100644 index 00000000..20746fe8 --- /dev/null +++ b/src/extract/viteEmitPlugin.ts @@ -0,0 +1,170 @@ +import { babelInjectStyleId, resetStaticCollectState } from './babelInjectStyleId'; +import { pullExtractedChunks } from './collector'; +import { clearCompiledExtractChunks, pullCompiledExtractChunks } from './compiledCollector'; +import { buildExtractAssets } from './core'; +import type { + AntdStyleExtractVitePluginOptions, + ExtractStyleChunk, + StaticCollectRule, +} from './types'; + +const DEFAULT_SCRIPT_PATTERN = /\.[cm]?[jt]sx?$/; + +type TransformResult = + | null + | undefined + | string + | { + code: string; + map?: any; + }; + +interface ViteLikePlugin { + buildStart?: () => void; + enforce?: 'pre' | 'post'; + generateBundle?: (this: { emitFile: (asset: any) => void }, ...args: any[]) => void; + name: string; + transform?: (code: string, id: string) => TransformResult; +} + +const shouldCollect = (resourcePath: string, rule?: StaticCollectRule) => { + if (!DEFAULT_SCRIPT_PATTERN.test(resourcePath)) return false; + if (!rule) return true; + + if (rule.exclude) { + if (typeof rule.exclude === 'function' && rule.exclude(resourcePath)) return false; + if (rule.exclude instanceof RegExp && rule.exclude.test(resourcePath)) return false; + } + + if (rule.include) { + if (typeof rule.include === 'function') return rule.include(resourcePath); + if (rule.include instanceof RegExp) return rule.include.test(resourcePath); + return false; + } + + return true; +}; + +const resolveChunks = ( + options: Required> & + Omit, +): ExtractStyleChunk[] => { + if (options.getChunks) return options.getChunks(); + + if (options.getEntries) { + return options.getEntries().map((entry) => ({ + ...entry, + cssText: '', + })); + } + + const compiledChunks = pullCompiledExtractChunks(); + if (compiledChunks.length > 0) return compiledChunks; + + if (options.useRuntimeCollector) return pullExtractedChunks(); + + return []; +}; + +/** + * Experimental Vite/Rollup plugin for zero-runtime extraction. + * + * - Emits manifest + css assets in generateBundle + * - Optional static collect mode in transform hook + */ +export const AntdStyleExtractVitePlugin = ( + options: AntdStyleExtractVitePluginOptions = {}, +): ViteLikePlugin => { + const resolved = { + manifestFile: options.manifestFile || '__antd-style.extract.manifest.json', + cssFile: options.cssFile || '__antd-style.extract.css', + getEntries: options.getEntries, + getChunks: options.getChunks, + buildOptions: options.buildOptions, + experimentalStaticCollect: options.experimentalStaticCollect || false, + staticCollect: options.staticCollect, + useRuntimeCollector: options.useRuntimeCollector || false, + }; + + return { + buildStart() { + if (!resolved.experimentalStaticCollect) return; + clearCompiledExtractChunks(); + resetStaticCollectState(); + }, + enforce: 'post', + name: 'antd-style-extract-vite-plugin', + transform(code, id) { + if (!resolved.experimentalStaticCollect) return null; + if (!shouldCollect(id, resolved.staticCollect)) return null; + + let babel: any; + try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + babel = require('@babel/core'); + } catch { + return null; + } + + try { + const result = babel.transformSync(code, { + babelrc: false, + configFile: false, + filename: id, + parserOpts: { + plugins: [ + 'classPrivateMethods', + 'classPrivateProperties', + 'classProperties', + 'jsx', + 'topLevelAwait', + 'typescript', + ], + sourceType: 'unambiguous', + }, + plugins: [ + [ + babelInjectStyleId, + { + collectStatic: true, + emotionKey: resolved.staticCollect?.emotionKey, + cssVarPrefix: resolved.staticCollect?.cssVarPrefix, + rootDir: resolved.staticCollect?.rootDir, + salt: resolved.staticCollect?.salt, + }, + ], + ], + sourceMaps: true, + }); + + if (!result?.code) return null; + + return { + code: result.code, + map: result.map || null, + }; + } catch { + // static collection is best-effort; fallback remains runtime safe + return null; + } + }, + generateBundle() { + const chunks = resolveChunks(resolved); + const { manifest, cssText } = buildExtractAssets(chunks, resolved.buildOptions); + + this.emitFile({ + fileName: resolved.manifestFile, + source: JSON.stringify(manifest, null, 2), + type: 'asset', + }); + + this.emitFile({ + fileName: resolved.cssFile, + source: cssText, + type: 'asset', + }); + }, + }; +}; + +export default AntdStyleExtractVitePlugin; diff --git a/src/extract/webpackEmitPlugin.ts b/src/extract/webpackEmitPlugin.ts index b491b8d5..7f740df6 100644 --- a/src/extract/webpackEmitPlugin.ts +++ b/src/extract/webpackEmitPlugin.ts @@ -1,21 +1,29 @@ import type { Compiler } from 'webpack'; +import { babelInjectStyleId, resetStaticCollectState } from './babelInjectStyleId'; import { pullExtractedChunks } from './collector'; +import { clearCompiledExtractChunks, pullCompiledExtractChunks } from './compiledCollector'; +import { buildExtractAssets } from './core'; import type { AntdStyleExtractWebpackPluginOptions, - ExtractStyleManifest, + ExtractStyleChunk, + StaticCollectRule, } from './types'; +const DEFAULT_SCRIPT_PATTERN = /\.[cm]?[jt]sx?$/; + /** * Experimental webpack plugin for zero-runtime pipeline. * * Current capability: * - emits manifest json - * - emits placeholder css file + * - emits css file from extracted chunks * - * Next phase: - * - collect css text + class map from transformed modules - * - emit real css assets per chunk + * Input source priority: + * 1. options.getChunks() + * 2. options.getEntries() (backward-compatible, cssText empty) + * 3. compiled collector (compile-time bridge) + * 4. runtime collector (legacy, opt-in) */ export class AntdStyleExtractWebpackPlugin { private options: Required< @@ -28,6 +36,11 @@ export class AntdStyleExtractWebpackPlugin { manifestFile: options.manifestFile || '__antd-style.extract.manifest.json', cssFile: options.cssFile || '__antd-style.extract.css', getEntries: options.getEntries, + getChunks: options.getChunks, + buildOptions: options.buildOptions, + experimentalStaticCollect: options.experimentalStaticCollect || false, + staticCollect: options.staticCollect, + useRuntimeCollector: options.useRuntimeCollector || false, }; } @@ -43,34 +56,186 @@ export class AntdStyleExtractWebpackPlugin { stage: Compilation.PROCESS_ASSETS_STAGE_ADDITIONS, }, () => { - const collected = pullExtractedChunks(); - const entries = - this.options.getEntries?.() || - collected.map(({ styleId, styles }) => ({ - styleId, - styles, - })); - - const manifest: ExtractStyleManifest = { - version: 1, - entries, - }; + if (this.options.experimentalStaticCollect) { + this.collectStaticChunksFromCompilation(compilation); + } + + const chunks = this.resolveChunks(); + const { manifest, cssText } = buildExtractAssets(chunks, this.options.buildOptions); compilation.emitAsset( this.options.manifestFile, new sources.RawSource(JSON.stringify(manifest, null, 2)), ); - const cssText = collected.map((c) => c.cssText).filter(Boolean).join('\n'); - - compilation.emitAsset( - this.options.cssFile, - new sources.RawSource(cssText || '/* antd-style extract css (empty) */\n'), - ); + compilation.emitAsset(this.options.cssFile, new sources.RawSource(cssText)); }, ); }); } + + private resolveChunks(): ExtractStyleChunk[] { + if (this.options.getChunks) return this.options.getChunks(); + + if (this.options.getEntries) { + return this.options.getEntries().map((entry) => ({ + ...entry, + cssText: '', + })); + } + + const compiledChunks = pullCompiledExtractChunks(); + if (compiledChunks.length > 0) return compiledChunks; + + if (this.options.useRuntimeCollector) return pullExtractedChunks(); + + return []; + } + + private collectStaticChunksFromCompilation(compilation: any) { + let babel: any; + try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + babel = require('@babel/core'); + } catch { + return; + } + + clearCompiledExtractChunks(); + resetStaticCollectState(); + + const modules = Array.from(compilation.modules || []) as any[]; + const visited = new Set(); + + for (const module of modules) { + const resources = this.getCollectResourcePaths(module); + + for (const resource of resources) { + if (visited.has(resource)) continue; + visited.add(resource); + + if (!this.shouldCollect(resource)) continue; + + const source = this.getCollectSource(module, resource); + if (!source) continue; + + try { + babel.transformSync(source, { + babelrc: false, + configFile: false, + filename: resource, + parserOpts: { + plugins: [ + 'classPrivateMethods', + 'classPrivateProperties', + 'classProperties', + 'jsx', + 'topLevelAwait', + 'typescript', + ], + sourceType: 'unambiguous', + }, + plugins: [ + [ + babelInjectStyleId, + { + collectStatic: true, + emotionKey: this.options.staticCollect?.emotionKey, + cssVarPrefix: this.options.staticCollect?.cssVarPrefix, + rootDir: this.options.staticCollect?.rootDir, + salt: this.options.staticCollect?.salt, + }, + ], + ], + }); + } catch { + // static collection is best-effort; unsupported sources fall back + } + } + } + } + + private getCollectResourcePaths(module: any): string[] { + const resources = new Set(); + + const pushResource = (value: unknown) => { + if (typeof value !== 'string' || !value) return; + resources.add(value); + }; + + pushResource(module?.resource); + pushResource(module?.rootModule?.resource); + + const nestedModules = (module?.modules || module?._modules) as any[] | undefined; + if (Array.isArray(nestedModules)) { + for (const nested of nestedModules) { + pushResource(nested?.resource); + } + } + + return Array.from(resources.values()); + } + + private shouldCollect(resourcePath?: string) { + if (!resourcePath) return false; + if (!DEFAULT_SCRIPT_PATTERN.test(resourcePath)) return false; + + const rule = this.options.staticCollect as StaticCollectRule | undefined; + if (!rule) return true; + + if (rule.exclude) { + if (typeof rule.exclude === 'function' && rule.exclude(resourcePath)) return false; + if (rule.exclude instanceof RegExp && rule.exclude.test(resourcePath)) return false; + } + + if (rule.include) { + if (typeof rule.include === 'function') return rule.include(resourcePath); + if (rule.include instanceof RegExp) return rule.include.test(resourcePath); + return false; + } + + return true; + } + + private getCollectSource(module: any, resourcePath: string): string | undefined { + const candidates: any[] = []; + + candidates.push(module); + + if (module?.rootModule) { + candidates.push(module.rootModule); + } + + const nestedModules = (module?.modules || module?._modules) as any[] | undefined; + if (Array.isArray(nestedModules)) { + for (const nested of nestedModules) { + candidates.push(nested); + } + } + + for (const candidate of candidates) { + if (candidate?.resource !== resourcePath) continue; + + const source = this.getModuleSource(candidate); + if (source) return source; + } + + return this.getModuleSource(module); + } + + private getModuleSource(module: any): string | undefined { + const source = module?.originalSource?.(); + if (!source || typeof source.source !== 'function') return; + + const value = source.source(); + if (typeof value === 'string') return value; + + if (value && typeof (value as any).toString === 'function') { + return (value as any).toString('utf8'); + } + + return; + } } export default AntdStyleExtractWebpackPlugin; diff --git a/src/factories/createStaticStyles/index.ts b/src/factories/createStaticStyles/index.ts index 1959cc91..31b77a37 100644 --- a/src/factories/createStaticStyles/index.ts +++ b/src/factories/createStaticStyles/index.ts @@ -109,7 +109,8 @@ export const createStaticStylesFactory = ( try { // lazy import to avoid hard coupling and potential circular init // eslint-disable-next-line @typescript-eslint/no-var-requires - const { pushExtractedChunk } = require('@/extract/collector') as typeof import('@/extract/collector'); + const { pushExtractedChunk } = + require('@/extract/collector') as typeof import('@/extract/collector'); const classNames = new Set(); for (const value of Object.values(res as any)) { @@ -118,14 +119,14 @@ export const createStaticStylesFactory = ( } const cssParts: string[] = []; - for (const cls of classNames) { + classNames.forEach((cls) => { const hash = cls.startsWith(`${emotionCache.key}-`) ? cls.slice(emotionCache.key.length + 1) : undefined; - if (!hash) continue; + if (!hash) return; const inserted = (emotionCache.inserted as any)?.[hash]; if (typeof inserted === 'string') cssParts.push(inserted); - } + }); pushExtractedChunk({ styleId: options.styleId, diff --git a/src/factories/createStyleProvider/index.tsx b/src/factories/createStyleProvider/index.tsx index 15ee0b2b..dc76b69f 100644 --- a/src/factories/createStyleProvider/index.tsx +++ b/src/factories/createStyleProvider/index.tsx @@ -90,8 +90,8 @@ export const createStyleProvider = (EmotionContext: Context): FC { diff --git a/src/functions/extractStaticStyle.tsx b/src/functions/extractStaticStyle.tsx index 4d0c075f..df0cb957 100644 --- a/src/functions/extractStaticStyle.tsx +++ b/src/functions/extractStaticStyle.tsx @@ -84,7 +84,10 @@ export const extractStaticStyle = (html?: string, options?: ExtractStyleOptions) // copy from emotion ssr // https://github.com/vercel/next.js/blob/deprecated-main/examples/with-emotion-vanilla/pages/_document.js - const styles = global.__ANTD_STYLE_CACHE_MANAGER_FOR_SSR__.getCacheList().map((cache) => { + const cacheManager = (globalThis as any).__ANTD_STYLE_CACHE_MANAGER_FOR_SSR__; + const cacheList = cacheManager?.getCacheList?.() || []; + + const styles = cacheList.map((cache: EmotionCache) => { const extractHtml = createExtractCritical(cache); const result: { ids: string[]; css: string } = !html From bba50819a76bd3b849c86d790eae84828a7529b7 Mon Sep 17 00:00:00 2001 From: Jiyuan Zheng Date: Fri, 27 Feb 2026 11:41:51 +0800 Subject: [PATCH 09/13] chore: trigger ci after enabling actions From 66616418a79ca7e041634d7d36586319755cf2db Mon Sep 17 00:00:00 2001 From: Jiyuan Zheng Date: Fri, 27 Feb 2026 11:43:21 +0800 Subject: [PATCH 10/13] fix: remove hard webpack type dependency in extract plugin --- src/extract/webpackEmitPlugin.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/extract/webpackEmitPlugin.ts b/src/extract/webpackEmitPlugin.ts index 7f740df6..a3d0b2b1 100644 --- a/src/extract/webpackEmitPlugin.ts +++ b/src/extract/webpackEmitPlugin.ts @@ -1,5 +1,3 @@ -import type { Compiler } from 'webpack'; - import { babelInjectStyleId, resetStaticCollectState } from './babelInjectStyleId'; import { pullExtractedChunks } from './collector'; import { clearCompiledExtractChunks, pullCompiledExtractChunks } from './compiledCollector'; @@ -44,7 +42,7 @@ export class AntdStyleExtractWebpackPlugin { }; } - apply(compiler: Compiler) { + apply(compiler: any) { const pluginName = 'AntdStyleExtractWebpackPlugin'; compiler.hooks.thisCompilation.tap(pluginName, (compilation: any) => { From c924d4eef1499dbadf870bf2f80d2775a9672fdb Mon Sep 17 00:00:00 2001 From: Jiyuan Zheng Date: Fri, 27 Feb 2026 12:44:28 +0800 Subject: [PATCH 11/13] fix: lazy-load babel core in extract plugins and refresh snapshots --- src/extract/viteEmitPlugin.ts | 32 ++++++++++++++++++------ src/extract/webpackEmitPlugin.ts | 32 ++++++++++++++++++------ tests/__snapshots__/export.test.tsx.snap | 17 +++++++++++++ tests/hooks/useResponsive.test.ts | 1 + 4 files changed, 68 insertions(+), 14 deletions(-) diff --git a/src/extract/viteEmitPlugin.ts b/src/extract/viteEmitPlugin.ts index 20746fe8..403f545a 100644 --- a/src/extract/viteEmitPlugin.ts +++ b/src/extract/viteEmitPlugin.ts @@ -66,6 +66,29 @@ const resolveChunks = ( return []; }; +const loadBabelCore = () => { + try { + // Prefer eval-based require in CommonJS runtime. + // Avoid static require() so client bundlers won't pull @babel/core eagerly. + // eslint-disable-next-line no-eval + const req = eval('require'); + if (typeof req === 'function') return req('@babel/core'); + } catch { + // ignore + } + + try { + // Fallback for environments where eval('require') is unavailable. + // eslint-disable-next-line no-new-func + const dynamicRequire = Function('try { return require; } catch { return null; }')(); + if (typeof dynamicRequire === 'function') return dynamicRequire('@babel/core'); + } catch { + // ignore + } + + return undefined; +}; + /** * Experimental Vite/Rollup plugin for zero-runtime extraction. * @@ -98,13 +121,8 @@ export const AntdStyleExtractVitePlugin = ( if (!resolved.experimentalStaticCollect) return null; if (!shouldCollect(id, resolved.staticCollect)) return null; - let babel: any; - try { - // eslint-disable-next-line @typescript-eslint/no-var-requires - babel = require('@babel/core'); - } catch { - return null; - } + const babel = loadBabelCore(); + if (!babel) return null; try { const result = babel.transformSync(code, { diff --git a/src/extract/webpackEmitPlugin.ts b/src/extract/webpackEmitPlugin.ts index a3d0b2b1..af21983d 100644 --- a/src/extract/webpackEmitPlugin.ts +++ b/src/extract/webpackEmitPlugin.ts @@ -10,6 +10,29 @@ import type { const DEFAULT_SCRIPT_PATTERN = /\.[cm]?[jt]sx?$/; +const loadBabelCore = () => { + try { + // Prefer eval-based require in CommonJS runtime. + // Avoid static require() so client bundlers won't pull @babel/core eagerly. + // eslint-disable-next-line no-eval + const req = eval('require'); + if (typeof req === 'function') return req('@babel/core'); + } catch { + // ignore + } + + try { + // Fallback for environments where eval('require') is unavailable. + // eslint-disable-next-line no-new-func + const dynamicRequire = Function('try { return require; } catch { return null; }')(); + if (typeof dynamicRequire === 'function') return dynamicRequire('@babel/core'); + } catch { + // ignore + } + + return undefined; +}; + /** * Experimental webpack plugin for zero-runtime pipeline. * @@ -91,13 +114,8 @@ export class AntdStyleExtractWebpackPlugin { } private collectStaticChunksFromCompilation(compilation: any) { - let babel: any; - try { - // eslint-disable-next-line @typescript-eslint/no-var-requires - babel = require('@babel/core'); - } catch { - return; - } + const babel = loadBabelCore(); + if (!babel) return; clearCompiledExtractChunks(); resetStaticCollectState(); diff --git a/tests/__snapshots__/export.test.tsx.snap b/tests/__snapshots__/export.test.tsx.snap index e43b30df..d114cb8d 100644 --- a/tests/__snapshots__/export.test.tsx.snap +++ b/tests/__snapshots__/export.test.tsx.snap @@ -2,9 +2,26 @@ exports[`export > should work 1`] = ` [ + "babelInjectStyleId", + "resetStaticCollectState", + "clearExtractedChunks", + "pullExtractedChunks", + "pushExtractedChunk", + "clearCompiledExtractChunks", + "pullCompiledExtractChunks", + "pushCompiledExtractChunk", + "buildExtractAssets", + "hydrateExtractedStyles", + "createStyleId", + "AntdStyleExtractVitePlugin", + "AntdStyleExtractWebpackPlugin", "extractStaticStyle", "setupStyled", "createInstance", + "clearExtractedStyles", + "getStyleRuntimeMode", + "registerExtractedStyles", + "setStyleRuntimeMode", "createStaticStylesFactory", "createStyles", "createGlobalStyle", diff --git a/tests/hooks/useResponsive.test.ts b/tests/hooks/useResponsive.test.ts index 74dc7170..8324b82c 100644 --- a/tests/hooks/useResponsive.test.ts +++ b/tests/hooks/useResponsive.test.ts @@ -16,6 +16,7 @@ describe('useResponsive', () => { "xl": false, "xs": false, "xxl": false, + "xxxl": false, } `); }); From 2a176f8dbbd8157d1485a451154364bbe125218f Mon Sep 17 00:00:00 2001 From: Jiyuan Zheng Date: Fri, 27 Feb 2026 12:47:24 +0800 Subject: [PATCH 12/13] test: make extract prototype verify resilient without babel core --- scripts/verify-extract-prototype.cjs | 84 +++++++++++++++++++--------- 1 file changed, 57 insertions(+), 27 deletions(-) diff --git a/scripts/verify-extract-prototype.cjs b/scripts/verify-extract-prototype.cjs index b9fa09e3..a31fbd85 100644 --- a/scripts/verify-extract-prototype.cjs +++ b/scripts/verify-extract-prototype.cjs @@ -104,30 +104,54 @@ function verifyViteEmitPlugin() { m.clearCompiledExtractChunks(); m.resetStaticCollectState(); - const plugin = m.AntdStyleExtractVitePlugin({ - experimentalStaticCollect: true, - staticCollect: { - include: /sample\.tsx$/, - rootDir: process.cwd(), - }, - }); - - plugin.buildStart?.(); + let hasBabelCore = false; + try { + require.resolve('@babel/core'); + hasBabelCore = true; + } catch { + hasBabelCore = false; + } - const transformed = plugin.transform?.( - ` - import { createStaticStyles } from 'antd-style'; - export const styles = createStaticStyles(({ css, cssVar }) => ({ - item: css\`padding: 8px; border-color: \${cssVar.colorBorder};\`, - })); - `, - `${process.cwd().replace(/\\/g, '/')}/src/__verify__/sample.tsx`, + const plugin = m.AntdStyleExtractVitePlugin( + hasBabelCore + ? { + experimentalStaticCollect: true, + staticCollect: { + include: /sample\.tsx$/, + rootDir: process.cwd(), + }, + } + : { + getChunks: () => [ + { + styleId: 'verify-vite-style-id', + styles: { item: 'acss-vite' }, + cssText: '.acss-vite{color:orange;}', + }, + ], + }, ); - assert.ok( - transformed && typeof transformed === 'object' && transformed.code.includes('styleId'), - 'vite transform should inject styleId when static collect enabled', - ); + plugin.buildStart?.(); + + if (hasBabelCore) { + const transformed = plugin.transform?.( + ` + import { createStaticStyles } from 'antd-style'; + export const styles = createStaticStyles(({ css, cssVar }) => ({ + item: css\`padding: 8px; border-color: \${cssVar.colorBorder};\`, + })); + `, + `${process.cwd().replace(/\\/g, '/')}/src/__verify__/sample.tsx`, + ); + + assert.ok( + transformed && typeof transformed === 'object' && transformed.code.includes('styleId'), + 'vite transform should inject styleId when static collect enabled', + ); + } else { + console.log('⚠️ @babel/core not found, skip vite static transform verification'); + } const emitted = []; plugin.generateBundle?.call( @@ -149,12 +173,18 @@ function verifyViteEmitPlugin() { const manifest = JSON.parse(manifestAsset.source); assert.equal(manifest.version, 1); assert.equal(manifest.entries.length, 1); - assert.ok(manifest.entries[0].styleId.startsWith('as-')); - assert.ok(typeof cssAsset.source === 'string' && cssAsset.source.includes('.acss-')); - assert.ok( - typeof cssAsset.source === 'string' && cssAsset.source.includes('var(--ant-color-border)'), - 'vite static collect should resolve cssVar interpolation', - ); + + if (hasBabelCore) { + assert.ok(manifest.entries[0].styleId.startsWith('as-')); + assert.ok(typeof cssAsset.source === 'string' && cssAsset.source.includes('.acss-')); + assert.ok( + typeof cssAsset.source === 'string' && cssAsset.source.includes('var(--ant-color-border)'), + 'vite static collect should resolve cssVar interpolation', + ); + } else { + assert.equal(manifest.entries[0].styleId, 'verify-vite-style-id'); + assert.ok(typeof cssAsset.source === 'string' && cssAsset.source.includes('.acss-vite')); + } console.log('✅ vite emit plugin verified'); } From 46d3a5569c8bb8eab0c24c8549266f4eb7a8b540 Mon Sep 17 00:00:00 2001 From: Jiyuan Zheng Date: Tue, 10 Mar 2026 17:45:31 +0800 Subject: [PATCH 13/13] feat: extend zero-runtime static extraction --- scripts/verify-extract-prototype.cjs | 50 +- src/extract/ROADMAP.md | 6 +- src/extract/babelInjectStyleId.ts | 919 ++++++++++++++++++++++++--- src/extract/types.ts | 17 + src/extract/viteEmitPlugin.ts | 2 + src/extract/webpackEmitPlugin.ts | 2 + 6 files changed, 893 insertions(+), 103 deletions(-) diff --git a/scripts/verify-extract-prototype.cjs b/scripts/verify-extract-prototype.cjs index a31fbd85..8029e38a 100644 --- a/scripts/verify-extract-prototype.cjs +++ b/scripts/verify-extract-prototype.cjs @@ -68,15 +68,20 @@ function verifyBabelStaticCollection() { m.resetStaticCollectState(); const source = ` - import { createStaticStyles } from 'antd-style'; + import { createStaticStyles, cssVar, responsive } from 'antd-style'; - const styles = createStaticStyles(({ css, cssVar }) => ({ - a: css\`color: red;\`, - b: css\`margin: 0; &:hover { margin: 1px; }\`, - c: css\`border-color: \${cssVar.colorText};\`, + const prefixCls = 'ant'; + const space = 4; + + const styles = createStaticStyles(({ css, cx }) => ({ + base: css\`color: \${cssVar.colorText};\`, + media: css\`\${responsive.sm} { padding: \${space * 2}px; }\`, + mixed: cx('helper', css\`.\${prefixCls}-x { margin: \${space + 1}px; }\`), })); - export { styles }; + const atom = createStaticStyles(({ css }) => css\`margin: \${space + 3}px;\`); + + export { styles, atom }; `; babel.transformSync(source, { @@ -88,14 +93,25 @@ function verifyBabelStaticCollection() { const compiledChunks = m.pullCompiledExtractChunks(); - assert.equal(compiledChunks.length, 1, 'babel static collection should push one compiled chunk'); - assert.ok(compiledChunks[0].styleId.startsWith('as-')); - assert.deepEqual(Object.keys(compiledChunks[0].styles), ['a', 'b', 'c']); - assert.ok(compiledChunks[0].cssText.includes('.acss-')); - assert.ok( - compiledChunks[0].cssText.includes('var(--ant-color-text)'), - 'babel static collection should resolve cssVar interpolation', - ); + assert.equal(compiledChunks.length, 2, 'babel static collection should push two compiled chunks'); + + const objectChunk = compiledChunks.find((chunk) => chunk.styles && typeof chunk.styles === 'object'); + const atomChunk = compiledChunks.find((chunk) => typeof chunk.styles === 'string'); + + assert.ok(objectChunk, 'object-style chunk should exist'); + assert.ok(atomChunk, 'atom-style chunk should exist'); + + assert.ok(objectChunk.styleId.startsWith('as-')); + assert.deepEqual(Object.keys(objectChunk.styles), ['base', 'media', 'mixed']); + assert.ok(objectChunk.styles.mixed.includes('helper')); + assert.ok(objectChunk.cssText.includes('.acss-')); + assert.ok(objectChunk.cssText.includes('var(--ant-color-text)')); + assert.ok(objectChunk.cssText.includes('@media (max-width: 575.98px)')); + assert.ok(objectChunk.cssText.includes('.ant-x')); + + assert.ok(atomChunk.styleId.startsWith('as-')); + assert.ok(typeof atomChunk.styles === 'string' && atomChunk.styles.includes('acss-')); + assert.ok(atomChunk.cssText.includes('margin:7px')); console.log('✅ babel static collection verified'); } @@ -286,7 +302,11 @@ async function runWebpackBuild(webpack, config) { }); }); - await new Promise((resolve) => compiler.close(() => resolve())); + await new Promise((resolve) => { + compiler.close(() => { + resolve(); + }); + }); return buildDir; } diff --git a/src/extract/ROADMAP.md b/src/extract/ROADMAP.md index 8d3f2b02..96a29327 100644 --- a/src/extract/ROADMAP.md +++ b/src/extract/ROADMAP.md @@ -8,8 +8,10 @@ This folder contains the building blocks for zero-runtime extraction. - Manifest hydration API (`hydrateExtractedStyles`) - Experimental Babel pass to inject stable `styleId` for `createStaticStyles` - supports optional `salt` (or `ANTD_STYLE_EXTRACT_SALT`) to avoid cross-project collisions - - supports strict static collection mode (`collectStatic`) for template-literal `css` rules - - supports limited interpolation static evaluation for `cssVar.xxx` expressions + - supports best-effort static collection mode (`collectStatic`) for object and atom returns + - supports static interpolation for `cssVar.xxx`, `responsive.xxx`, local constants, and imported values via resolver + - supports `cx(...)` composition and collected `keyframes` + - can optionally prune runtime style factories with `pruneRuntimeStyles` - Extract asset assembly core (`buildExtractAssets`) for manifest/css generation - Experimental webpack emit plugin scaffold (`AntdStyleExtractWebpackPlugin`) - supports compilation static collect mode (`experimentalStaticCollect`) diff --git a/src/extract/babelInjectStyleId.ts b/src/extract/babelInjectStyleId.ts index 69067471..a91b0bf7 100644 --- a/src/extract/babelInjectStyleId.ts +++ b/src/extract/babelInjectStyleId.ts @@ -1,8 +1,18 @@ import createEmotion from '@emotion/css/create-instance'; +import { responsive as staticResponsiveMap } from '@/factories/createStaticStyles/responsive'; + import { pushCompiledExtractChunk } from './compiledCollector'; import { createStyleId } from './styleId'; +interface ImportBindingMeta { + imported: string; + local: string; + source: string; + type: 'default' | 'named' | 'namespace'; + property?: string; +} + interface BabelPluginOptions { collectStatic?: boolean; emotionKey?: string; @@ -11,6 +21,15 @@ interface BabelPluginOptions { * @default 'ant' */ cssVarPrefix?: string; + /** + * Enable replacing collected createStaticStyles runtime function with pre-resolved class map. + * @default false + */ + pruneRuntimeStyles?: boolean; + /** + * Optional resolver for imported values used in static expression evaluation. + */ + resolveImportedValue?: (meta: ImportBindingMeta) => string | number | boolean | undefined; rootDir?: string; salt?: string; } @@ -24,10 +43,31 @@ interface BabelState { opts?: BabelPluginOptions; } +interface ImportedStaticNames { + cssVar: Set; + cx: Set; + keyframes: Set; + responsive: Set; +} + interface StaticCollectContext { cssTagIdentifier: string; - cssVarIdentifier?: string; + cssVarIdentifiers: Set; cssVarPrefix: string; + cxIdentifiers: Set; + keyframesIdentifiers: Set; + responsiveIdentifiers: Set; + resolveImportedValue?: (meta: ImportBindingMeta) => string | number | boolean | undefined; +} + +interface CollectedStyleValue { + className: string; + cssTexts: string[]; +} + +interface ResolvedTemplateExpression { + cssTexts: string[]; + text: string; } let staticChunkOrder = 0; @@ -67,6 +107,18 @@ const resolveCssVarInterpolation = (tokenName: string, prefix = 'ant') => { return `var(--${prefix}-${kebab})`; }; +const resolveResponsiveInterpolation = (tokenName: string): string | undefined => { + return staticResponsiveMap[tokenName as keyof typeof staticResponsiveMap]; +}; + +const unwrapParenthesizedExpression = (t: any, node: any): any => { + let current = node; + while (current && t.isParenthesizedExpression(current)) { + current = current.expression; + } + return current; +}; + const getObjectPatternBindingName = (t: any, stylesFnNode: any, propertyName: string) => { const firstParam = stylesFnNode?.params?.[0]; if (!firstParam || !t.isObjectPattern(firstParam)) return undefined; @@ -95,35 +147,53 @@ const getStaticCollectContext = ( t: any, stylesFnNode: any, cssVarPrefix: string, + importedNames: ImportedStaticNames, + resolveImportedValue?: (meta: ImportBindingMeta) => string | number | boolean | undefined, ): StaticCollectContext | undefined => { const cssTagIdentifier = getObjectPatternBindingName(t, stylesFnNode, 'css'); if (!cssTagIdentifier) return undefined; + const cssVarIdentifiers = new Set(importedNames.cssVar); + const responsiveIdentifiers = new Set(importedNames.responsive); + const cxIdentifiers = new Set(importedNames.cx); + const keyframesIdentifiers = new Set(importedNames.keyframes); + const cssVarIdentifier = getObjectPatternBindingName(t, stylesFnNode, 'cssVar'); + if (cssVarIdentifier) cssVarIdentifiers.add(cssVarIdentifier); + + const responsiveIdentifier = getObjectPatternBindingName(t, stylesFnNode, 'responsive'); + if (responsiveIdentifier) responsiveIdentifiers.add(responsiveIdentifier); + + const cxIdentifier = getObjectPatternBindingName(t, stylesFnNode, 'cx'); + if (cxIdentifier) cxIdentifiers.add(cxIdentifier); return { cssTagIdentifier, - cssVarIdentifier, + cssVarIdentifiers, cssVarPrefix, + cxIdentifiers, + keyframesIdentifiers, + responsiveIdentifiers, + resolveImportedValue, }; }; -const getObjectExpressionFromReturn = (t: any, stylesFnNode: any) => { +const getReturnedExpressionFromStylesFn = (t: any, stylesFnNode: any) => { if ( (t.isArrowFunctionExpression(stylesFnNode) || t.isFunctionExpression(stylesFnNode)) && - t.isObjectExpression(stylesFnNode.body) + stylesFnNode.body ) { - return stylesFnNode.body; - } + const body = unwrapParenthesizedExpression(t, stylesFnNode.body); + if (!body) return undefined; - if ( - (t.isArrowFunctionExpression(stylesFnNode) || t.isFunctionExpression(stylesFnNode)) && - t.isBlockStatement(stylesFnNode.body) - ) { - for (const statement of stylesFnNode.body.body) { + if (!t.isBlockStatement(body)) return body; + + for (const statement of body.body) { if (!t.isReturnStatement(statement)) continue; - if (!statement.argument || !t.isObjectExpression(statement.argument)) continue; - return statement.argument; + if (!statement.argument) continue; + + const returned = unwrapParenthesizedExpression(t, statement.argument); + if (returned) return returned; } } @@ -150,46 +220,462 @@ const getMemberPropertyName = (t: any, expression: any): string | undefined => { return undefined; }; -const resolveTemplateExpression = ( +const getBindingInitializer = (callPath: any, name: string) => { + const binding = callPath?.scope?.getBinding?.(name); + if (binding && binding.constant && binding.path?.isVariableDeclarator?.()) { + return binding.path.node.init; + } + + const stylesFnNode = callPath?.node?.arguments?.[0]; + if (!stylesFnNode?.body?.body || !Array.isArray(stylesFnNode.body.body)) return undefined; + + for (const statement of stylesFnNode.body.body) { + if ( + !statement || + statement.type !== 'VariableDeclaration' || + !Array.isArray(statement.declarations) + ) { + continue; + } + + for (const declaration of statement.declarations) { + if (!declaration?.id || declaration.id.type !== 'Identifier') continue; + if (declaration.id.name !== name) continue; + if (!declaration.init) return undefined; + + return declaration.init; + } + } + + return undefined; +}; + +const getImportBindingMeta = (callPath: any, localName: string): ImportBindingMeta | undefined => { + const binding = callPath?.scope?.getBinding?.(localName); + if (!binding?.path) return undefined; + + const bindingPath = binding.path; + const parent = bindingPath.parentPath?.node; + if (!parent?.source || typeof parent.source.value !== 'string') return undefined; + + if (bindingPath.isImportSpecifier?.()) { + const importedNode = bindingPath.node.imported; + const imported = importedNode?.name || importedNode?.value?.toString?.() || 'default'; + return { + imported, + local: localName, + source: parent.source.value, + type: 'named', + }; + } + + if (bindingPath.isImportDefaultSpecifier?.()) { + return { + imported: 'default', + local: localName, + source: parent.source.value, + type: 'default', + }; + } + + if (bindingPath.isImportNamespaceSpecifier?.()) { + return { + imported: '*', + local: localName, + source: parent.source.value, + type: 'namespace', + }; + } + + return undefined; +}; + +const evaluateBinaryExpression = (operator: string, left: any, right: any) => { + switch (operator) { + case '+': { + return left + right; + } + case '-': { + return left - right; + } + case '*': { + return left * right; + } + case '/': { + return left / right; + } + case '%': { + return left % right; + } + case '**': { + return left ** right; + } + case '===': { + return left === right; + } + case '!==': { + return left !== right; + } + case '<': { + return left < right; + } + case '<=': { + return left <= right; + } + case '>': { + return left > right; + } + case '>=': { + return left >= right; + } + default: { + return undefined; + } + } +}; + +const evaluateUnaryExpression = (operator: string, value: any) => { + switch (operator) { + case '+': { + return +value; + } + case '-': { + return -value; + } + case '!': { + return !value; + } + case '~': { + return ~value; + } + default: { + return undefined; + } + } +}; + +const evaluateLogicalExpression = ( + operator: string, + left: any, + resolveRight: () => any, +): any | undefined => { + switch (operator) { + case '&&': { + if (!left) return left; + return resolveRight(); + } + case '||': { + if (left) return left; + return resolveRight(); + } + case '??': { + if (left !== null && left !== undefined) return left; + return resolveRight(); + } + default: { + return undefined; + } + } +}; + +const resolveStaticExpression = ( t: any, expression: any, context: StaticCollectContext, -): string | undefined => { - if (t.isStringLiteral(expression)) return expression.value; - if (t.isNumericLiteral(expression)) return String(expression.value); - if (t.isBooleanLiteral(expression)) return String(expression.value); - if (t.isNullLiteral(expression)) return 'null'; + callPath: any, + visiting = new Set(), +): any | undefined => { + const node = unwrapParenthesizedExpression(t, expression); + if (!node) return undefined; + + if (t.isStringLiteral(node)) return node.value; + if (t.isNumericLiteral(node)) return node.value; + if (t.isBooleanLiteral(node)) return node.value; + if (t.isNullLiteral(node)) return null; + if (t.isBigIntLiteral(node)) { + try { + return Number(node.value); + } catch { + return undefined; + } + } + + if (t.isTemplateLiteral(node)) { + let text = ''; + + for (let i = 0; i < node.quasis.length; i += 1) { + text += node.quasis[i].value.cooked ?? node.quasis[i].value.raw; - if (t.isTemplateLiteral(expression)) { - if (expression.expressions.length > 0) return undefined; + if (i >= node.expressions.length) continue; - return expression.quasis.map((quasi: any) => quasi.value.cooked ?? quasi.value.raw).join(''); + const resolved = resolveStaticExpression(t, node.expressions[i], context, callPath, visiting); + if (resolved === undefined || typeof resolved === 'object') return undefined; + text += String(resolved); + } + + return text; } - const isOptionalMemberExpression = - typeof t.isOptionalMemberExpression === 'function' && t.isOptionalMemberExpression(expression); + if (t.isIdentifier(node)) { + if (node.name === 'undefined') return undefined; + + if ( + context.cssVarIdentifiers.has(node.name) || + context.responsiveIdentifiers.has(node.name) || + context.cxIdentifiers.has(node.name) + ) { + return undefined; + } + + const importMeta = getImportBindingMeta(callPath, node.name); + if (importMeta && typeof context.resolveImportedValue === 'function') { + const resolvedImportValue = context.resolveImportedValue(importMeta); + if (resolvedImportValue !== undefined) return resolvedImportValue; + } + + if (visiting.has(node.name)) return undefined; - if (t.isMemberExpression(expression) || isOptionalMemberExpression) { - if (!context.cssVarIdentifier) return undefined; + const initializer = getBindingInitializer(callPath, node.name); + if (!initializer) return undefined; - const objectName = t.isIdentifier(expression.object) ? expression.object.name : undefined; - if (objectName !== context.cssVarIdentifier) return undefined; + visiting.add(node.name); + const resolved = resolveStaticExpression(t, initializer, context, callPath, visiting); + visiting.delete(node.name); - const propertyName = getMemberPropertyName(t, expression); + return resolved; + } + + const isOptionalMemberExpression = + typeof t.isOptionalMemberExpression === 'function' && t.isOptionalMemberExpression(node); + + if (t.isMemberExpression(node) || isOptionalMemberExpression) { + const object = unwrapParenthesizedExpression(t, node.object); + const objectName = t.isIdentifier(object) ? object.name : undefined; + + const propertyName = getMemberPropertyName(t, node); if (!propertyName) return undefined; - return resolveCssVarInterpolation(propertyName, context.cssVarPrefix); + if (objectName && context.cssVarIdentifiers.has(objectName)) { + return resolveCssVarInterpolation(propertyName, context.cssVarPrefix); + } + + if (objectName && context.responsiveIdentifiers.has(objectName)) { + return resolveResponsiveInterpolation(propertyName); + } + + if (objectName) { + const importObjectMeta = getImportBindingMeta(callPath, objectName); + if (importObjectMeta && typeof context.resolveImportedValue === 'function') { + const resolvedImportMember = context.resolveImportedValue({ + ...importObjectMeta, + property: propertyName, + }); + + if (resolvedImportMember !== undefined) return resolvedImportMember; + } + } + + const resolvedObject = resolveStaticExpression(t, object, context, callPath, visiting); + if (!resolvedObject || typeof resolvedObject !== 'object') return undefined; + + return (resolvedObject as any)[propertyName]; + } + + if (t.isObjectExpression(node)) { + const result: Record = {}; + + for (const property of node.properties) { + if (!t.isObjectProperty(property)) return undefined; + + const key = + t.isIdentifier(property.key) || t.isStringLiteral(property.key) + ? property.key.name || property.key.value + : undefined; + if (!key) return undefined; + + const value = resolveStaticExpression(t, property.value, context, callPath, visiting); + if (value === undefined) return undefined; + + result[key] = value; + } + + return result; + } + + if (t.isArrayExpression(node)) { + const result: any[] = []; + + for (const element of node.elements) { + if (!element || t.isSpreadElement(element)) return undefined; + + const value = resolveStaticExpression(t, element, context, callPath, visiting); + if (value === undefined) return undefined; + + result.push(value); + } + + return result; + } + + if (t.isConditionalExpression(node)) { + const test = resolveStaticExpression(t, node.test, context, callPath, visiting); + if (test === undefined) return undefined; + + return test + ? resolveStaticExpression(t, node.consequent, context, callPath, visiting) + : resolveStaticExpression(t, node.alternate, context, callPath, visiting); + } + + if (t.isLogicalExpression(node)) { + const left = resolveStaticExpression(t, node.left, context, callPath, visiting); + if (left === undefined) return undefined; + + return evaluateLogicalExpression(node.operator, left, () => + resolveStaticExpression(t, node.right, context, callPath, visiting), + ); + } + + if (t.isUnaryExpression(node)) { + const value = resolveStaticExpression(t, node.argument, context, callPath, visiting); + if (value === undefined) return undefined; + + return evaluateUnaryExpression(node.operator, value); + } + + if (t.isBinaryExpression(node)) { + const left = resolveStaticExpression(t, node.left, context, callPath, visiting); + if (left === undefined) return undefined; + + const right = resolveStaticExpression(t, node.right, context, callPath, visiting); + if (right === undefined) return undefined; + + return evaluateBinaryExpression(node.operator, left, right); } return undefined; }; -const buildCssSourceFromTemplate = ( +function collectKeyframesTaggedTemplate( + t: any, + taggedTemplate: any, + context: StaticCollectContext, + callPath: any, + collector: ReturnType, +): ResolvedTemplateExpression | undefined { + if ( + !t.isIdentifier(taggedTemplate.tag) || + !context.keyframesIdentifiers.has(taggedTemplate.tag.name) + ) { + return undefined; + } + + // Mutual recursion is intentional here: keyframes can contain nested template interpolations. + // eslint-disable-next-line @typescript-eslint/no-use-before-define + const built = buildCssSourceFromTemplate(t, taggedTemplate.quasi, context, callPath, collector); + if (!built || !built.cssSource || !built.cssSource.trim()) return undefined; + + const animationName = collector.keyframes(built.cssSource); + const hash = animationName.startsWith('animation-') + ? animationName.slice('animation-'.length) + : undefined; + if (!hash) return undefined; + + const cssText = collector.cache.inserted?.[hash]; + const cssTexts = [...(built.cssTexts || [])]; + if (typeof cssText === 'string' && cssText.trim()) cssTexts.push(cssText); + + return { + cssTexts, + text: animationName, + }; +} + +function collectKeyframesFromExpression( + t: any, + expression: any, + context: StaticCollectContext, + callPath: any, + collector: ReturnType, + visiting = new Set(), +): ResolvedTemplateExpression | undefined { + const node = unwrapParenthesizedExpression(t, expression); + if (!node) return undefined; + + if (t.isTaggedTemplateExpression(node)) { + return collectKeyframesTaggedTemplate(t, node, context, callPath, collector); + } + + if (t.isIdentifier(node)) { + if (visiting.has(node.name)) return undefined; + + const initializer = getBindingInitializer(callPath, node.name); + if (!initializer) return undefined; + + visiting.add(node.name); + const resolved = collectKeyframesFromExpression( + t, + initializer, + context, + callPath, + collector, + visiting, + ); + visiting.delete(node.name); + + return resolved; + } + + if (t.isConditionalExpression(node)) { + const test = resolveStaticExpression(t, node.test, context, callPath, visiting); + if (test === undefined) return undefined; + + return collectKeyframesFromExpression( + t, + test ? node.consequent : node.alternate, + context, + callPath, + collector, + visiting, + ); + } + + return undefined; +} + +function resolveTemplateExpression( + t: any, + expression: any, + context: StaticCollectContext, + callPath: any, + collector: ReturnType, +): ResolvedTemplateExpression | undefined { + const keyframesResolved = collectKeyframesFromExpression( + t, + expression, + context, + callPath, + collector, + ); + if (keyframesResolved) return keyframesResolved; + + const resolved = resolveStaticExpression(t, expression, context, callPath); + if (resolved === undefined || typeof resolved === 'object') return undefined; + + return { + cssTexts: [], + text: String(resolved), + }; +} + +function buildCssSourceFromTemplate( t: any, quasi: any, context: StaticCollectContext, -): string | undefined => { + callPath: any, + collector: ReturnType, +): { cssSource: string; cssTexts: string[] } | undefined { let cssSource = ''; + const cssTexts: string[] = []; for (let i = 0; i < quasi.quasis.length; i += 1) { const quasiNode = quasi.quasis[i]; @@ -198,13 +684,194 @@ const buildCssSourceFromTemplate = ( if (i >= quasi.expressions.length) continue; const expression = quasi.expressions[i]; - const resolved = resolveTemplateExpression(t, expression, context); - if (typeof resolved !== 'string') return undefined; + const resolved = resolveTemplateExpression(t, expression, context, callPath, collector); + if (!resolved || typeof resolved.text !== 'string') return undefined; + + cssSource += resolved.text; - cssSource += resolved; + if (Array.isArray(resolved.cssTexts) && resolved.cssTexts.length > 0) { + cssTexts.push(...resolved.cssTexts); + } } - return cssSource; + return { + cssSource, + cssTexts, + }; +} + +const collectCssTaggedTemplate = ( + t: any, + taggedTemplate: any, + context: StaticCollectContext, + callPath: any, + collector: ReturnType, +): CollectedStyleValue | undefined => { + if (!t.isIdentifier(taggedTemplate.tag) || taggedTemplate.tag.name !== context.cssTagIdentifier) { + return undefined; + } + + const built = buildCssSourceFromTemplate(t, taggedTemplate.quasi, context, callPath, collector); + if (!built) return undefined; + + const className = collector.css(built.cssSource || ''); + const hash = className.startsWith(`${collector.cache.key}-`) + ? className.slice(collector.cache.key.length + 1) + : undefined; + if (!hash) return undefined; + + const cssText = collector.cache.inserted?.[hash]; + const cssTexts = [...(built.cssTexts || [])]; + if (typeof cssText === 'string' && cssText.trim()) cssTexts.push(cssText); + + return { + className, + cssTexts, + }; +}; + +const appendClassValue = (value: any, classNames: string[]): boolean => { + if (value === null || value === undefined || value === false) return true; + + if (typeof value === 'string') { + if (value.trim()) classNames.push(value.trim()); + return true; + } + + if (typeof value === 'number') { + if (!Number.isNaN(value) && value !== 0) classNames.push(String(value)); + return true; + } + + if (Array.isArray(value)) { + for (const item of value) { + if (!appendClassValue(item, classNames)) return false; + } + return true; + } + + if (typeof value === 'object') { + for (const [className, enabled] of Object.entries(value)) { + if (enabled) classNames.push(className); + } + return true; + } + + return false; +}; + +const collectCxCallExpression = ( + t: any, + callExpression: any, + context: StaticCollectContext, + callPath: any, + collector: ReturnType, + visiting = new Set(), +): CollectedStyleValue | undefined => { + if ( + !t.isIdentifier(callExpression.callee) || + !context.cxIdentifiers.has(callExpression.callee.name) + ) { + return undefined; + } + + const classNames: string[] = []; + const cssTexts: string[] = []; + + for (const argument of callExpression.arguments) { + if (t.isSpreadElement(argument)) return undefined; + + const arg = unwrapParenthesizedExpression(t, argument); + + if (t.isTaggedTemplateExpression(arg)) { + const collected = collectCssTaggedTemplate(t, arg, context, callPath, collector); + if (!collected) return undefined; + + classNames.push(collected.className); + cssTexts.push(...collected.cssTexts); + continue; + } + + if (t.isCallExpression(arg)) { + const nested = collectCxCallExpression(t, arg, context, callPath, collector, visiting); + if (!nested) return undefined; + + classNames.push(nested.className); + cssTexts.push(...nested.cssTexts); + continue; + } + + if (t.isIdentifier(arg) && visiting.has(arg.name)) return undefined; + + const resolved = resolveStaticExpression(t, arg, context, callPath, visiting); + if (resolved === undefined) return undefined; + + if (!appendClassValue(resolved, classNames)) return undefined; + } + + return { + className: classNames.filter(Boolean).join(' '), + cssTexts, + }; +}; + +const collectStyleValue = ( + t: any, + valueNode: any, + context: StaticCollectContext, + callPath: any, + collector: ReturnType, + visiting = new Set(), +): CollectedStyleValue | undefined => { + const value = unwrapParenthesizedExpression(t, valueNode); + + if (t.isTaggedTemplateExpression(value)) { + return collectCssTaggedTemplate(t, value, context, callPath, collector); + } + + if (t.isCallExpression(value)) { + return collectCxCallExpression(t, value, context, callPath, collector, visiting); + } + + if (t.isConditionalExpression(value)) { + const test = resolveStaticExpression(t, value.test, context, callPath, visiting); + if (test === undefined) return undefined; + + return collectStyleValue( + t, + test ? value.consequent : value.alternate, + context, + callPath, + collector, + visiting, + ); + } + + if (t.isIdentifier(value)) { + if (visiting.has(value.name)) return undefined; + + const initializer = getBindingInitializer(callPath, value.name); + if (initializer) { + visiting.add(value.name); + const resolved = collectStyleValue(t, initializer, context, callPath, collector, visiting); + visiting.delete(value.name); + if (resolved) return resolved; + } + } + + const resolved = resolveStaticExpression(t, value, context, callPath, visiting); + if (resolved === undefined) return undefined; + + const classNames: string[] = []; + if (!appendClassValue(resolved, classNames)) return undefined; + + const className = classNames.filter(Boolean).join(' '); + if (!className) return undefined; + + return { + className, + cssTexts: [], + }; }; const getStyleIdFromOptions = (t: any, optionsNode: any): string | undefined => { @@ -219,75 +886,110 @@ const getStyleIdFromOptions = (t: any, optionsNode: any): string | undefined => return undefined; }; +const createPrunedStylesFnNode = (t: any, styles: Record | string) => { + if (typeof styles === 'string') { + return t.arrowFunctionExpression([], t.stringLiteral(styles)); + } + + if (!styles || typeof styles !== 'object') return undefined; + + const properties = Object.entries(styles) + .map(([key, value]) => { + if (typeof value !== 'string') return undefined; + + return t.objectProperty(t.identifier(key), t.stringLiteral(value)); + }) + .filter(Boolean); + + if (properties.length === 0) return undefined; + + return t.arrowFunctionExpression([], t.objectExpression(properties)); +}; + const collectStaticChunk = ( t: any, callPath: any, styleId: string, emotionKey: string, cssVarPrefix: string, -): boolean => { + importedNames: ImportedStaticNames, + resolveImportedValue?: (meta: ImportBindingMeta) => string | number | boolean | undefined, +): Record | string | undefined => { const stylesFnNode = callPath.node.arguments[0]; - if (!stylesFnNode) return false; + if (!stylesFnNode) return undefined; - const context = getStaticCollectContext(t, stylesFnNode, cssVarPrefix); - if (!context) return false; + const context = getStaticCollectContext( + t, + stylesFnNode, + cssVarPrefix, + importedNames, + resolveImportedValue, + ); + if (!context) return undefined; - const returnedObject = getObjectExpressionFromReturn(t, stylesFnNode); - if (!returnedObject) return false; + const returned = getReturnedExpressionFromStylesFn(t, stylesFnNode); + if (!returned) return undefined; const collector = getEmotionCollector(emotionKey); - const styles: Record = {}; const cssParts: string[] = []; const cssSeen = new Set(); - for (const property of returnedObject.properties) { - if (!t.isObjectProperty(property)) return false; - - const key = - t.isIdentifier(property.key) || t.isStringLiteral(property.key) - ? property.key.name || property.key.value - : undefined; - if (!key) return false; - - if (!t.isTaggedTemplateExpression(property.value)) return false; - if ( - !t.isIdentifier(property.value.tag) || - property.value.tag.name !== context.cssTagIdentifier - ) { - return false; + const pushCssTexts = (texts: string[]) => { + for (const cssText of texts) { + if (!cssText || !cssText.trim()) continue; + if (cssSeen.has(cssText)) continue; + cssSeen.add(cssText); + cssParts.push(cssText); } + }; - const cssSource = buildCssSourceFromTemplate(t, property.value.quasi, context); - if (!cssSource || !cssSource.trim()) return false; + if (t.isObjectExpression(returned)) { + const styles: Record = {}; - const className = collector.css(cssSource); - const hash = className.startsWith(`${emotionKey}-`) - ? className.slice(emotionKey.length + 1) - : undefined; - if (!hash) return false; + if (returned.properties.length === 0) return undefined; - const cssText = collector.cache.inserted?.[hash]; - if (typeof cssText !== 'string' || !cssText.trim()) return false; + for (const property of returned.properties) { + if (!t.isObjectProperty(property)) return undefined; - styles[key] = className; + const key = + t.isIdentifier(property.key) || t.isStringLiteral(property.key) + ? property.key.name || property.key.value + : undefined; + if (!key) return undefined; - if (!cssSeen.has(cssText)) { - cssSeen.add(cssText); - cssParts.push(cssText); + const collected = collectStyleValue(t, property.value, context, callPath, collector); + if (!collected || !collected.className) return undefined; + + styles[key] = collected.className; + pushCssTexts(collected.cssTexts); } + + if (Object.keys(styles).length === 0) return undefined; + + pushCompiledExtractChunk({ + cssText: cssParts.join('\n'), + order: staticChunkOrder++, + styleId, + styles, + }); + + return styles; } - if (Object.keys(styles).length === 0) return false; + const collected = collectStyleValue(t, returned, context, callPath, collector); + if (!collected || !collected.className) return undefined; + + pushCssTexts(collected.cssTexts); pushCompiledExtractChunk({ cssText: cssParts.join('\n'), order: staticChunkOrder++, styleId, - styles, + styles: collected.className, }); - return true; + return collected.className; }; /** @@ -295,10 +997,8 @@ const collectStaticChunk = ( * injects `{ styleId: "..." }` as second argument for * `createStaticStyles(stylesFn)` calls imported from `antd-style`. * - * With `collectStatic: true`, it also performs a strict static collection pass: - * - only supports object-literal returns - * - supports plain template literals and limited `cssVar.xxx` interpolations - * - unsupported callsites are skipped and should fallback at runtime + * With `collectStatic: true`, it also performs a best-effort static collection pass. + * Unsupported callsites are skipped and will safely fallback to runtime generation. */ export const babelInjectStyleId = ({ types: t }: any) => ({ name: 'antd-style-babel-inject-style-id', @@ -309,6 +1009,8 @@ export const babelInjectStyleId = ({ types: t }: any) => ({ collectStatic = false, emotionKey = 'acss', cssVarPrefix = 'ant', + pruneRuntimeStyles = false, + resolveImportedValue, rootDir, salt, } = state.opts || {}; @@ -326,20 +1028,33 @@ export const babelInjectStyleId = ({ types: t }: any) => ({ : normalizedFile; const localNames = new Set(); + const importedNames: ImportedStaticNames = { + cssVar: new Set(), + cx: new Set(), + keyframes: new Set(), + responsive: new Set(), + }; + let callIndex = 0; + let fileCallsiteCount = 0; + let fileCollectedCount = 0; + let filePruneCount = 0; for (const nodePath of pathNode.get('body')) { if (!nodePath.isImportDeclaration()) continue; if (nodePath.node.source.value !== 'antd-style') continue; for (const specifier of nodePath.node.specifiers) { - if ( - t.isImportSpecifier(specifier) && - t.isIdentifier(specifier.imported) && - specifier.imported.name === 'createStaticStyles' - ) { - localNames.add(specifier.local.name); - } + if (!t.isImportSpecifier(specifier) || !t.isIdentifier(specifier.imported)) continue; + + const importedName = specifier.imported.name; + const localName = specifier.local.name; + + if (importedName === 'createStaticStyles') localNames.add(localName); + if (importedName === 'cssVar') importedNames.cssVar.add(localName); + if (importedName === 'responsive') importedNames.responsive.add(localName); + if (importedName === 'cx') importedNames.cx.add(localName); + if (importedName === 'keyframes') importedNames.keyframes.add(localName); } } @@ -350,6 +1065,8 @@ export const babelInjectStyleId = ({ types: t }: any) => ({ const callee = callPath.node.callee; if (!t.isIdentifier(callee) || !localNames.has(callee.name)) return; + fileCallsiteCount += 1; + if (callPath.node.arguments.length === 0) return; if (callPath.node.arguments.length < 2) { @@ -367,12 +1084,42 @@ export const babelInjectStyleId = ({ types: t }: any) => ({ if (!styleId) return; try { - collectStaticChunk(t, callPath, styleId, emotionKey, cssVarPrefix); + const collectedStyles = collectStaticChunk( + t, + callPath, + styleId, + emotionKey, + cssVarPrefix, + importedNames, + resolveImportedValue, + ); + + if (collectedStyles !== undefined) { + fileCollectedCount += 1; + } + + if (pruneRuntimeStyles && collectedStyles !== undefined) { + const prunedStylesFnNode = createPrunedStylesFnNode(t, collectedStyles); + if (prunedStylesFnNode) { + callPath.node.arguments[0] = prunedStylesFnNode; + filePruneCount += 1; + } + } } catch { // static collection is best-effort; fallback path remains runtime safe } }, }); + + if ( + typeof process !== 'undefined' && + process.env.ANTD_STYLE_EXTRACT_PRUNE_DEBUG === '1' && + fileCallsiteCount > 0 + ) { + console.info( + `[antd-style prune stats] ${relativeFile}: total=${fileCallsiteCount} collected=${fileCollectedCount} pruned=${filePruneCount}`, + ); + } }, }, }); diff --git a/src/extract/types.ts b/src/extract/types.ts index dbbfd1b6..aba3a592 100644 --- a/src/extract/types.ts +++ b/src/extract/types.ts @@ -68,6 +68,14 @@ export interface StaticCollectRule { exclude?: RegExp | ((resourcePath: string) => boolean); } +export interface StaticCollectImportMeta { + imported: string; + local: string; + source: string; + type: 'default' | 'named' | 'namespace'; + property?: string; +} + export interface StaticCollectOptions extends StaticCollectRule { emotionKey?: string; /** @@ -75,6 +83,15 @@ export interface StaticCollectOptions extends StaticCollectRule { * @default 'ant' */ cssVarPrefix?: string; + /** + * Replace collected createStaticStyles runtime function body with pre-resolved class map. + * @default false + */ + pruneRuntimeStyles?: boolean; + /** + * Optional resolver for imported values used in static expression evaluation. + */ + resolveImportedValue?: (meta: StaticCollectImportMeta) => string | number | boolean | undefined; rootDir?: string; salt?: string; } diff --git a/src/extract/viteEmitPlugin.ts b/src/extract/viteEmitPlugin.ts index 403f545a..d76adc2a 100644 --- a/src/extract/viteEmitPlugin.ts +++ b/src/extract/viteEmitPlugin.ts @@ -149,6 +149,8 @@ export const AntdStyleExtractVitePlugin = ( cssVarPrefix: resolved.staticCollect?.cssVarPrefix, rootDir: resolved.staticCollect?.rootDir, salt: resolved.staticCollect?.salt, + pruneRuntimeStyles: resolved.staticCollect?.pruneRuntimeStyles === true, + resolveImportedValue: resolved.staticCollect?.resolveImportedValue, }, ], ], diff --git a/src/extract/webpackEmitPlugin.ts b/src/extract/webpackEmitPlugin.ts index af21983d..61aeb533 100644 --- a/src/extract/webpackEmitPlugin.ts +++ b/src/extract/webpackEmitPlugin.ts @@ -160,6 +160,8 @@ export class AntdStyleExtractWebpackPlugin { cssVarPrefix: this.options.staticCollect?.cssVarPrefix, rootDir: this.options.staticCollect?.rootDir, salt: this.options.staticCollect?.salt, + pruneRuntimeStyles: this.options.staticCollect?.pruneRuntimeStyles === true, + resolveImportedValue: this.options.staticCollect?.resolveImportedValue, }, ], ],