Skip to content

Add tsdown example project; fix Rolldown sprite assembly and declaration output - #9

Merged
evoactivity merged 2 commits into
svg-jar:mainfrom
NullVoxPopuli-ai-agent:tsdown-example
Jul 23, 2026
Merged

Add tsdown example project; fix Rolldown sprite assembly and declaration output#9
evoactivity merged 2 commits into
svg-jar:mainfrom
NullVoxPopuli-ai-agent:tsdown-example

Conversation

@NullVoxPopuli-ai-agent

Copy link
Copy Markdown

Adds an example tsdown project under test-projects/tsdown/library, and fixes the two bugs that stopped it from working.

Bug 1: Rolldown builds never assembled sprites

The @svg-jar/plugin/rolldown entry point existed, but the sprite-assembly hooks (renderChunk / generateBundle) were only registered under the vite and rollup keys in the unplugin factory. Under Rolldown (and anything built on it, like tsdown):

  • __SVG_JAR_SPRITE__<hash>__ placeholders survived into the output chunk unreplaced
  • no sprite sheets were emitted
  • ?file imports exported an asset URL pointing at a file that was never emitted

Fix: register the same hooks under the rolldown key (unplugin ≥3 supports it).

Bug 2: dts: true polluted declaration output with runtime JS

With tsdown's dts: true, a library entry that re-exports SVG components produced a broken index.d.ts: rolldown-plugin-dts resolves imports from virtual .d.ts twins of the source modules, and this plugin resolved those SVG imports into the regular JS module graph. The "declarations" ended up being a copy of the JS re-export chunk (importing from a .js file), and the shared SVG modules also forced an unnecessary chunk split in the JS output.

