diff --git a/.changeset/per-component-css-token-slices.md b/.changeset/per-component-css-token-slices.md new file mode 100644 index 00000000..c6528b6a --- /dev/null +++ b/.changeset/per-component-css-token-slices.md @@ -0,0 +1,6 @@ +--- +"@tiny-design/react": minor +"@tiny-design/tokens": minor +--- + +Emit per-component CSS token slices to dramatically shrink per-component bundles. The tokens package now emits `dist/css/foundation.css` (primitives), `dist/css/semantic.css` (semantics), and `dist/css/components/.css` (per-component) alongside the existing `base.css`. Each compiled component entry imports only the slices it transitively needs, reducing per-component CSS by ~60% raw and ~80% gzipped (Button: 261 KB → 103 KB raw, 36 KB → 7.5 KB gzipped). Full-library bundle size is unchanged; `base.css` is still emitted for backward compatibility. diff --git a/packages/react/scripts/build-styles.js b/packages/react/scripts/build-styles.js index 8e564b32..4b586920 100644 --- a/packages/react/scripts/build-styles.js +++ b/packages/react/scripts/build-styles.js @@ -19,15 +19,37 @@ async function processWithPostcss(css) { return result.css; } -// 1. Base CSS: copy the runtime theme bundle from @tiny-design/tokens +// 1. Base CSS: copy the runtime theme bundle from @tiny-design/tokens. +// Also copies the per-tier (foundation/semantic) and per-component slice files +// plus the slice manifest so inject-style-imports.js can wire components to slices. function copyBaseCss() { - const src = require.resolve('@tiny-design/tokens/dist/css/base.css'); + const baseSrc = require.resolve('@tiny-design/tokens/dist/css/base.css'); + const tokensCssDir = path.dirname(baseSrc); + const foundationSrc = path.join(tokensCssDir, 'foundation.css'); + const semanticSrc = path.join(tokensCssDir, 'semantic.css'); + const componentsSrcDir = path.join(tokensCssDir, 'components'); + const manifestSrc = path.join(tokensCssDir, 'component-deps.json'); + for (const dir of [ES_DIR, LIB_DIR]) { const outDir = path.join(dir, 'style'); + const componentsOutDir = path.join(outDir, 'components'); mkdirp(outDir); - fs.copyFileSync(src, path.join(outDir, 'base.css')); + mkdirp(componentsOutDir); + fs.copyFileSync(baseSrc, path.join(outDir, 'base.css')); + fs.copyFileSync(foundationSrc, path.join(outDir, 'foundation.css')); + fs.copyFileSync(semanticSrc, path.join(outDir, 'semantic.css')); + fs.copyFileSync(manifestSrc, path.join(outDir, 'component-deps.json')); + + for (const file of fs.readdirSync(componentsSrcDir)) { + if (!file.endsWith('.css')) continue; + fs.copyFileSync(path.join(componentsSrcDir, file), path.join(componentsOutDir, file)); + } } - console.log(' es/style/base.css + lib/style/base.css (copied from @tiny-design/tokens base theme CSS)'); + const sliceCount = fs.readdirSync(componentsSrcDir).filter((f) => f.endsWith('.css')).length; + console.log( + ` es/style/{base,foundation,semantic}.css + lib/style/{base,foundation,semantic}.css copied` + ); + console.log(` es/style/components/*.css + lib/style/components/*.css (${sliceCount} slices)`); } // 2. Per-component CSS: compile each component's style/index.scss entry diff --git a/packages/react/scripts/inject-style-imports.js b/packages/react/scripts/inject-style-imports.js index 9eccb1aa..178ed9a9 100644 --- a/packages/react/scripts/inject-style-imports.js +++ b/packages/react/scripts/inject-style-imports.js @@ -69,41 +69,48 @@ function transformPath(cssPath) { return cssPath; } +function loadSliceManifest(baseDir) { + const manifestPath = path.join(baseDir, 'style', 'component-deps.json'); + if (!fs.existsSync(manifestPath)) return {}; + return JSON.parse(fs.readFileSync(manifestPath, 'utf-8')); +} + +function formatImport(p, format) { + return format === 'esm' ? `import '${p}';` : `require('${p}');`; +} + /** * Build the CSS import lines to prepend to a component's index.js. + * + * Component CSS now resolves tokens through three layers: + * 1. foundation.css — primitive token tier (always loaded) + * 2. semantic.css — semantic token tier (always loaded) + * 3. components/.css — only the component-tier slices this component needs + * (own + transitive style/var() deps), per the slice manifest + * After tokens come the component's own selectors and any composed selector deps. */ -function buildImportLines(componentDir, format) { +function buildImportLines(componentDir, format, manifest) { + const componentName = path.basename(componentDir); const styleJsPath = path.join(componentDir, 'style', 'index.js'); const ownCssPath = path.join(componentDir, 'style', 'index.css'); const deps = parseCssDeps(styleJsPath); const imports = []; - // Always import base styles first (theme, normalize, animations) - const basePath = '../style/base.css'; - if (format === 'esm') { - imports.push(`import '${basePath}';`); - } else { - imports.push(`require('${basePath}');`); + imports.push(formatImport('../style/foundation.css', format)); + imports.push(formatImport('../style/semantic.css', format)); + + const sliceNames = manifest[componentName] || []; + for (const sliceName of sliceNames) { + imports.push(formatImport(`../style/components/${sliceName}.css`, format)); } if (deps.length > 0) { - // Use parsed dependencies from style/index.js (includes own CSS + deps) for (const dep of deps) { - const transformed = transformPath(dep); - if (format === 'esm') { - imports.push(`import '${transformed}';`); - } else { - imports.push(`require('${transformed}');`); - } + imports.push(formatImport(transformPath(dep), format)); } } else if (fs.existsSync(ownCssPath)) { - // Fallback: no style/index.js but has compiled CSS - if (format === 'esm') { - imports.push("import './style/index.css';"); - } else { - imports.push("require('./style/index.css');"); - } + imports.push(formatImport('./style/index.css', format)); } return imports; @@ -112,17 +119,17 @@ function buildImportLines(componentDir, format) { /** * Inject CSS imports into a component's index.js file. */ -function injectComponent(componentDir, format) { +function injectComponent(componentDir, format, manifest) { const indexPath = path.join(componentDir, 'index.js'); if (!fs.existsSync(indexPath)) return false; - const imports = buildImportLines(componentDir, format); - if (imports.length <= 1) return false; // Only base import, no component CSS + const imports = buildImportLines(componentDir, format, manifest); + if (imports.length <= 2) return false; // Only foundation + semantic, no component CSS const content = fs.readFileSync(indexPath, 'utf-8'); // Don't inject twice - if (content.includes("style/index.css") || content.includes("style/base.css")) { + if (content.includes('style/foundation.css') || content.includes('style/index.css')) { return false; } @@ -132,24 +139,29 @@ function injectComponent(componentDir, format) { } /** - * Inject base CSS import into the barrel index.js (es/index.js or lib/index.js). + * Inject foundation + semantic token imports into the barrel index.js. + * Per-component slices are NOT injected here — each component entry pulls its own slices. */ function injectBarrel(dir, format) { const indexPath = path.join(dir, 'index.js'); if (!fs.existsSync(indexPath)) return; const content = fs.readFileSync(indexPath, 'utf-8'); - if (content.includes("style/base.css")) return; + if (content.includes('style/foundation.css')) return; - const line = format === 'esm' - ? "import './style/base.css';\n" - : "require('./style/base.css');\n"; + const lines = [ + formatImport('./style/foundation.css', format), + formatImport('./style/semantic.css', format), + ].join('\n') + '\n'; - fs.writeFileSync(indexPath, line + content); - console.log(` injected base CSS into ${path.relative(path.resolve(__dirname, '..'), indexPath)}`); + fs.writeFileSync(indexPath, lines + content); + console.log( + ` injected foundation + semantic CSS into ${path.relative(path.resolve(__dirname, '..'), indexPath)}` + ); } function processDir(baseDir, format) { + const manifest = loadSliceManifest(baseDir); const entries = fs.readdirSync(baseDir, { withFileTypes: true }); let count = 0; @@ -159,7 +171,7 @@ function processDir(baseDir, format) { if (entry.name.startsWith('_') || entry.name === 'style' || entry.name === 'locale') continue; const componentDir = path.join(baseDir, entry.name); - if (injectComponent(componentDir, format)) { + if (injectComponent(componentDir, format, manifest)) { count++; } } diff --git a/packages/tokens/scripts/build-runtime.js b/packages/tokens/scripts/build-runtime.js index bb8a8eee..cd4c3dd1 100644 --- a/packages/tokens/scripts/build-runtime.js +++ b/packages/tokens/scripts/build-runtime.js @@ -735,6 +735,127 @@ function buildBaseThemeCss(tokens, cssValues, tokenMap, lightTheme, darkTheme) { return parts.join('\n'); } +function buildSliceCss(tokens, cssValues, tokenMap, lightTheme, darkTheme) { + if (tokens.length === 0) return ''; + return buildBaseThemeCss(tokens, cssValues, tokenMap, lightTheme, darkTheme); +} + +const SLICE_SCAN_SKIP_DIRS = new Set(['__tests__', '__snapshots__', 'demo']); + +function listSliceScanFiles(dirPath) { + if (!fs.existsSync(dirPath)) return []; + const result = []; + for (const entry of fs.readdirSync(dirPath, { withFileTypes: true })) { + if (SLICE_SCAN_SKIP_DIRS.has(entry.name)) continue; + const full = path.join(dirPath, entry.name); + if (entry.isDirectory()) { + result.push(...listSliceScanFiles(full)); + } else if (CONSUMER_FILE_EXTENSIONS.has(path.extname(entry.name))) { + result.push(full); + } + } + return result; +} + +function listReactComponentDirs() { + if (!fs.existsSync(REACT_SRC_DIR)) return []; + return listDirectories(REACT_SRC_DIR).filter( + (name) => !name.startsWith('_') && name !== 'style' && name !== 'locale' + ); +} + +function extractStyleEntryDeps(dirName, namespacesWithStyles) { + const styleEntryPath = path.join(REACT_SRC_DIR, dirName, 'style', 'index.tsx'); + if (!fs.existsSync(styleEntryPath)) return new Set(); + + const sourceText = readText(styleEntryPath); + const importRegex = /from\s+['"]([^'"]+)['"]|import\s+['"]([^'"]+)['"]/g; + const deps = new Set(); + let match; + + while ((match = importRegex.exec(sourceText)) !== null) { + const importPath = match[1] || match[2]; + const styleDepMatch = importPath.match(/^\.\.\/\.\.\/([^/]+)\/style/); + if (styleDepMatch && namespacesWithStyles.has(styleDepMatch[1])) { + deps.add(styleDepMatch[1]); + } + } + + return deps; +} + +function computeSliceManifest(allTokens) { + const componentTokens = allTokens.filter((token) => token.category === 'component'); + const tokensByNamespace = new Map(); + for (const token of componentTokens) { + if (!token.component) continue; + if (!tokensByNamespace.has(token.component)) { + tokensByNamespace.set(token.component, []); + } + tokensByNamespace.get(token.component).push(token); + } + + const varToNamespace = new Map(); + for (const token of componentTokens) { + if (token.component) { + varToNamespace.set(tokenKeyToCssVar(token.key), token.component); + } + } + + const reactDirs = listReactComponentDirs(); + const namespacesWithStyles = new Set(reactDirs); + + const directDeps = new Map(); + for (const dirName of reactDirs) { + const dirPath = path.join(REACT_SRC_DIR, dirName); + const deps = new Set(); + + for (const filePath of listSliceScanFiles(dirPath)) { + const sourceText = readText(filePath); + for (const reference of extractTyVarReferences(sourceText)) { + const namespace = varToNamespace.get(reference.name); + if (namespace) deps.add(namespace); + } + } + + for (const styleDep of extractStyleEntryDeps(dirName, namespacesWithStyles)) { + deps.add(styleDep); + } + + directDeps.set(dirName, deps); + } + + const closure = new Map(); + for (const dirName of directDeps.keys()) { + const visited = new Set(); + const queue = [...(directDeps.get(dirName) || [])]; + while (queue.length > 0) { + const namespace = queue.shift(); + if (visited.has(namespace)) continue; + visited.add(namespace); + const nsDeps = directDeps.get(namespace); + if (nsDeps) { + for (const next of nsDeps) { + if (!visited.has(next)) queue.push(next); + } + } + } + closure.set(dirName, visited); + } + + const manifest = {}; + const tokenSliceNames = new Set(tokensByNamespace.keys()); + for (const [dirName, namespaceSet] of closure.entries()) { + const sliceNames = Array.from(namespaceSet) + .filter((namespace) => tokenSliceNames.has(namespace)) + .sort(); + if (sliceNames.length === 0) continue; + manifest[dirName] = sliceNames; + } + + return { tokensByNamespace, manifest }; +} + function buildRegistryDts() { return `export type TokenCategory = 'primitive' | 'semantic' | 'component'; export type TokenType = @@ -970,13 +1091,32 @@ function buildRuntimeTokens(options = {}) { const darkCss = buildThemeCss(allTokens, cssValues, tokenMap, darkThemeOverrides, "[data-tiny-theme='dark']"); const baseCss = buildBaseThemeCss(allTokens, cssValues, tokenMap, lightTheme, darkTheme); + const primitiveTier = allTokens.filter((token) => token.category === 'primitive'); + const semanticTier = allTokens.filter((token) => token.category === 'semantic'); + const foundationCss = buildSliceCss(primitiveTier, cssValues, tokenMap, lightTheme, darkTheme); + const semanticCss = buildSliceCss(semanticTier, cssValues, tokenMap, lightTheme, darkTheme); + + const { tokensByNamespace, manifest } = computeSliceManifest(allTokens); + mkdirp(DIST_CSS_DIR); mkdirp(SCHEMA_DIST_DIR); + const componentsDir = path.join(DIST_CSS_DIR, 'components'); + mkdirp(componentsDir); writeJson(path.join(DIST_DIR, 'registry.json'), registry); writeJson(path.join(DIST_DIR, 'presets.json'), presets); fs.writeFileSync(path.join(DIST_CSS_DIR, 'light.css'), lightCss); fs.writeFileSync(path.join(DIST_CSS_DIR, 'dark.css'), darkCss); fs.writeFileSync(path.join(DIST_CSS_DIR, 'base.css'), baseCss); + fs.writeFileSync(path.join(DIST_CSS_DIR, 'foundation.css'), foundationCss); + fs.writeFileSync(path.join(DIST_CSS_DIR, 'semantic.css'), semanticCss); + + for (const [namespace, namespaceTokens] of tokensByNamespace.entries()) { + const sliceCss = buildSliceCss(namespaceTokens, cssValues, tokenMap, lightTheme, darkTheme); + fs.writeFileSync(path.join(componentsDir, `${namespace}.css`), sliceCss); + } + + writeJson(path.join(DIST_CSS_DIR, 'component-deps.json'), manifest); + fs.writeFileSync(REGISTRY_DTS_PATH, buildRegistryDts()); fs.writeFileSync(PRESETS_DTS_PATH, buildPresetsDts(presets)); fs.copyFileSync(THEME_SCHEMA_PATH, path.join(SCHEMA_DIST_DIR, 'theme.schema.json')); @@ -989,6 +1129,10 @@ function buildRuntimeTokens(options = {}) { console.log(' dist/css/light.css'); console.log(' dist/css/dark.css'); console.log(' dist/css/base.css'); + console.log(' dist/css/foundation.css'); + console.log(' dist/css/semantic.css'); + console.log(` dist/css/components/*.css (${tokensByNamespace.size} slices)`); + console.log(` dist/css/component-deps.json (${Object.keys(manifest).length} dirs)`); console.log('\nRuntime tokens done.'); return { registry, presets };