From 4a4054d840ac695da16b9f5d888c46f5dcea67ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Kuna?= <1282324+codecalm@users.noreply.github.com> Date: Sun, 4 Oct 2026 12:18:12 +0200 Subject: [PATCH] Speed up declaration bundling for JS framework packages (#1618) --- .build/build-icons.mjs | 63 +++++++++++++++++-- .build/rollup-plugins.mjs | 27 ++++++++ .gitignore | 1 + packages/icons-astro/build.mjs | 1 + packages/icons-astro/rollup.config.mjs | 4 +- packages/icons-preact/build.mjs | 1 + packages/icons-preact/rollup.config.mjs | 4 +- packages/icons-react-native/build.mjs | 1 + packages/icons-react-native/rollup.config.mjs | 28 +++------ packages/icons-react/build.mjs | 1 + packages/icons-react/rollup.config.mjs | 4 +- packages/icons-vue/build.mjs | 1 + packages/icons-vue/rollup.config.mjs | 3 +- 13 files changed, 106 insertions(+), 33 deletions(-) diff --git a/.build/build-icons.mjs b/.build/build-icons.mjs index 14451e841..748524a64 100644 --- a/.build/build-icons.mjs +++ b/.build/build-icons.mjs @@ -6,12 +6,23 @@ import { stringify } from 'svgson'; /** * Build icons * - * @param name - * @param componentTemplate - * @param indexIconTemplate - * @param indexTypeTemplate - * @param extension - * @param pretty + * @param name package directory name inside `packages/`, e.g. `icons-react` + * @param componentTemplate returns the source of a single icon module; called + * with `{ type, name, namePascal, children, stringify, svg }` + * @param indexItemTemplate returns the line re-exporting an icon from the + * icons index; called with `{ type, name, namePascal, svg }` + * @param aliasTemplate returns the line exporting an alias in + * `src/aliases.ts`; called with `{ from, to, fromPascal, toPascal }`. When + * omitted, the aliases file is left empty + * @param extension extension of the generated icon modules + * @param key add a `key` attribute (`svg-0`, `svg-1`, …) to every icon node + * @param pascalCase rename `stroke-width` to `strokeWidth` in icon nodes + * @param pascalName name icon modules `Icon` instead of the + * kebab-case icon name + * @param indexFile file name of the icons index inside `src/icons` + * @param dtsEntry generate `src/icons-dts`, a single-module stand-in for the + * icon modules that the declaration bundle is built from (see `iconsDts` + * in rollup-plugins.mjs) */ export const buildJsIcons = ({ name, @@ -23,12 +34,14 @@ export const buildJsIcons = ({ pascalCase = false, pascalName = true, indexFile = 'icons.ts', + dtsEntry = false, }) => { const DIST_DIR = path.resolve(PACKAGES_DIR, name); const aliases = getAliases(), allIcons = getAllIcons(true, true); let index = []; + let dtsIcons = []; Object.entries(allIcons).forEach(([type, icons]) => { icons.forEach((icon, i) => { // process.stdout.write( @@ -72,6 +85,13 @@ export const buildJsIcons = ({ ); fs.writeFileSync(filePath, component, 'utf-8'); + dtsIcons.push({ + exportName: `Icon${iconNamePascal}`, + module: path.basename(filePath, `.${extension}`), + // the JSDoc block documenting the component, if the template has one + doc: component.match(/\/\*\*(?:(?!\*\/)[^])*\*\/(?=\s*const )/)?.[0], + }); + index.push( indexItemTemplate({ type, @@ -99,6 +119,37 @@ export const buildJsIcons = ({ } fs.writeFileSync(path.resolve(DIST_DIR, `./src/aliases.ts`), aliasesStr || `export {};`, 'utf-8'); + + if (dtsEntry) { + buildDtsEntry({ dir: path.resolve(DIST_DIR, 'src/icons-dts'), icons: dtsIcons, aliases }); + } +}; + +/** + * Every icon module has the same type, but emitting declarations for each of + * them separately dominates the bundle build time. Instead, write one module + * that re-declares all icons with the type inferred from a real icon module, + * and one that maps aliases onto it. + */ +const buildDtsEntry = ({ dir, icons, aliases }) => { + fs.ensureDirSync(dir); + + const index = [`import Icon from '../icons/${icons[0].module}';`, '']; + icons.forEach(({ exportName, doc }) => { + if (doc) { + index.push(doc); + } + index.push(`export const ${exportName} = Icon;`); + }); + fs.writeFileSync(path.resolve(dir, 'index.ts'), index.join('\n'), 'utf-8'); + + const aliasesStr = Object.entries(aliases || {}) + .map( + ([from, to]) => + `export { Icon${toPascalCase(to)} as Icon${toPascalCase(from)} } from './index';\n`, + ) + .join(''); + fs.writeFileSync(path.resolve(dir, 'aliases.ts'), aliasesStr || `export {};`, 'utf-8'); }; export const buildIconsList = (name) => { diff --git a/.build/rollup-plugins.mjs b/.build/rollup-plugins.mjs index 421d28449..89c843869 100644 --- a/.build/rollup-plugins.mjs +++ b/.build/rollup-plugins.mjs @@ -2,6 +2,33 @@ import { visualizer } from 'rollup-plugin-visualizer' import license from 'rollup-plugin-license' import esbuild from 'rollup-plugin-esbuild' import { nodeResolve } from '@rollup/plugin-node-resolve'; +import path from 'path'; + +/** + * For the declaration bundle only: resolve the icons index and the aliases to + * the generated `src/icons-dts` modules (see `buildDtsEntry` in + * build-icons.mjs), so rollup-plugin-dts emits declarations for one module + * instead of one per icon. Must be placed before `dts()`. + */ +export const iconsDts = (srcDir = 'src') => { + const src = path.resolve(srcDir); + const redirects = { + [path.join(src, 'icons')]: path.join(src, 'icons-dts/index.ts'), + [path.join(src, 'icons/index')]: path.join(src, 'icons-dts/index.ts'), + [path.join(src, 'aliases')]: path.join(src, 'icons-dts/aliases.ts'), + }; + + return { + name: 'icons-dts', + resolveId(source, importer) { + if (!importer || !source.startsWith('.')) { + return null; + } + + return redirects[path.resolve(path.dirname(importer), source)] ?? null; + }, + }; +}; const getRollupPlugins = (pkg, minify) => { return [ diff --git a/.gitignore b/.gitignore index 0ee1fb5c1..07399acdd 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,7 @@ _import.tsv packages/icons*/icons/* packages/icons*/src/icons/* +packages/icons*/src/icons-dts/ packages/icons*/src/icons.js packages/icons*/src/icons.ts packages/icons*/src/aliases.ts diff --git a/packages/icons-astro/build.mjs b/packages/icons-astro/build.mjs index 6aebd5367..c0ac7bc0d 100644 --- a/packages/icons-astro/build.mjs +++ b/packages/icons-astro/build.mjs @@ -30,6 +30,7 @@ const indexItemTemplate = ({ const aliasTemplate = ({ fromPascal, to }) => `export { default as Icon${fromPascal} } from './icons/${to}';\n` buildJsIcons({ + dtsEntry: true, name: 'icons-astro', componentTemplate, indexItemTemplate, diff --git a/packages/icons-astro/rollup.config.mjs b/packages/icons-astro/rollup.config.mjs index 9570f95c0..6bb75b194 100644 --- a/packages/icons-astro/rollup.config.mjs +++ b/packages/icons-astro/rollup.config.mjs @@ -1,5 +1,5 @@ import fs from 'fs' -import { getRollupConfig } from '../../.build/rollup-plugins.mjs' +import { getRollupConfig, iconsDts } from '../../.build/rollup-plugins.mjs' import dts from 'rollup-plugin-dts' const pkg = JSON.parse(fs.readFileSync('package.json', 'utf-8')) @@ -37,7 +37,7 @@ export default [ format: 'es', }, ], - plugins: [dts()], + plugins: [iconsDts(), dts()], }, ...getRollupConfig(pkg, outputFileName, bundles, {}), ] diff --git a/packages/icons-preact/build.mjs b/packages/icons-preact/build.mjs index d92cfa3a3..a70a5b9b6 100644 --- a/packages/icons-preact/build.mjs +++ b/packages/icons-preact/build.mjs @@ -19,6 +19,7 @@ const indexItemTemplate = ({ const aliasTemplate = ({ fromPascal, toPascal }) => `export { default as Icon${fromPascal} } from './icons/Icon${toPascal}';\n` buildJsIcons({ + dtsEntry: true, name: 'icons-preact', componentTemplate, indexItemTemplate, diff --git a/packages/icons-preact/rollup.config.mjs b/packages/icons-preact/rollup.config.mjs index 10befcff2..b7359d075 100644 --- a/packages/icons-preact/rollup.config.mjs +++ b/packages/icons-preact/rollup.config.mjs @@ -1,5 +1,5 @@ import fs from 'fs' -import { getRollupConfig } from '../../.build/rollup-plugins.mjs' +import { getRollupConfig, iconsDts } from '../../.build/rollup-plugins.mjs' import dts from "rollup-plugin-dts"; const pkg = JSON.parse(fs.readFileSync('package.json', 'utf-8')) @@ -29,7 +29,7 @@ export default [ format: 'es', }, ], - plugins: [dts()], + plugins: [iconsDts(), dts()], }, ...getRollupConfig(pkg, outputFileName, bundles, { preact: 'preact' diff --git a/packages/icons-react-native/build.mjs b/packages/icons-react-native/build.mjs index 83a735858..7991469bb 100644 --- a/packages/icons-react-native/build.mjs +++ b/packages/icons-react-native/build.mjs @@ -19,6 +19,7 @@ const indexItemTemplate = ({ const aliasTemplate = ({ fromPascal, toPascal }) => `export { default as Icon${fromPascal} } from './icons/Icon${toPascal}';\n` buildJsIcons({ + dtsEntry: true, name: 'icons-react-native', componentTemplate, indexItemTemplate, diff --git a/packages/icons-react-native/rollup.config.mjs b/packages/icons-react-native/rollup.config.mjs index 1bec159c2..7115a35bb 100644 --- a/packages/icons-react-native/rollup.config.mjs +++ b/packages/icons-react-native/rollup.config.mjs @@ -1,5 +1,5 @@ import fs from 'fs'; -import { getRollupConfig } from '../../.build/rollup-plugins.mjs'; +import { getRollupConfig, iconsDts } from '../../.build/rollup-plugins.mjs'; import dts from 'rollup-plugin-dts'; const pkg = JSON.parse(fs.readFileSync('package.json', 'utf-8')); @@ -30,7 +30,7 @@ export default [ format: 'es', }, ], - plugins: [dts()], + plugins: [iconsDts(), dts()], }, { input: './src/icons/index.ts', @@ -49,26 +49,14 @@ export default [ emitDeclarationOnly: true, }, }), - ], - external: ['react', 'react-native-svg'], - }, - { - input: './src/icons/index.ts', - output: [ + // The CJS build ships the same per-icon declarations — copy them + // instead of emitting every icon a second time. { - dir: 'dist/cjs/icons', - format: 'es', - preserveModules: true, - preserveModulesRoot: 'src', - }, - ], - plugins: [ - dts({ - compilerOptions: { - declaration: true, - emitDeclarationOnly: true, + name: 'copy-icons-dts-to-cjs', + writeBundle() { + fs.cpSync('dist/icons', 'dist/cjs/icons', { recursive: true }); }, - }), + }, ], external: ['react', 'react-native-svg'], }, diff --git a/packages/icons-react/build.mjs b/packages/icons-react/build.mjs index b0a7c61b7..666bffe6e 100644 --- a/packages/icons-react/build.mjs +++ b/packages/icons-react/build.mjs @@ -41,6 +41,7 @@ const aliasTemplate = ({ fromPascal, toPascal }) => `export { default as Icon${fromPascal} } from './icons/Icon${toPascal}';\n`; buildJsIcons({ + dtsEntry: true, name: 'icons-react', componentTemplate, indexItemTemplate, diff --git a/packages/icons-react/rollup.config.mjs b/packages/icons-react/rollup.config.mjs index 7cb959c0e..9893f7374 100644 --- a/packages/icons-react/rollup.config.mjs +++ b/packages/icons-react/rollup.config.mjs @@ -1,5 +1,5 @@ import fs from 'fs'; -import { getRollupConfig } from '../../.build/rollup-plugins.mjs'; +import { getRollupConfig, iconsDts } from '../../.build/rollup-plugins.mjs'; import dts from 'rollup-plugin-dts'; const pkg = JSON.parse(fs.readFileSync('package.json', 'utf-8')); @@ -36,7 +36,7 @@ export default [ format: 'es', }, ], - plugins: [dts()], + plugins: [iconsDts(), dts()], }, ...getRollupConfig(pkg, outputFileName, bundles, { diff --git a/packages/icons-vue/build.mjs b/packages/icons-vue/build.mjs index f99fc9665..74486f05f 100644 --- a/packages/icons-vue/build.mjs +++ b/packages/icons-vue/build.mjs @@ -19,6 +19,7 @@ const indexItemTemplate = ({ const aliasTemplate = ({ fromPascal, toPascal }) => `export { default as Icon${fromPascal} } from './icons/Icon${toPascal}';\n` buildJsIcons({ + dtsEntry: true, name: 'icons-vue', componentTemplate, indexItemTemplate, diff --git a/packages/icons-vue/rollup.config.mjs b/packages/icons-vue/rollup.config.mjs index 49a1e1eee..1b80c1bd7 100644 --- a/packages/icons-vue/rollup.config.mjs +++ b/packages/icons-vue/rollup.config.mjs @@ -1,5 +1,5 @@ import fs from 'fs' -import { getRollupConfig } from '../../.build/rollup-plugins.mjs' +import { getRollupConfig, iconsDts } from '../../.build/rollup-plugins.mjs' import dts from "rollup-plugin-dts"; const pkg = JSON.parse(fs.readFileSync('package.json', 'utf-8')) @@ -30,6 +30,7 @@ export default [ }, ], plugins: [ + iconsDts(), dts({ compilerOptions: { preserveSymlinks: false