Fix: when the importer is a declaration module (.d.ts/.d.mts/.d.cts), resolveId now resolves SVG imports to a virtual .d.ts module, and load serves per-target declaration code mirroring the ambient types in client/*.d.ts (component shape per target, string for ?file). Libraries built with tsdown now get correctly typed exports:

declare const component: (options?: SvgOptions) => SVGSVGElement; // dom target
declare const url: string;                                        // ?file
export { component as Arrow, url as starUrl };

This only activates for declaration importers, so Vite/Rollup behavior is unchanged.

Example project

test-projects/tsdown/library is an icon library exercising sprite mode, a named sprite, ?unsafe-inline, and ?file, with dts: true. It's wired into build:test-projects so CI builds it alongside the Vite apps.

Also along the way

  • Rolldown integration test mirroring the Rollup one (asserts placeholders are replaced and sprite/file assets are emitted — a regression test for bug 1), plus unit tests for the new declaration codegen and virtual-id helpers
  • Fixed plugin typecheck, which was failing on main: codegen.test.ts's BASE_CTX was missing the required isEmbedded field
  • README: Rolldown + tsdown setup sections; CONTRIBUTING: new test-project layout

Testing

  • pnpm --filter @svg-jar/plugin test:unit — 145 passed (12 files)
  • pnpm --filter @svg-jar/plugin test:e2e — 121 passed
  • pnpm --filter @svg-jar/plugin typecheck — clean (was failing on main)
  • pnpm lint / prettier — clean
  • pnpm --filter @svg-jar/plugin build:test-projects — all Vite apps + the tsdown library build

🤖 Generated with Claude Code

The rolldown entry point existed but the sprite-assembly hooks
(renderChunk/generateBundle) were only registered for vite and rollup,
so Rolldown-based builds (including tsdown) left
__SVG_JAR_SPRITE__<hash>__ placeholders unreplaced and never emitted
sprite or ?file assets. Register the same hooks under the rolldown key.

With tsdown's dts: true, SVG imports re-exported from a library entry
polluted the declaration bundle with runtime JS: rolldown-plugin-dts
resolves imports from virtual .d.ts twins, and the plugin resolved
those SVG imports into the JS module graph. Resolve SVG imports from
declaration importers to virtual .d.ts modules instead, loaded as
per-target declaration code mirroring client/*.d.ts, so libraries get
correctly typed exports (and the JS chunk graph stays unpolluted).

Also:
- test-projects/tsdown/library: icon library example exercising sprite,
  named sprite, inline, and file modes with declaration output; built
  by build:test-projects
- Rolldown integration test mirroring the Rollup one, asserting
  placeholders are replaced and assets emitted
- fix codegen.test.ts missing isEmbedded (typecheck was failing)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@evoactivity

Copy link
Copy Markdown
Contributor

I reviewed this closely and built the tsdown example locally to verify the claims. Both bugs are real and genuinely fixed: the built index.js has resolved sprite URLs with no surviving placeholders, and index.d.ts has clean declarations with no runtime JS. Solid detective work on the dts-twin behavior.

That said, I think we should step back on scope before merging, because the example steers toward a pattern I don't think we want to ship.

Testing gap

Bug 1 has a proper regression test. Bug 2, the more subtle fix, only has isolated unit tests on generateDts. Nothing exercises the real path (isDtsImporter -> resolveId -> virtual .d.ts -> load -> rolldown-plugin-dts), and build:test-projects only checks that tsdown exits 0, which it also did before the fix while emitting a polluted .d.ts.

I'd add an integration test that builds the example and asserts on dist/index.d.ts: contains declare const component / declare const url, and does not contain createSvg or any .js import. This also guards against drift, since the detection is coupled to rolldown-plugin-dts internals and we're on prerelease rolldown/tsdown.

The bigger issue: prebaked sprites from a distributed library

Under tsdown, configResolved never runs (it's vite-only), so base stays / and the output bakes root-absolute strings like /assets/sprite-*.svg#id and /assets/star-*.svg. Those assets land in the library's own dist/assets and, because they're opaque strings rather than module imports, the consumer's bundler never copies them. So sprite and ?file from an installed library 404 unless the consumer hand-serves our dist.

More fundamentally, a sprite sheet is an application-level optimization. Its wins (app-wide dedup, per-symbol tree-shaking, one sheet, one deploy origin) only exist when a single build owns the whole graph. A library that prebakes a sheet assembles it from its own icon set, ships it as an opaque asset the app can't tree-shake or merge, and can't reliably serve. That's the costs of a sprite with none of the benefits. Shipping inline "fixes" the serving problem but throws away the entire reason svg-jar exists, so that isn't a defense either. The model that actually delivers our value through a library is: the library ships raw SVGs and the app runs svg-jar to assemble one sheet.

The one case where this is legitimate

An internal monorepo library consumed by an app you also own, where you control the deploy origin, add an asset-copy step, and pin a known base. That's a real use case, but it's a constrained build artifact, not a general icon library. The distinction that matters is not library vs app, it's whether you control the deploy origin.

Making the monorepo case work

I checked what rolldown/tsdown actually expose: no user-facing base / publicPath (the base-like bindings in rolldown are internal Vite-integration only), and no import.meta.ROLLUP_FILE_URL token. So two options if we want to support it:

  • Add a base option to the plugin. state.base already drives every asset URL and is only populated by the Vite hook today, so this is a few lines and matches the copy-step model.
  • Emit new URL('./assets/...', import.meta.url) in codegen instead of a baked absolute string. rolldown supports and rewrites that pattern, and the consumer's bundler would copy the asset automatically with zero config. More robust, but ESM-only, and the downstream copy-from-node_modules behavior needs verifying across Vite/rolldown/webpack before we trust it.

What I'd like to see on this PR

  1. Narrow the example to the case we actually support. If the target is the internal-monorepo scenario, make it look like that: a component that uses an icon, a base option, and a documented copy step, not a general icon library re-exporting sprite/named-sprite/file icons.
  2. Add the dts assertion test described above.
  3. Decide the scope question explicitly: is tsdown sprite output something we want to support at all? Sprites belong to apps, and apps build with Vite (supported) or rolldown-as-app-bundler, not tsdown. If public libraries are the goal, the library-appropriate outputs are raw-SVG passthrough or inline components, and I'd leave the tsdown sprite path out rather than showcase it.

The dts codegen itself is correct and worth keeping regardless, since re-exporting an icon as real public API is a legitimate thing someone will do. My concern is the example teaching a shape that breaks in production, not the fix.

@evoactivity

Copy link
Copy Markdown
Contributor

Might also be worth having a rolldown as app bundler test project rather than just the library use case.

… rolldown app project

- Add a `base` plugin option (trailing slash normalized) seeding
  state.base outside Vite; in Vite the resolved config base is used
  unless the option is set explicitly. This makes the internal-package
  copy-step model workable under Rolldown/tsdown, where configResolved
  never runs.
- Narrow the tsdown example to the supported scenario: renamed
  library -> internal-package, now a component using a sprite icon
  internally plus an inline re-export and one ?file export, built with
  base: '/vendor/icons/' and a README documenting the asset copy step
  and when prebaked sprites are (not) appropriate.
- Add a tsdown integration test that builds the example via execa
  (rebuilding the plugin first so it never tests a stale dist) and
  asserts on dist/index.js, dist/assets, and dist/index.d.ts - the
  declaration file must contain the typed exports and no runtime JS or
  .js imports. This covers the real dts path end to end; tsdown exiting
  0 was previously sufficient even with polluted declarations.
- Add test-projects/rolldown/vanilla: Rolldown as the application
  bundler, using the Vite apps' dist/assets layout, served with vite
  preview and covered by a new playwright project (port 4207) with a
  committed linux baseline.
- Extend the rolldown integration test with base-option URL assertions;
  unit tests for base resolution.
- README: document the base option and add a scope note - sprites are
  an app-level optimization; public libraries should ship raw SVGs or
  inline components; tsdown sprite output targets internal packages
  where the app controls the deploy origin.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@NullVoxPopuli-ai-agent

Copy link
Copy Markdown
Author

Thanks for the thorough review — the deploy-origin analysis was spot on, and you were right that tsdown exiting 0 proved nothing about the declaration output. All three asks addressed in 407d721, plus the app-bundler project from your follow-up comment:

1. Example narrowed to the internal-monorepo case. test-projects/tsdown/library is now test-projects/tsdown/internal-package: a component that uses a sprite icon internally, one inline re-export, and one ?file export, built with base: '/vendor/icons/'. Its README documents the copy step (cp -r <pkg>/dist/assets public/vendor/icons/assets) and states explicitly when this pattern applies (you control the deploy origin) and what public libraries should do instead (raw SVGs or inline components).

To make that work I went with your first option — a base plugin option. It seeds state.base for all bundlers (trailing slash normalized); under Vite the resolved config base still applies unless the option is set explicitly. You're right that it's a few lines — state.base already drove every URL. I left the new URL(..., import.meta.url) codegen idea alone for now given the cross-bundler verification it needs, but it would compose with this later as an opt-in emit mode.

2. dts assertion test added (test/integration/tsdown.test.ts). It builds the example via execa and asserts on the real dist output: placeholders replaced with base-prefixed URLs, assets on disk, and index.d.ts containing the typed exports (declare const component: (options?: SvgOptions) => SVGSVGElement, declare const url: string) with no runtime imports/calls and no .js imports. The beforeAll rebuilds the plugin first so the test can never pass against a stale dist — your observation that the old check would have passed before the fix was correct, and this one fails against the pre-fix build.

3. Scope decision, stated in the README (tsdown setup section, [!IMPORTANT] note): sprites are an application-level optimization; sprite/?file output from tsdown is supported only for internal packages where the consuming app controls the deploy origin (via base + copy step); public libraries should ship raw SVGs or inline components. I kept the tsdown sprite path rather than removing it, since with base it's now a working, documented, constrained artifact — but the example no longer showcases the general-icon-library shape.

4. Rolldown-as-app-bundler project (your second comment): added test-projects/rolldown/vanilla — Rolldown builds the app graph directly (rolldown -c, JS chunks emitted to dist/assets/ matching the Vite layout), served with vite preview on port 4207 and covered by a new Playwright project (15 tests incl. a committed linux visual baseline). This is the sprite path we do want to showcase outside Vite.

Local runs: 154 unit/integration, 136 e2e, lint/prettier/typecheck clean (root tsc error count drops 11 → 8; the remainder pre-exist in the Vite apps).

@evoactivity evoactivity added the enhancement New feature or request label Jul 23, 2026
@evoactivity
evoactivity merged commit 9515617 into svg-jar:main Jul 23, 2026
9 checks passed
@github-actions github-actions Bot mentioned this pull request Jul 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants