Skip to content

[Bug] astryx theme build copies an extensionless ./icons specifier into its ESM output — all 7 published themes' /built entries fail to load in Node #4620

Description

@imdreamrunner

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-L524importPath: 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions