Skip to content

Shrink output size - #109

Closed
JLHwung wants to merge 7 commits into
node-unicode:mainfrom
JLHwung:shrink-output-size
Closed

JLHwung wants to merge 7 commits into
node-unicode:mainfrom
JLHwung:shrink-output-size

Conversation

@JLHwung

@JLHwung JLHwung commented Oct 25, 2026

Copy link
Copy Markdown
Contributor

In this PR we shrink the output size by more than 50% with the help of LLM:

Reduces the published @unicode/unicode-18.0.0 packages from 700 KB to 312 KB packed, from 1.96 MB to 1.08 MB unpacked, and from 8,344 to 4,320 files. No module path, export shape, regex source, or map entry order changes; every step was verified against the previously generated output for all 35 Unicode versions.

Changes

Commit Change Packed size after
bfbcb5f Compress inline data with brotli instead of gzip 590 KB
7fb6a67 Build regex.mjs at runtime from ranges.mjs 444 KB
4b18111 Derive mapping symbols.mjs from code-points.mjs 410 KB
86b542e Front-code the Names table 390 KB
0086ab8 Delta-encode case and decomposition mappings 378 KB
0506410 Share type declarations via typesVersions 312 KB
22387c7 Declare the default export of the package index —

Brotli. gzipInline becomes brotliInline at maximum quality and window size. Generated modules import brotliDecompressSync from node:zlib.

Runtime regexes. Each regex.mjs held a regex literal from Regenerate; astral ranges expand into long surrogate-pair alternations (470 KB per package). regex.mjs now imports ranges.mjs and calls the new ranges-to-regex.mjs, a port of Regenerate's serializer. source and flags are byte-identical to the previous literals.

Derived symbols. For the four mapping types, symbols.mjs carried a second compressed copy of code-points.mjs. It now derives the map at runtime through mapping-to-symbols.mjs. Mappings with fewer than 10 entries keep their inline literals.

Front-coded Names. Names/index.mjs stores one name per line, front-coded against the previous name, with the run pairs as a JSON first line. decode-names.mjs rebuilds the same Map<number, string>. 158 KB → 123 KB.

Delta-coded mappings. Case and decomposition code-points.mjs files store zigzag deltas as variable-length integers in the same base64 scheme as ranges.mjs, decoded by decode-mapping.mjs. A count of 0 marks a scalar value, so files that mix scalar and array values (Unicode 3.0.1 Case_Folding/E) round-trip exactly.

Shared declarations. The four identical per-directory .d.mts files are written once under types/ and selected through a generated typesVersions map. Mapping directories keep their own code-points/symbols declarations because their value types differ; more specific patterns route them there. The build removes stale per-directory declarations from earlier runs.

Fixes found along the way

  • ranges.d.mts imported a UnicodeRange type that decode-ranges.d.mts only declared inside a namespace, so TypeScript consumers got an error on every ranges.mjs import. The type is now exported at module level.
  • index.d.mts declared named exports while index.mjs only has a default export, so import dirs from '@unicode/unicode-X' did not type-check. It now declares the default export with one readonly <Type>: string[] per property type.

Things to be aware of

  • Node.js floor. brotliDecompressSync requires Node.js 11.7 or later. The previous output needed only gunzipSync. Note that although we have not exactly specified the Node.js support, we already use Object.fromEntries in decode-ranges.mjs, which requires Node.js 12.0.0, so the switch from gzip to brotli should be safe.
  • TypeScript floor. .d.mts files already required TypeScript 4.7; the generated README now says so explicitly. typesVersions itself has existed since TypeScript 3.1.
  • Import cost. regex.mjs and mapping symbols.mjs now do a little work on import instead of shipping precomputed data. Names/index.mjs builds its map on import, as before.
  • Publishing. All 35 output/unicode-* trees were regenerated locally and need to be committed and tagged in their own repositories as usual.

Verification

  • npm test passes (21 tests). New tests assert that every regex.mjs matches Regenerate's output for the same ranges, that every mapping symbols.mjs matches its code-points.mjs, that representative Names lookups are correct, that the declaration layout and typesVersions targets are consistent, and that index.d.mts matches the keys exported by index.mjs.
  • One-off checks against the previous output across all 35 versions: 19,610 regex sources identical, 1,699 mapping symbols.mjs maps identical, 1,699 mapping code-points.mjs maps identical (entry order included), and all 35 Names maps identical by hash.
  • Type resolution checked with TypeScript 5.9 under node16, bundler and node10 resolution, and with TypeScript 4.7: imports of regex.mjs, ranges.mjs, code-points.mjs, symbols.mjs, a mapping code-points.mjs, Names/index.mjs and the package root resolve to the same types as before.

Switch `gzipInline` to `brotliInline`, using maximum quality and the
largest window so long-range repetition (e.g. in `Names/index.mjs`) is
exploited. Generated modules now import `brotliDecompressSync` from
`node:zlib`. For Unicode 18.0.0 this shrinks the package from 700 KB to
590 KB packed (1.96 MB to 1.79 MB unpacked); `Names/index.mjs` goes from
228 KB to 158 KB.

Assisted-By: Claude Fable 5.1
Each `regex.mjs` used to contain a regex literal produced by Regenerate,
and for astral ranges that expands into long surrogate-pair alternations
(470 KB per package, 190 KB of it surrogates). The data is already in
`ranges.mjs`, so `regex.mjs` now imports it and calls the new
`ranges-to-regex.mjs`, a port of Regenerate's serialization logic. The
resulting `source` and `flags` are identical to the previous literals;
a new test asserts this for every property against Regenerate itself,
and the port was checked against all 19,610 regexes previously generated
across the 35 Unicode versions.

For Unicode 18.0.0 this shrinks the package from 590 KB to 444 KB packed
(1.79 MB to 1.47 MB unpacked).

Assisted-By: Claude Fable 5.1
For Case_Folding, Simple_Case_Mapping, Special_Casing and
Decomposition_Mapping, `symbols.mjs` shipped a second compressed copy of
the same data as `code-points.mjs`, differing only in that every code
point was replaced by its string. It now imports `code-points.mjs` and
derives the map through the new `mapping-to-symbols.mjs`. Small mappings
(fewer than 10 entries) keep their inline literals. The resulting maps
are identical, including entry order; a new test checks this for every
mapping directory.

For Unicode 18.0.0 this shrinks the package from 444 KB to 410 KB packed
(1.47 MB to 1.43 MB unpacked).

Assisted-By: Claude Fable 5.1
`Names/index.mjs` stored every character name as a JSON string inside
the run-length array shared with the other property maps. Names are
sorted by code point and neighbouring names share long prefixes (e.g.
`LATIN SMALL LETTER A WITH …`), so the table is now written as text: a
first line with the `[gap, length]` run pairs as JSON, followed by one
front-coded name per line (a base64 digit for the number of characters
shared with the previous name, then the remainder). The new
`decode-names.mjs` rebuilds the same `Map<number, string>`, with the
same entries in the same order; this was verified against all 35
versions, and a new test checks representative lookups.

For Unicode 18.0.0 `Names/index.mjs` shrinks from 158 KB to 123 KB, and
the package from 410 KB to 390 KB packed (1.43 MB to 1.39 MB unpacked).

Assisted-By: Claude Fable 5.1
The `code-points.mjs` of Case_Folding, Simple_Case_Mapping,
Special_Casing and Decomposition_Mapping stored the map as compressed
JSON `[from, to]` pairs. Mapped code points are almost always close to
their keys, so the map is now written as a stream of variable-length
integers using the same base64 scheme as `ranges.mjs`: per entry, the
zigzag-encoded difference between its key and the previous key, a count,
and the zigzag-encoded differences between consecutive mapped code
points. A count of 0 marks an entry mapping to a single code point, so
files mixing scalar and array values (e.g. Unicode 3.0.1 Case_Folding/E)
round-trip exactly. The stream is brotli-compressed as before and decoded
by the new `decode-mapping.mjs`; `decode-ranges.mjs` now also exports
`decodeDeltas` for it. All 1,699 maps across the 35 versions are
identical to the previous output, entry order included.

For Unicode 18.0.0, `Simple_Case_Mapping/Uppercase/code-points.mjs`
shrinks from 4.1 KB to 1.1 KB, and the package from 390 KB to 378 KB
packed (1.39 MB to 1.38 MB unpacked).

