Skip to content

Commit e5335cf

Browse files
authored
feat(react): emit per-component CSS token slices (#132)
* feat(react): emit per-component CSS token slices Replaces the monolithic 240KB base.css with per-tier (foundation, semantic) and per-component slice files. Each component entry now imports only the slices it actually needs, computed by closure over both var(--ty-*) references and style/index.tsx SCSS imports. Per-component CSS bundles drop ~60% raw / ~80% gzipped (Button: 261KB->103KB raw, 36KB->7.5KB gzip). Full-library bundle size is unchanged. base.css is still emitted for backward compatibility. * chore: add changeset for per-component CSS token slices
1 parent d06e59d commit e5335cf

4 files changed

Lines changed: 220 additions & 36 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@tiny-design/react": minor
3+
"@tiny-design/tokens": minor
4+
---
5+
6+
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/<name>.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.

packages/react/scripts/build-styles.js

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,37 @@ async function processWithPostcss(css) {
1919
return result.css;
2020
}
2121

22-
// 1. Base CSS: copy the runtime theme bundle from @tiny-design/tokens
22+
// 1. Base CSS: copy the runtime theme bundle from @tiny-design/tokens.
23+
// Also copies the per-tier (foundation/semantic) and per-component slice files
24+
// plus the slice manifest so inject-style-imports.js can wire components to slices.
2325
function copyBaseCss() {
24-
const src = require.resolve('@tiny-design/tokens/dist/css/base.css');
26+
const baseSrc = require.resolve('@tiny-design/tokens/dist/css/base.css');
27+
const tokensCssDir = path.dirname(baseSrc);
28+
const foundationSrc = path.join(tokensCssDir, 'foundation.css');
29+
const semanticSrc = path.join(tokensCssDir, 'semantic.css');
30+
const componentsSrcDir = path.join(tokensCssDir, 'components');
31+
const manifestSrc = path.join(tokensCssDir, 'component-deps.json');
32+
2533
for (const dir of [ES_DIR, LIB_DIR]) {
2634
const outDir = path.join(dir, 'style');
35+
const componentsOutDir = path.join(outDir, 'components');
2736
mkdirp(outDir);
28-
fs.copyFileSync(src, path.join(outDir, 'base.css'));
37+
mkdirp(componentsOutDir);
38+
fs.copyFileSync(baseSrc, path.join(outDir, 'base.css'));
39+
fs.copyFileSync(foundationSrc, path.join(outDir, 'foundation.css'));
40+
fs.copyFileSync(semanticSrc, path.join(outDir, 'semantic.css'));
41+
fs.copyFileSync(manifestSrc, path.join(outDir, 'component-deps.json'));
42+
43+
for (const file of fs.readdirSync(componentsSrcDir)) {
44+
if (!file.endsWith('.css')) continue;
45+
fs.copyFileSync(path.join(componentsSrcDir, file), path.join(componentsOutDir, file));
46+
}
2947
}
30-
console.log(' es/style/base.css + lib/style/base.css (copied from @tiny-design/tokens base theme CSS)');
48+
const sliceCount = fs.readdirSync(componentsSrcDir).filter((f) => f.endsWith('.css')).length;
49+
console.log(
50+
` es/style/{base,foundation,semantic}.css + lib/style/{base,foundation,semantic}.css copied`
51+
);
52+
console.log(` es/style/components/*.css + lib/style/components/*.css (${sliceCount} slices)`);
3153
}
3254

3355
// 2. Per-component CSS: compile each component's style/index.scss entry

packages/react/scripts/inject-style-imports.js

Lines changed: 44 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -69,41 +69,48 @@ function transformPath(cssPath) {
6969
return cssPath;
7070
}
7171

72+
function loadSliceManifest(baseDir) {
73+
const manifestPath = path.join(baseDir, 'style', 'component-deps.json');
74+
if (!fs.existsSync(manifestPath)) return {};
75+
return JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
76+
}
77+
78+
function formatImport(p, format) {
79+
return format === 'esm' ? `import '${p}';` : `require('${p}');`;
80+
}
81+
7282
/**
7383
* Build the CSS import lines to prepend to a component's index.js.
84+
*
85+
* Component CSS now resolves tokens through three layers:
86+
* 1. foundation.css — primitive token tier (always loaded)
87+
* 2. semantic.css — semantic token tier (always loaded)
88+
* 3. components/<slice>.css — only the component-tier slices this component needs
89+
* (own + transitive style/var() deps), per the slice manifest
90+
* After tokens come the component's own selectors and any composed selector deps.
7491
*/
75-
function buildImportLines(componentDir, format) {
92+
function buildImportLines(componentDir, format, manifest) {
93+
const componentName = path.basename(componentDir);
7694
const styleJsPath = path.join(componentDir, 'style', 'index.js');
7795
const ownCssPath = path.join(componentDir, 'style', 'index.css');
7896
const deps = parseCssDeps(styleJsPath);
7997

8098
const imports = [];
8199

82-
// Always import base styles first (theme, normalize, animations)
83-
const basePath = '../style/base.css';
84-
if (format === 'esm') {
85-
imports.push(`import '${basePath}';`);
86-
} else {
87-
imports.push(`require('${basePath}');`);
100+
imports.push(formatImport('../style/foundation.css', format));
101+
imports.push(formatImport('../style/semantic.css', format));
102+
103+
const sliceNames = manifest[componentName] || [];
104+
for (const sliceName of sliceNames) {
105+
imports.push(formatImport(`../style/components/${sliceName}.css`, format));
88106
}
89107

90108
if (deps.length > 0) {
91-
// Use parsed dependencies from style/index.js (includes own CSS + deps)
92109
for (const dep of deps) {
93-
const transformed = transformPath(dep);
94-
if (format === 'esm') {
95-
imports.push(`import '${transformed}';`);
96-
} else {
97-
imports.push(`require('${transformed}');`);
98-
}
110+
imports.push(formatImport(transformPath(dep), format));
99111
}
100112
} else if (fs.existsSync(ownCssPath)) {
101-
// Fallback: no style/index.js but has compiled CSS
102-
if (format === 'esm') {
103-
imports.push("import './style/index.css';");
104-
} else {
105-
imports.push("require('./style/index.css');");
106-
}
113+
imports.push(formatImport('./style/index.css', format));
107114
}
108115

109116
return imports;
@@ -112,17 +119,17 @@ function buildImportLines(componentDir, format) {
112119
/**
113120
* Inject CSS imports into a component's index.js file.
114121
*/
115-
function injectComponent(componentDir, format) {
122+
function injectComponent(componentDir, format, manifest) {
116123
const indexPath = path.join(componentDir, 'index.js');
117124
if (!fs.existsSync(indexPath)) return false;
118125

119-
const imports = buildImportLines(componentDir, format);
120-
if (imports.length <= 1) return false; // Only base import, no component CSS
126+
const imports = buildImportLines(componentDir, format, manifest);
127+
if (imports.length <= 2) return false; // Only foundation + semantic, no component CSS
121128

122129
const content = fs.readFileSync(indexPath, 'utf-8');
123130

124131
// Don't inject twice
125-
if (content.includes("style/index.css") || content.includes("style/base.css")) {
132+
if (content.includes('style/foundation.css') || content.includes('style/index.css')) {
126133
return false;
127134
}
128135

@@ -132,24 +139,29 @@ function injectComponent(componentDir, format) {
132139
}
133140

134141
/**
135-
* Inject base CSS import into the barrel index.js (es/index.js or lib/index.js).
142+
* Inject foundation + semantic token imports into the barrel index.js.
143+
* Per-component slices are NOT injected here — each component entry pulls its own slices.
136144
*/
137145
function injectBarrel(dir, format) {
138146
const indexPath = path.join(dir, 'index.js');
139147
if (!fs.existsSync(indexPath)) return;
140148

141149
const content = fs.readFileSync(indexPath, 'utf-8');
142-
if (content.includes("style/base.css")) return;
150+
if (content.includes('style/foundation.css')) return;
143151

144-
const line = format === 'esm'
145-
? "import './style/base.css';\n"
146-
: "require('./style/base.css');\n";
152+
const lines = [
153+
formatImport('./style/foundation.css', format),
154+
formatImport('./style/semantic.css', format),
155+
].join('\n') + '\n';
147156

148-
fs.writeFileSync(indexPath, line + content);
149-
console.log(` injected base CSS into ${path.relative(path.resolve(__dirname, '..'), indexPath)}`);
157+
fs.writeFileSync(indexPath, lines + content);
158+
console.log(
159+
` injected foundation + semantic CSS into ${path.relative(path.resolve(__dirname, '..'), indexPath)}`
160+
);
150161
}
151162

152163
function processDir(baseDir, format) {
164+
const manifest = loadSliceManifest(baseDir);
153165
const entries = fs.readdirSync(baseDir, { withFileTypes: true });
154166
let count = 0;
155167

@@ -159,7 +171,7 @@ function processDir(baseDir, format) {
159171
if (entry.name.startsWith('_') || entry.name === 'style' || entry.name === 'locale') continue;
160172

161173
const componentDir = path.join(baseDir, entry.name);
162-
if (injectComponent(componentDir, format)) {
174+
if (injectComponent(componentDir, format, manifest)) {
163175
count++;
164176
}
165177
}

packages/tokens/scripts/build-runtime.js

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -735,6 +735,127 @@ function buildBaseThemeCss(tokens, cssValues, tokenMap, lightTheme, darkTheme) {
735735
return parts.join('\n');
736736
}
737737

738+
function buildSliceCss(tokens, cssValues, tokenMap, lightTheme, darkTheme) {
739+
if (tokens.length === 0) return '';
740+
return buildBaseThemeCss(tokens, cssValues, tokenMap, lightTheme, darkTheme);
741+
}
742+
743+
const SLICE_SCAN_SKIP_DIRS = new Set(['__tests__', '__snapshots__', 'demo']);
744+
745+
function listSliceScanFiles(dirPath) {
746+
if (!fs.existsSync(dirPath)) return [];
747+
const result = [];
748+
for (const entry of fs.readdirSync(dirPath, { withFileTypes: true })) {
749+
if (SLICE_SCAN_SKIP_DIRS.has(entry.name)) continue;
750+
const full = path.join(dirPath, entry.name);
751+
if (entry.isDirectory()) {
752+
result.push(...listSliceScanFiles(full));
753+
} else if (CONSUMER_FILE_EXTENSIONS.has(path.extname(entry.name))) {
754+
result.push(full);
755+
}
756+
}
757+
return result;
758+
}
759+
760+
function listReactComponentDirs() {
761+
if (!fs.existsSync(REACT_SRC_DIR)) return [];
762+
return listDirectories(REACT_SRC_DIR).filter(
763+
(name) => !name.startsWith('_') && name !== 'style' && name !== 'locale'
764+
);
765+
}
766+
767+
function extractStyleEntryDeps(dirName, namespacesWithStyles) {
768+
const styleEntryPath = path.join(REACT_SRC_DIR, dirName, 'style', 'index.tsx');
769+
if (!fs.existsSync(styleEntryPath)) return new Set();
770+
771+
const sourceText = readText(styleEntryPath);
772+
const importRegex = /from\s+['"]([^'"]+)['"]|import\s+['"]([^'"]+)['"]/g;
773+
const deps = new Set();
774+
let match;
775+
776+
while ((match = importRegex.exec(sourceText)) !== null) {
777+
const importPath = match[1] || match[2];
778+
const styleDepMatch = importPath.match(/^\.\.\/\.\.\/([^/]+)\/style/);
779+
if (styleDepMatch && namespacesWithStyles.has(styleDepMatch[1])) {
780+
deps.add(styleDepMatch[1]);
781+
}
782+
}
783+
784+
return deps;
785+
}
786+
787+
function computeSliceManifest(allTokens) {
788+
const componentTokens = allTokens.filter((token) => token.category === 'component');
789+
const tokensByNamespace = new Map();
790+
for (const token of componentTokens) {
791+
if (!token.component) continue;
792+
if (!tokensByNamespace.has(token.component)) {
793+
tokensByNamespace.set(token.component, []);
794+
}
795+
tokensByNamespace.get(token.component).push(token);
796+
}
797+
798+
const varToNamespace = new Map();
799+
for (const token of componentTokens) {
800+
if (token.component) {
801+
varToNamespace.set(tokenKeyToCssVar(token.key), token.component);
802+
}
803+
}
804+
805+
const reactDirs = listReactComponentDirs();
806+
const namespacesWithStyles = new Set(reactDirs);
807+
808+
const directDeps = new Map();
809+
for (const dirName of reactDirs) {
810+
const dirPath = path.join(REACT_SRC_DIR, dirName);
811+
const deps = new Set();
812+
813+
for (const filePath of listSliceScanFiles(dirPath)) {
814+
const sourceText = readText(filePath);
815+
for (const reference of extractTyVarReferences(sourceText)) {
816+
const namespace = varToNamespace.get(reference.name);
817+
if (namespace) deps.add(namespace);
818+
}
819+
}
820+
821+
for (const styleDep of extractStyleEntryDeps(dirName, namespacesWithStyles)) {
822+
deps.add(styleDep);
823+
}
824+
825+
directDeps.set(dirName, deps);
826+
}
827+
828+
const closure = new Map();
829+
for (const dirName of directDeps.keys()) {
830+
const visited = new Set();
831+
const queue = [...(directDeps.get(dirName) || [])];
832+
while (queue.length > 0) {
833+
const namespace = queue.shift();
834+
if (visited.has(namespace)) continue;
835+
visited.add(namespace);
836+
const nsDeps = directDeps.get(namespace);
837+
if (nsDeps) {
838+
for (const next of nsDeps) {
839+
if (!visited.has(next)) queue.push(next);
840+
}
841+
}
842+
}
843+
closure.set(dirName, visited);
844+
}
845+
846+
const manifest = {};
847+
const tokenSliceNames = new Set(tokensByNamespace.keys());
848+
for (const [dirName, namespaceSet] of closure.entries()) {
849+
const sliceNames = Array.from(namespaceSet)
850+
.filter((namespace) => tokenSliceNames.has(namespace))
851+
.sort();
852+
if (sliceNames.length === 0) continue;
853+
manifest[dirName] = sliceNames;
854+
}
855+
856+
return { tokensByNamespace, manifest };
857+
}
858+
738859
function buildRegistryDts() {
739860
return `export type TokenCategory = 'primitive' | 'semantic' | 'component';
740861
export type TokenType =
@@ -970,13 +1091,32 @@ function buildRuntimeTokens(options = {}) {
9701091
const darkCss = buildThemeCss(allTokens, cssValues, tokenMap, darkThemeOverrides, "[data-tiny-theme='dark']");
9711092
const baseCss = buildBaseThemeCss(allTokens, cssValues, tokenMap, lightTheme, darkTheme);
9721093

1094+
const primitiveTier = allTokens.filter((token) => token.category === 'primitive');
1095+
const semanticTier = allTokens.filter((token) => token.category === 'semantic');
1096+
const foundationCss = buildSliceCss(primitiveTier, cssValues, tokenMap, lightTheme, darkTheme);
1097+
const semanticCss = buildSliceCss(semanticTier, cssValues, tokenMap, lightTheme, darkTheme);
1098+
1099+
const { tokensByNamespace, manifest } = computeSliceManifest(allTokens);
1100+
9731101
mkdirp(DIST_CSS_DIR);
9741102
mkdirp(SCHEMA_DIST_DIR);
1103+
const componentsDir = path.join(DIST_CSS_DIR, 'components');
1104+
mkdirp(componentsDir);
9751105
writeJson(path.join(DIST_DIR, 'registry.json'), registry);
9761106
writeJson(path.join(DIST_DIR, 'presets.json'), presets);
9771107
fs.writeFileSync(path.join(DIST_CSS_DIR, 'light.css'), lightCss);
9781108
fs.writeFileSync(path.join(DIST_CSS_DIR, 'dark.css'), darkCss);
9791109
fs.writeFileSync(path.join(DIST_CSS_DIR, 'base.css'), baseCss);
1110+
fs.writeFileSync(path.join(DIST_CSS_DIR, 'foundation.css'), foundationCss);
1111+
fs.writeFileSync(path.join(DIST_CSS_DIR, 'semantic.css'), semanticCss);
1112+
1113+
for (const [namespace, namespaceTokens] of tokensByNamespace.entries()) {
1114+
const sliceCss = buildSliceCss(namespaceTokens, cssValues, tokenMap, lightTheme, darkTheme);
1115+
fs.writeFileSync(path.join(componentsDir, `${namespace}.css`), sliceCss);
1116+
}
1117+
1118+
writeJson(path.join(DIST_CSS_DIR, 'component-deps.json'), manifest);
1119+
9801120
fs.writeFileSync(REGISTRY_DTS_PATH, buildRegistryDts());
9811121
fs.writeFileSync(PRESETS_DTS_PATH, buildPresetsDts(presets));
9821122
fs.copyFileSync(THEME_SCHEMA_PATH, path.join(SCHEMA_DIST_DIR, 'theme.schema.json'));
@@ -989,6 +1129,10 @@ function buildRuntimeTokens(options = {}) {
9891129
console.log(' dist/css/light.css');
9901130
console.log(' dist/css/dark.css');
9911131
console.log(' dist/css/base.css');
1132+
console.log(' dist/css/foundation.css');
1133+
console.log(' dist/css/semantic.css');
1134+
console.log(` dist/css/components/*.css (${tokensByNamespace.size} slices)`);
1135+
console.log(` dist/css/component-deps.json (${Object.keys(manifest).length} dirs)`);
9921136
console.log('\nRuntime tokens done.');
9931137

9941138
return { registry, presets };

0 commit comments

Comments
 (0)