Files
suaveplan/openspec/archive/2026-05-17-split-font-compress/design.md
2026-05-27 23:29:53 +01:00

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:

  1. 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).
  2. Move to a third @suaveplan/font-typeface-format package. Adds a coordinate package with three constants and no functional behaviour. Costs a workspace:^ dep on both packages plus another row in MANIFEST.md for marginal benefit.
  3. Make font-compress depend on font-decompress for 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 malformed
  • FONT_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-decompress inherits 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 own error-codes.ts (every code reachable) and decode-string.ts (byte-level boundary cases).
  • font-compress keeps its packer test file untouched. Its error-codes.test.ts is regenerated to assert the trimmed registry (no UNPACK_FAILED). The encode-string.test.ts is trimmed to drop the decodeString cases that move to font-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.