Skip to content

build(esm): emit one bundle per form so a CDN page pays for what it uses - #431

Merged
forzagreen merged 1 commit into
mainfrom
build/per-form-bundles
Aug 21, 2026
Merged

build(esm): emit one bundle per form so a CDN page pays for what it uses#431
forzagreen merged 1 commit into
mainfrom
build/per-form-bundles

Conversation

@forzagreen

Copy link
Copy Markdown
Owner

Follows up @TylerVigario's proto/form-split numbers on #428. This takes the per-form half of that idea — the larger half by bytes — as a standalone change against main, independent of the currency-matrix discussion.

3 files, +85/−4.

The problem

A dist bundle is a prebuilt file fetched from a CDN, so whatever it contains is what the page downloads — there's no bundler on the other end to prune the forms it didn't ask for. dist/en-US.js carries all three, which is most of its weight for a page that only spells prices.

The change

Adds dist/{langCode}/{form}.js alongside the existing combined bundle, built from a virtual entry that re-exports the single form so Rollup drops the other two and everything only they reach — the currency vocabulary for a cardinal bundle, the scale tables for a currency one.

<script type="module">
  import { toCurrency } from 'https://cdn.jsdelivr.net/npm/n2words/dist/en-US/currency.js'
  toCurrency(42.50)  // 'forty-two dollars and fifty cents'
</script>

Measured from an actual npm run build:

en-US bytes
dist/en-US.js (all three) 7,778
dist/en-US/cardinal.js 4,293 −45%
dist/en-US/ordinal.js 3,815 −51%
dist/en-US/currency.js 3,827 −51%

Holds across languages — ja-JP 5,100 → 2,706/2,766/2,799, fr-FR 7,602 → 4,400/4,583/4,733.

Scope decisions

  • ESM only. UMD is the legacy path, its globals are already namespaced by form (n2words.currency.enUS), and splitting it would double the file count for the consumers least likely to be counting bytes.
  • Only forms a language exports, read from real module exports via getExportedForms — a language without toOrdinal gets no ordinal.js rather than an empty bundle.
  • 216 new files for the current 72 languages. That's tarball weight for a benefit only CDN consumers get; ESM-only keeps it to half of what a full split would cost.
  • FORM_EXPORTS moves from module-private to exported in language-helpers.js so rollup.config.js uses the same form → export-name mapping rather than a second copy that could drift.

npm consumers are unaffected and need nothing

src/{lang}.js already exports the three forms independently and the package is sideEffects-free, so import { toCurrency } from 'n2words/en-US' has always tree-shaken cardinals and ordinals away. This only closes the gap for the one consumer that couldn't.

No exports map changes — these are fetched by URL, not resolved as bare specifiers.

Verification

  • npm test — 453 tests pass
  • npm run lint — clean
  • npm run build — clean; 216 per-form bundles emitted, spot-checked that dist/en-US/currency.js runs and dist/en-US/cardinal.js contains no currency vocabulary

A dist bundle is a prebuilt file fetched from a CDN, so whatever it contains
is what the page downloads — there is no bundler on the other end to prune
the forms it didn't ask for. dist/en-US.js carries all three, which is most
of its weight for a page that only spells prices.

Adds dist/{langCode}/{form}.js alongside it, built from a virtual entry that
re-exports the single form so Rollup drops the other two and everything only
they reach — the currency vocabulary for a cardinal bundle, the scale tables
for a currency one:

  dist/en-US.js            7,778 B   all three forms
  dist/en-US/cardinal.js   4,293 B   -45%
  dist/en-US/ordinal.js    3,815 B   -51%
  dist/en-US/currency.js   3,827 B   -51%

Only for forms a language actually exports, read from the module's real
exports via getExportedForms — a language without toOrdinal gets no
ordinal.js rather than an empty bundle. 216 new files for the current 72
languages.

ESM only. UMD is the legacy path, its globals are already namespaced by form
(n2words.currency.enUS), and splitting it would double the file count for the
consumers least likely to be counting bytes.

npm consumers are unaffected and need nothing: src/{lang}.js already exports
the three forms independently and the package is sideEffects-free, so
`import { toCurrency } from 'n2words/en-US'` has always tree-shaken cardinals
and ordinals away. This closes the gap for the one consumer that couldn't.

FORM_EXPORTS moves from module-private to exported in language-helpers.js so
rollup.config.js uses the same form -> export-name mapping instead of
declaring a second copy that could drift.
@forzagreen
forzagreen merged commit 8782995 into main Aug 21, 2026
7 checks passed
@forzagreen
forzagreen deleted the build/per-form-bundles branch August 21, 2026 20:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant