7.1 KiB
Design — split-font-compress
Non-normative. File layout for the two packages after the split, the rationale for duplicating the BTF wire-format constants, and the boundary between pack and unpack error registries.
Post-split file layout
packages/fonts/font-decompress/
packages/fonts/font-decompress/
package.json # @suaveplan/font-decompress v0.0.0, sideEffects: false
tsconfig.json
tsconfig.lib.json
vite.config.ts
vitest.config.ts
README.md
src/
index.ts # explicit named re-exports
constants/
index.ts # CMD_M, CMD_L, CMD_B, CMD_Q, CMD_Z, CMD_COORD_COUNTS, METRICS_COUNT
error-codes.ts # FONT_DECOMPRESS_CODES + FontDecompressError
error-codes.test.ts
error-codes.md
utils/
decode-string.ts # decodeString(data, offset)
decode-string.test.ts
decode-string.md
unpacker/
binary-font-unpacker/
binary-font-unpacker.ts # BinaryFontUnpacker class + createBinaryFontUnpacker factory
binary-font-unpacker.test.ts
binary-font-unpacker.md
index.ts
types/
index.ts # re-exports TypefaceFont, PathCommand from @suaveplan/font-convert
packages/fonts/font-compress/ (post-trim)
packages/fonts/font-compress/
package.json # unchanged version, subpath exports trimmed
README.md # updated to reflect packer-only scope
src/
index.ts # drops BinaryFontUnpacker / createBinaryFontUnpacker exports
constants/
index.ts # unchanged (CMD_*, CMD_COORD_COUNTS, METRICS_COUNT)
error-codes.ts # FONT_COMPRESS_CODES trimmed: { PACK_FAILED, INVALID_DATA }
utils/
encode-string.ts # encodeString only; decodeString removed
parse-path.ts # unchanged
packer/
binary-font-packer/
binary-font-packer.ts
binary-font-packer.test.ts
binary-font-packer.md
index.ts
# NOTE: src/unpacker/ removed entirely
Why duplicate the BTF constants rather than introducing a shared package
The BTF wire-format constants are 19 lines of magic numbers (CMD_M = 0, CMD_L = 1, etc.). Three alternatives were considered:
- Duplicate (chosen). Each package keeps its own copy of
constants/index.ts. Trivially low maintenance; if the format ever changes, both packages get updated in the same commit (which is when wire-format changes happen anyway — they are coordinated breaking events). - Move to a third
@suaveplan/font-typeface-formatpackage. Adds a coordinate package with three constants and no functional behaviour. Costs aworkspace:^dep on both packages plus another row inMANIFEST.mdfor marginal benefit. - Make
font-compressdepend onfont-decompressfor the constants. Asymmetric — compress conceptually generates what decompress reads, so a forward dep would invert the natural composition. Rejected.
Option 1 is the cleanest. The duplication is auditable in one place per package, both files are byte-identical, and bun scripts/folder-reorg/generate-manifest.ts confirms each package's dep count stays minimal.
Error registry split
font-compress originally shipped three codes: PACK_FAILED, UNPACK_FAILED, INVALID_DATA. Post-split:
| Code | font-compress | font-decompress | Notes |
|---|---|---|---|
PACK_FAILED |
✅ kept | — | Thrown by BinaryFontPacker.pack only |
UNPACK_FAILED |
❌ removed | ✅ added | Thrown by BinaryFontUnpacker.unpack only |
INVALID_DATA |
✅ kept | ✅ added | Both pack and unpack throw it on boundary-validation failures; namespaces keep the codes distinct |
The namespaces (FONT_COMPRESS vs FONT_DECOMPRESS) ensure that even codes with the same reason string are programmatically distinct:
FONT_COMPRESS.INVALID_DATA— pack input was malformedFONT_DECOMPRESS.INVALID_DATA— unpack input was malformed
Consumers dispatching on the error code see different strings; consumers using err instanceof FontCompressError vs err instanceof FontDecompressError see different classes.
Public-surface diffs
font-compress/src/index.ts (post-split)
export type { FontCompressErrorCode } from "./error-codes.js";
export { FONT_COMPRESS_CODES, FontCompressError } from "./error-codes.js";
export {
BinaryFontPacker,
createBinaryFontPacker,
} from "./packer/binary-font-packer/index.js";
// REMOVED: BinaryFontUnpacker + createBinaryFontUnpacker exports.
font-decompress/src/index.ts (new)
export type { FontDecompressErrorCode } from "./error-codes.js";
export { FONT_DECOMPRESS_CODES, FontDecompressError } from "./error-codes.js";
export {
BinaryFontUnpacker,
createBinaryFontUnpacker,
} from "./unpacker/binary-font-unpacker/index.js";
export type { PathCommand, TypefaceFont } from "@suaveplan/font-convert";
The convenience re-export of TypefaceFont and PathCommand from @suaveplan/font-convert lets consumers type their unpack(...) return value without taking an extra dep — but it is a single named re-export of types only, which CLAUDE.md rule §4 permits (the ban is on convenience re-exports of external runtime deps, not on types).
Actually — rule §4 says "NEVER re-export external deps for convenience — import directly from source." That applies here too. Removing the convenience re-export; consumers import TypefaceFont directly from @suaveplan/font-convert. The package's own types module just declares its public shapes.
Revised font-decompress/src/index.ts:
export type { FontDecompressErrorCode } from "./error-codes.js";
export { FONT_DECOMPRESS_CODES, FontDecompressError } from "./error-codes.js";
export {
BinaryFontUnpacker,
createBinaryFontUnpacker,
} from "./unpacker/binary-font-unpacker/index.js";
No type re-export. TypefaceFont and PathCommand come from @suaveplan/font-convert directly.
Test coverage strategy
font-decompressinherits the unpacker's existing 100% test file. The tests get moved verbatim alongside the implementation; only the import paths update. New tests cover the package's ownerror-codes.ts(every code reachable) anddecode-string.ts(byte-level boundary cases).font-compresskeeps its packer test file untouched. Itserror-codes.test.tsis regenerated to assert the trimmed registry (noUNPACK_FAILED). Theencode-string.test.tsis trimmed to drop thedecodeStringcases that move tofont-decompress.
Build/dist verification
Both packages run their gates independently:
bunx turbo lint typecheck test --filter=@suaveplan/font-compress
bunx turbo lint typecheck test --filter=@suaveplan/font-decompress
bunx turbo test:coverage --filter=@suaveplan/font-compress
bunx turbo test:coverage --filter=@suaveplan/font-decompress
bun run docs:check --filter=@suaveplan/font-compress
bun run docs:check --filter=@suaveplan/font-decompress
bunx turbo build --filter=@suaveplan/font-compress
bunx turbo build --filter=@suaveplan/font-decompress
All eight must pass before archive. Coverage thresholds are 100% Istanbul on both packages.