Assisted-By: Claude Fable 5.1
Every property directory shipped four identical declaration files
(`ranges.d.mts`, `regex.d.mts`, `code-points.d.mts`, `symbols.d.mts`),
about 4,000 files per package. They are now written once under `types/`
and selected through a generated `typesVersions` map in package.json.
Mapping directories (Case_Folding, Simple_Case_Mapping, Special_Casing,
Decomposition_Mapping) keep their own `code-points`/`symbols`
declarations, since their value types differ per directory; more
specific patterns route them there. The build removes per-directory
declarations left over from earlier runs.

`decode-ranges.d.mts` now exports the `UnicodeRange` type at module
level; previously `ranges.d.mts` imported a name that was only declared
inside a namespace, so TypeScript consumers saw an error. The README now
documents TypeScript 4.7 as the minimum, which is what `.d.mts` requires.

Verified with TypeScript 5.9 (node16, bundler and node10 resolution) and
TypeScript 4.7: imports resolve to the same types as before. A new test
checks the declaration layout and the typesVersions targets.

For Unicode 18.0.0 this shrinks the package from 378 KB to 312 KB packed
(1.38 MB to 1.08 MB unpacked) and from 8,352 to 4,320 files.

Assisted-By: Claude Fable 5.1
`index.mjs` has a single default export, an object mapping each property
type to its list of directories, but `index.d.mts` declared named
exports instead, so `import dirs from '@unicode/unicode-X'` failed to
type-check. The declaration now describes the default export with one
`readonly <Type>: string[]` property per type. A test checks that the
declared keys match the object exported at runtime.

Assisted-By: Claude Fable 5.1
@JLHwung

JLHwung commented Oct 25, 2026

Copy link
Copy Markdown
Contributor Author

As for the Node.js floor, I suggest we explicitly specify the engines requirement in package.json, but that should be addressed in a different PR.

@mathiasbynens

mathiasbynens commented Oct 25, 2026 •

Copy link
Copy Markdown
Collaborator

Thanks for your work on this!

I’m considering merging this except for the runtime regexes (7fb6a67). This is the only change that I struggle to get behind. I like that it’s easy to look up the regular expressions for a given property/value pair just by checking e.g. https://github.com/node-unicode/unicode-18.0.0/blob/main/Script_Extensions/Adlam/regex.mjs, and that was one of the original motivations of this project (as the README change in that comment indeed suggests). I don’t like that static/ranges-to-regex.mjs reimplements parts of Regenerate, either.

mathiasbynens pushed a commit that referenced this pull request Oct 25, 2026
Switch `gzipInline` to `brotliInline`, using maximum quality and the
largest window so long-range repetition (e.g. in `Names/index.mjs`) is
exploited. Generated modules now import `brotliDecompressSync` from
`node:zlib`. For Unicode 18.0.0 this shrinks the package from 700 KB to
590 KB packed (1.96 MB to 1.79 MB unpacked); `Names/index.mjs` goes from
228 KB to 158 KB.

Assisted-By: Claude Fable 5.1
Issue: #109
mathiasbynens pushed a commit that referenced this pull request Oct 25, 2026
For Case_Folding, Simple_Case_Mapping, Special_Casing and
Decomposition_Mapping, `symbols.mjs` shipped a second compressed copy of
the same data as `code-points.mjs`, differing only in that every code
point was replaced by its string. It now imports `code-points.mjs` and
derives the map through the new `mapping-to-symbols.mjs`. Small mappings
(fewer than 10 entries) keep their inline literals. The resulting maps
are identical, including entry order; a new test checks this for every
mapping directory.

For Unicode 18.0.0 this shrinks the package from 444 KB to 410 KB packed
(1.47 MB to 1.43 MB unpacked).

