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/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. 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..8029e38a --- /dev/null +++ b/scripts/verify-extract-prototype.cjs @@ -0,0 +1,433 @@ +/* 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, cssVar, responsive } from 'antd-style'; + + 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; }\`), + })); + + const atom = createStaticStyles(({ css }) => css\`margin: \${space + 3}px;\`); + + export { styles, atom }; + `; + + 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, 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'); +} + +function verifyViteEmitPlugin() { + m.clearCompiledExtractChunks(); + m.resetStaticCollectState(); + + let hasBabelCore = false; + try { + require.resolve('@babel/core'); + hasBabelCore = true; + } catch { + hasBabelCore = false; + } + + 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;}', + }, + ], + }, + ); + + 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( + { + 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); + + 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'); +} + +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/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/extract/ROADMAP.md b/src/extract/ROADMAP.md new file mode 100644 index 00000000..96a29327 --- /dev/null +++ b/src/extract/ROADMAP.md @@ -0,0 +1,40 @@ +# 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` + - supports optional `salt` (or `ANTD_STYLE_EXTRACT_SALT`) to avoid cross-project collisions + - 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`) +- 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 adapters + - Collect transformed modules with `styleId` + - Evaluate/serialize static CSS + - Feed chunks into extract core (`buildExtractAssets`) + - Emit CSS assets + JSON manifest +2. Bundler 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..a91b0bf7 --- /dev/null +++ b/src/extract/babelInjectStyleId.ts @@ -0,0 +1,1127 @@ +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; + /** + * Prefix used to resolve cssVar.xxx interpolations in static collection. + * @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; +} + +interface BabelState { + file?: { + opts?: { + filename?: string; + }; + }; + opts?: BabelPluginOptions; +} + +interface ImportedStaticNames { + cssVar: Set; + cx: Set; + keyframes: Set; + responsive: Set; +} + +interface StaticCollectContext { + cssTagIdentifier: 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; + +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 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; + + 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, + 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, + cssVarIdentifiers, + cssVarPrefix, + cxIdentifiers, + keyframesIdentifiers, + responsiveIdentifiers, + resolveImportedValue, + }; +}; + +const getReturnedExpressionFromStylesFn = (t: any, stylesFnNode: any) => { + if ( + (t.isArrowFunctionExpression(stylesFnNode) || t.isFunctionExpression(stylesFnNode)) && + stylesFnNode.body + ) { + const body = unwrapParenthesizedExpression(t, stylesFnNode.body); + if (!body) return undefined; + + if (!t.isBlockStatement(body)) return body; + + for (const statement of body.body) { + if (!t.isReturnStatement(statement)) continue; + if (!statement.argument) continue; + + const returned = unwrapParenthesizedExpression(t, statement.argument); + if (returned) return returned; + } + } + + 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 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, + 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 (i >= node.expressions.length) continue; + + const resolved = resolveStaticExpression(t, node.expressions[i], context, callPath, visiting); + if (resolved === undefined || typeof resolved === 'object') return undefined; + text += String(resolved); + } + + return text; + } + + 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; + + const initializer = getBindingInitializer(callPath, node.name); + if (!initializer) return undefined; + + visiting.add(node.name); + const resolved = resolveStaticExpression(t, initializer, context, callPath, visiting); + visiting.delete(node.name); + + 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; + + 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; +}; + +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, + 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]; + cssSource += quasiNode.value.cooked ?? quasiNode.value.raw; + + if (i >= quasi.expressions.length) continue; + + const expression = quasi.expressions[i]; + const resolved = resolveTemplateExpression(t, expression, context, callPath, collector); + if (!resolved || typeof resolved.text !== 'string') return undefined; + + cssSource += resolved.text; + + if (Array.isArray(resolved.cssTexts) && resolved.cssTexts.length > 0) { + cssTexts.push(...resolved.cssTexts); + } + } + + 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 => { + 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 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, + importedNames: ImportedStaticNames, + resolveImportedValue?: (meta: ImportBindingMeta) => string | number | boolean | undefined, +): Record | string | undefined => { + const stylesFnNode = callPath.node.arguments[0]; + if (!stylesFnNode) return undefined; + + const context = getStaticCollectContext( + t, + stylesFnNode, + cssVarPrefix, + importedNames, + resolveImportedValue, + ); + if (!context) return undefined; + + const returned = getReturnedExpressionFromStylesFn(t, stylesFnNode); + if (!returned) return undefined; + + const collector = getEmotionCollector(emotionKey); + + const cssParts: string[] = []; + const cssSeen = new Set(); + + 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); + } + }; + + if (t.isObjectExpression(returned)) { + const styles: Record = {}; + + if (returned.properties.length === 0) return undefined; + + for (const property of returned.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 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; + } + + 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: collected.className, + }); + + return collected.className; +}; + +/** + * Experimental Babel plugin: + * injects `{ styleId: "..." }` as second argument for + * `createStaticStyles(stylesFn)` calls imported from `antd-style`. + * + * 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', + visitor: { + Program(pathNode: any, state: BabelState) { + const filename = state.file?.opts?.filename || 'unknown'; + const { + collectStatic = false, + emotionKey = 'acss', + cssVarPrefix = 'ant', + pruneRuntimeStyles = false, + resolveImportedValue, + 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(); + 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)) 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); + } + } + + if (localNames.size === 0) return; + + pathNode.traverse({ + CallExpression(callPath: 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) { + 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 { + 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}`, + ); + } + }, + }, +}); + +export default babelInjectStyleId; diff --git a/src/extract/collector.ts b/src/extract/collector.ts new file mode 100644 index 00000000..153c6661 --- /dev/null +++ b/src/extract/collector.ts @@ -0,0 +1,21 @@ +import type { ExtractStyleChunk } from './types'; + +interface ExtractedChunk extends ExtractStyleChunk { + 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/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/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..57ec4691 --- /dev/null +++ b/src/extract/index.ts @@ -0,0 +1,24 @@ +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 type { + AntdStyleExtractPluginCommonOptions, + AntdStyleExtractVitePluginOptions, + AntdStyleExtractWebpackPluginOptions, + ExtractAssetBuildOptions, + ExtractAssetBuildResult, + ExtractStyleChunk, + ExtractStyleEntry, + ExtractStyleManifest, + StaticCollectOptions, + StaticCollectRule, +} from './types'; +export { AntdStyleExtractVitePlugin } from './viteEmitPlugin'; +export { AntdStyleExtractWebpackPlugin } from './webpackEmitPlugin'; diff --git a/src/extract/styleId.ts b/src/extract/styleId.ts new file mode 100644 index 00000000..2fb4371a --- /dev/null +++ b/src/extract/styleId.ts @@ -0,0 +1,22 @@ +export interface CreateStyleIdOptions { + salt?: string; +} + +/** + * Generate stable style id for createStaticStyles call sites. + * + * Stability source = transform input + optional salt. + * Salt priority: explicit options.salt > ANTD_STYLE_EXTRACT_SALT env. + */ +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 < 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 new file mode 100644 index 00000000..aba3a592 --- /dev/null +++ b/src/extract/types.ts @@ -0,0 +1,142 @@ +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; +} + +/** + * 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. + * + * CSS asset emission is handled by bundler plugins. + * This manifest only carries runtime class maps. + */ +export interface ExtractStyleManifest { + version: 1; + entries: ExtractStyleEntry[]; +} + +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 StaticCollectImportMeta { + imported: string; + local: string; + source: string; + type: 'default' | 'named' | 'namespace'; + property?: string; +} + +export interface StaticCollectOptions extends StaticCollectRule { + emotionKey?: string; + /** + * Prefix used to resolve cssVar.xxx interpolations during static collection. + * @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; +} + +export interface AntdStyleExtractPluginCommonOptions { + /** Output manifest file name */ + manifestFile?: string; + /** 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..d76adc2a --- /dev/null +++ b/src/extract/viteEmitPlugin.ts @@ -0,0 +1,190 @@ +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 []; +}; + +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. + * + * - 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; + + const babel = loadBabelCore(); + if (!babel) 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, + pruneRuntimeStyles: resolved.staticCollect?.pruneRuntimeStyles === true, + resolveImportedValue: resolved.staticCollect?.resolveImportedValue, + }, + ], + ], + 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 new file mode 100644 index 00000000..61aeb533 --- /dev/null +++ b/src/extract/webpackEmitPlugin.ts @@ -0,0 +1,259 @@ +import { babelInjectStyleId, resetStaticCollectState } from './babelInjectStyleId'; +import { pullExtractedChunks } from './collector'; +import { clearCompiledExtractChunks, pullCompiledExtractChunks } from './compiledCollector'; +import { buildExtractAssets } from './core'; +import type { + AntdStyleExtractWebpackPluginOptions, + ExtractStyleChunk, + StaticCollectRule, +} from './types'; + +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. + * + * Current capability: + * - emits manifest json + * - emits css file from extracted chunks + * + * 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< + 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, + getChunks: options.getChunks, + buildOptions: options.buildOptions, + experimentalStaticCollect: options.experimentalStaticCollect || false, + staticCollect: options.staticCollect, + useRuntimeCollector: options.useRuntimeCollector || false, + }; + } + + apply(compiler: any) { + 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, + }, + () => { + 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)), + ); + + 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) { + const babel = loadBabelCore(); + if (!babel) 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, + pruneRuntimeStyles: this.options.staticCollect?.pruneRuntimeStyles === true, + resolveImportedValue: this.options.staticCollect?.resolveImportedValue, + }, + ], + ], + }); + } 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 c5f11d24..31b77a37 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, @@ -82,7 +101,44 @@ 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[] = []; + classNames.forEach((cls) => { + const hash = cls.startsWith(`${emotionCache.key}-`) + ? cls.slice(emotionCache.key.length + 1) + : undefined; + if (!hash) return; + 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 { @@ -136,4 +192,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/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 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'; 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'; 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, } `); });