Description
astryx theme build scrapes the icon-registry import out of the theme's TypeScript source and writes it verbatim into the JavaScript it generates. ./icons is valid TypeScript and invalid ESM, so the generated module cannot be loaded by Node — even though the file it names does exist in the published tarball.
$ npm i @astryxdesign/theme-neutral@0.2.0
$ node --input-type=module -e "import '@astryxdesign/theme-neutral/built'"
Error [ERR_MODULE_NOT_FOUND]: Cannot find module
'.../node_modules/@astryxdesign/theme-neutral/dist/icons'
imported from '.../dist/neutral.js'
$ node --input-type=commonjs -e "require('@astryxdesign/theme-neutral/built')"
same ERR_MODULE_NOT_FOUND
$ node --input-type=module -e "import '@astryxdesign/theme-neutral'"
OK # the bare '.' entry is fine
dist/icons.mjs and dist/icons.js are both present beside dist/neutral.js. Only the spelling is wrong.
Per https://nodejs.org/api/esm.html#mandatory-file-extensions:
A file extension must be provided when using the import keyword to resolve relative or absolute specifiers.
Expected: the module astryx theme build writes should be loadable by Node.
Root cause
extractIconInfo() keeps the raw capture from the source's import statement; generateBuiltModule() interpolates it straight into a .js artifact.
packages/cli/api/theme/build/build.mjs#L504-L524 — importPath: importMatch[1]
packages/cli/api/theme/build/build.mjs#L534-L537 — `import { ${iconInfo.exportName} } from '${iconInfo.importPath}';\n`
(Line 532 in the published @astryxdesign/cli@0.2.0 tarball at api/theme/build/build.mjs.)
The sibling generateBuiltTypes() declares the registry locally rather than re-importing it, so the .d.ts has no relative specifier at all and TypeScript consumers see a perfectly well-typed module. Only the runtime JS is broken, which would explain how this shipped.
Worth noting dist/<name>.js is the only runtime file in dist/ still carrying an unresolved relative specifier — tsup's entries are ['src/source.ts','src/icons.tsx'], so it never reads the generated file, and clean: false preserves it. That is also why the bare . entry works: its ./icons was bundled away.
Scope
All 7 published themes are identical here: dist/<name>.js line 8 is from './icons', no "type" field, exports["./built"] mapping both import and require to that file, and dist/icons.js + dist/icons.mjs beside it. Verified by installing all 7 at 0.2.0 and running both import and require — 14/14 fail; the bare . entry succeeds for all 7.
Not a 0.2.0 regression. theme-neutral 0.0.15, 0.1.0, 0.1.4, 0.1.8, 0.1.9 and 0.2.0 all ship the same line, and exports["./built"] is byte-identical back to 0.1.0. Last week's downloads across the 7 packages total ~224k, spread over eleven versions (0.1.8 alone 25.6k; 0.2.0 has 288).
Only theme sources whose icons: field is fed by an extensionless relative import are affected. A defineTheme with no icons field generates a clean module.
Who this affects
Tested against the published 0.2.0 packages. The pattern is that anything handing the module to Node's resolver fails; anything that bundles it passes.
| consumer |
result |
| Vite client build |
pass |
Vite SSR, ssr.noExternal |
pass |
| Vite SSR, default (deps externalized) |
fail |
Vite dev-server SSR (ssrLoadModule) |
fail |
| Next.js App Router (webpack and Turbopack) |
pass |
Next.js App Router + serverExternalPackages |
fail |
Next.js Pages Router + getServerSideProps |
fail — next build aborts |
Next.js Pages Router + transpilePackages |
pass |
| Vitest, default |
pass |
Vitest + server.deps.external |
fail |
| webpack, bundled |
pass |
webpack + externals (typical node-server build) |
fail |
plain Node, import and require |
fail |
| browser native ESM via CDN |
fail |
| TypeScript type-check |
pass |
Remix / React Router v7 framework mode was not separately installed; it is code-path-identical to the failing Vite SSR rows (their Vite plugins externalize node_modules for SSR by default).
/built is also the entry the project actively steers people toward: packages/core/src/theme/Theme.tsx:126 warns every runtime-theme user to switch to it, astryx init prints the same, and it appears in core/README.md three times plus all 7 theme READMEs. packages/cli/docs/theme.doc.mjs:455 gives its Best for as "Production, SSR apps (Next.js, Remix)".
Suggested fix
Please don't hardcode an extension. There is no single correct one:
tsup with no "type" field emits ESM as icons.mjs, CJS as icons.js.
tsup with "type": "module" emits ESM as icons.js, CJS as icons.cjs, and no icons.mjs at all.
So the right specifier depends on the consuming package's configuration, which the generator cannot see — and it runs before the compile step that produces the file.
A flag is the smallest honest fix. astryx theme build currently has only -o and -w:
--icons-specifier <spec> Specifier for the icon registry import in the generated
module, e.g. ./icons.mjs
Present → emit verbatim. Absent → emit today's bytes unchanged, so nothing regresses. Then the 7 packages declare it in their own build scripts, which is the one place that genuinely knows the answer since it is also the thing that configures tsup:
astryx theme build src/neutralTheme.ts -o dist/theme.css --icons-specifier ./icons.mjs && tsup && tsc …
Fixing the CLI alone reaches nobody — the broken string is already in the published tarballs, so the 7 packages need rebuilding and republishing, and only republished versions improve.
One measurement worth having before choosing: ./icons.js resolves in more places than ./icons.mjs (bundlers substitute .js → .ts/.tsx), which makes it look like the safer choice. It is a 2.08× client bundle regression. Three real Vite builds of the same app:
| specifier |
bundle |
gzip |
./icons (shipped) |
657.33 kB |
200.30 kB |
./icons.mjs |
657.33 kB (identical content hash) |
200.30 kB |
./icons.js |
1,367.51 kB |
392.29 kB |
dist/icons.js is the CJS half; pinning it forces the CJS build through Rollup interop and defeats lucide-react tree-shaking. Also note bundlers disagree today: esbuild and webpack resolve ./icons to icons.js, Vite/Rolldown resolves it to icons.mjs.
Why CI didn't catch it
scripts/verify-exports.mjs already runs in CI and already walks every exports map — but it existsSync()s each target and never opens it. Extending it to await import() each resolvable target is ~15 lines and goes red immediately against current bytes. That is the same hole #4569 fell through. publint and @arethetypeswrong/cli would also catch this class; the repo has neither.
Happy to open the PR.
Environment
@astryxdesign/{cli,core,theme-*} 0.2.0; also checked against canary 827f173 (unchanged)
- Node 22.17.1, macOS 26.6 (Darwin 25.6.0, arm64), Vite 8.2.0, esbuild 0.28.1, webpack 5.109.2, Next.js 15.5.22
Description
astryx theme buildscrapes the icon-registry import out of the theme's TypeScript source and writes it verbatim into the JavaScript it generates../iconsis valid TypeScript and invalid ESM, so the generated module cannot be loaded by Node — even though the file it names does exist in the published tarball.dist/icons.mjsanddist/icons.jsare both present besidedist/neutral.js. Only the spelling is wrong.Per https://nodejs.org/api/esm.html#mandatory-file-extensions:
Expected: the module
astryx theme buildwrites should be loadable by Node.Root cause
extractIconInfo()keeps the raw capture from the source's import statement;generateBuiltModule()interpolates it straight into a.jsartifact.packages/cli/api/theme/build/build.mjs#L504-L524—importPath: importMatch[1]packages/cli/api/theme/build/build.mjs#L534-L537—`import { ${iconInfo.exportName} } from '${iconInfo.importPath}';\n`(Line 532 in the published
@astryxdesign/cli@0.2.0tarball atapi/theme/build/build.mjs.)The sibling
generateBuiltTypes()declares the registry locally rather than re-importing it, so the.d.tshas no relative specifier at all and TypeScript consumers see a perfectly well-typed module. Only the runtime JS is broken, which would explain how this shipped.Worth noting
dist/<name>.jsis the only runtime file indist/still carrying an unresolved relative specifier —tsup's entries are['src/source.ts','src/icons.tsx'], so it never reads the generated file, andclean: falsepreserves it. That is also why the bare.entry works: its./iconswas bundled away.Scope
All 7 published themes are identical here:
dist/<name>.jsline 8 isfrom './icons', no"type"field,exports["./built"]mapping bothimportandrequireto that file, anddist/icons.js+dist/icons.mjsbeside it. Verified by installing all 7 at 0.2.0 and running bothimportandrequire— 14/14 fail; the bare.entry succeeds for all 7.Not a 0.2.0 regression.
theme-neutral0.0.15, 0.1.0, 0.1.4, 0.1.8, 0.1.9 and 0.2.0 all ship the same line, andexports["./built"]is byte-identical back to 0.1.0. Last week's downloads across the 7 packages total ~224k, spread over eleven versions (0.1.8 alone 25.6k; 0.2.0 has 288).Only theme sources whose
icons:field is fed by an extensionless relative import are affected. AdefineThemewith noiconsfield generates a clean module.Who this affects
Tested against the published 0.2.0 packages. The pattern is that anything handing the module to Node's resolver fails; anything that bundles it passes.
ssr.noExternalssrLoadModule)serverExternalPackagesgetServerSidePropsnext buildabortstranspilePackagesserver.deps.externalexternals(typical node-server build)importandrequireRemix / React Router v7 framework mode was not separately installed; it is code-path-identical to the failing Vite SSR rows (their Vite plugins externalize
node_modulesfor SSR by default)./builtis also the entry the project actively steers people toward:packages/core/src/theme/Theme.tsx:126warns every runtime-theme user to switch to it,astryx initprints the same, and it appears incore/README.mdthree times plus all 7 theme READMEs.packages/cli/docs/theme.doc.mjs:455gives its Best for as "Production, SSR apps (Next.js, Remix)".Suggested fix
Please don't hardcode an extension. There is no single correct one:
tsupwith no"type"field emits ESM asicons.mjs, CJS asicons.js.tsupwith"type": "module"emits ESM asicons.js, CJS asicons.cjs, and noicons.mjsat all.So the right specifier depends on the consuming package's configuration, which the generator cannot see — and it runs before the compile step that produces the file.
A flag is the smallest honest fix.
astryx theme buildcurrently has only-oand-w:Present → emit verbatim. Absent → emit today's bytes unchanged, so nothing regresses. Then the 7 packages declare it in their own build scripts, which is the one place that genuinely knows the answer since it is also the thing that configures
tsup:Fixing the CLI alone reaches nobody — the broken string is already in the published tarballs, so the 7 packages need rebuilding and republishing, and only republished versions improve.
One measurement worth having before choosing:
./icons.jsresolves in more places than./icons.mjs(bundlers substitute.js → .ts/.tsx), which makes it look like the safer choice. It is a 2.08× client bundle regression. Three real Vite builds of the same app:./icons(shipped)./icons.mjs./icons.jsdist/icons.jsis the CJS half; pinning it forces the CJS build through Rollup interop and defeatslucide-reacttree-shaking. Also note bundlers disagree today: esbuild and webpack resolve./iconstoicons.js, Vite/Rolldown resolves it toicons.mjs.Why CI didn't catch it
scripts/verify-exports.mjsalready runs in CI and already walks everyexportsmap — but itexistsSync()s each target and never opens it. Extending it toawait import()each resolvable target is ~15 lines and goes red immediately against current bytes. That is the same hole #4569 fell through.publintand@arethetypeswrong/cliwould also catch this class; the repo has neither.Happy to open the PR.
Environment
@astryxdesign/{cli,core,theme-*}0.2.0; also checked against canary827f173(unchanged)