Assisted-By: Claude Fable 5.1
Issue: #109
mathiasbynens pushed a commit that referenced this pull request Oct 25, 2026
`Names/index.mjs` stored every character name as a JSON string inside
the run-length array shared with the other property maps. Names are
sorted by code point and neighbouring names share long prefixes (e.g.
`LATIN SMALL LETTER A WITH …`), so the table is now written as text: a
first line with the `[gap, length]` run pairs as JSON, followed by one
front-coded name per line (a base64 digit for the number of characters
shared with the previous name, then the remainder). The new
`decode-names.mjs` rebuilds the same `Map<number, string>`, with the
same entries in the same order; this was verified against all 35
versions, and a new test checks representative lookups.

For Unicode 18.0.0 `Names/index.mjs` shrinks from 158 KB to 123 KB, and
the package from 410 KB to 390 KB packed (1.43 MB to 1.39 MB unpacked).

Assisted-By: Claude Fable 5.1
Issue: #109
mathiasbynens pushed a commit that referenced this pull request Oct 25, 2026
The `code-points.mjs` of Case_Folding, Simple_Case_Mapping,
Special_Casing and Decomposition_Mapping stored the map as compressed
JSON `[from, to]` pairs. Mapped code points are almost always close to
their keys, so the map is now written as a stream of variable-length
integers using the same base64 scheme as `ranges.mjs`: per entry, the
zigzag-encoded difference between its key and the previous key, a count,
and the zigzag-encoded differences between consecutive mapped code
points. A count of 0 marks an entry mapping to a single code point, so
files mixing scalar and array values (e.g. Unicode 3.0.1 Case_Folding/E)
round-trip exactly. The stream is brotli-compressed as before and decoded
by the new `decode-mapping.mjs`; `decode-ranges.mjs` now also exports
`decodeDeltas` for it. All 1,699 maps across the 35 versions are
identical to the previous output, entry order included.

For Unicode 18.0.0, `Simple_Case_Mapping/Uppercase/code-points.mjs`
shrinks from 4.1 KB to 1.1 KB, and the package from 390 KB to 378 KB
packed (1.39 MB to 1.38 MB unpacked).

Assisted-By: Claude Fable 5.1
Issue: #109
mathiasbynens pushed a commit that referenced this pull request Oct 25, 2026
Every property directory shipped four identical declaration files
(`ranges.d.mts`, `regex.d.mts`, `code-points.d.mts`, `symbols.d.mts`),
about 4,000 files per package. They are now written once under `types/`
and selected through a generated `typesVersions` map in package.json.
Mapping directories (Case_Folding, Simple_Case_Mapping, Special_Casing,
Decomposition_Mapping) keep their own `code-points`/`symbols`
declarations, since their value types differ per directory; more
specific patterns route them there. The build removes per-directory
declarations left over from earlier runs.

`decode-ranges.d.mts` now exports the `UnicodeRange` type at module
level; previously `ranges.d.mts` imported a name that was only declared
inside a namespace, so TypeScript consumers saw an error. The README now
documents TypeScript 4.7 as the minimum, which is what `.d.mts` requires.

Verified with TypeScript 5.9 (node16, bundler and node10 resolution) and
TypeScript 4.7: imports resolve to the same types as before. A new test
checks the declaration layout and the typesVersions targets.

For Unicode 18.0.0 this shrinks the package from 378 KB to 312 KB packed
(1.38 MB to 1.08 MB unpacked) and from 8,352 to 4,320 files.

Assisted-By: Claude Fable 5.1
Issue: #109
mathiasbynens pushed a commit that referenced this pull request Oct 25, 2026
`index.mjs` has a single default export, an object mapping each property
type to its list of directories, but `index.d.mts` declared named
exports instead, so `import dirs from '@unicode/unicode-X'` failed to
type-check. The declaration now describes the default export with one
`readonly <Type>: string[]` property per type. A test checks that the
declared keys match the object exported at runtime.

Assisted-By: Claude Fable 5.1
Issue: #109
@mathiasbynens

Copy link
Copy Markdown
Collaborator

I’ve merged the changes except for the runtime regex patch. Thanks a lot!

As for the Node.js floor, I suggest we explicitly specify the engines requirement in package.json, but that should be addressed in a different PR.

Agreed.

mathiasbynens added a commit that referenced this pull request Oct 25, 2026
The generated modules use `brotliDecompressSync` (Node.js 11.7+) and `Object.fromEntries` (Node.js 12+).

Issue: #109
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.

2 participants