Skip to content

feat: relocate the flat/tree package boundary into document-schema.js - #27

Merged
Mearman merged 5 commits into
mainfrom
feat/relocate-package-boundary
Aug 19, 2026
Merged

feat: relocate the flat/tree package boundary into document-schema.js#27
Mearman merged 5 commits into
mainfrom
feat/relocate-package-boundary

Conversation

@Mearman

@Mearman Mearman commented Aug 19, 2026

Copy link
Copy Markdown
Member

Moves decompose/flattenPackage/factorStyles/assemblePackage out of documents.js (src/convert/{decompose,flatten,factor-styles}.ts) and into this package, and exports them from the barrel. Purely additive — nothing already exported changes shape, so this is a minor.

Why here

The ecosystem is moving to make DocumentPackage the only type a codec exposes publicly. ooxml.js, odf.js, markdown-codec, and pdf-codec all depend on document-schema.js and none of them depends on documents.js — documents.js depends on them. So for a codec's public read/write functions to return and accept DocumentPackage directly, the transform has to sit at or below the schema layer. Anywhere higher and it's a cycle.

This is a deliberate amendment to the "pure Zod schemas, no behaviour" charter, not drift. decompose/flatten/factorStyles aren't business logic or format-specific behaviour — they're the canonical, purely structural, zero-I/O transform between the two shapes this package already defines, and their correctness contract is the three laws src/package.ts states. Everything the charter actually guards against (XML, ZIP, PDF, fonts, layout, bytes, filesystem) stays out. The container README and this package's own README/description are updated to say so.

Relocation, not rewrite

The three implementation modules are character-identical to documents.js's origin/main copies apart from their import blocks and a handful of comment lines that named documents.js as the transform's home. Verified by diffing with comments stripped: zero non-import differences. decompose.test.ts and factor-styles.test.ts came across verbatim — import-block diffs only.

The bijection suite

The three laws are the whole proof this move broke nothing, and they run here over a corpus covering every document kind, every leaf the tree vocabulary admits, and every grouping signal decompose reads. The construct-boundary corpus came across verbatim.

What could not come across is documents.js's corpus itself: it's built from readers, editors, and conversion captures belonging to packages that depend on this one, so importing it would invert the dependency this move exists to keep one-way. documents.js keeps running the same law harness over that real-format corpus, and should keep doing so once it switches to importing from here — the two are complementary, not redundant.

One deliberate change inside the harness: law (ii) now checks for a surviving { node, children } group wrapper rather than for any key named style. ContentStroke carries its own style field (solid/dashed/dotted/double), so the old key-name scan reads a dashed line on a draw page as a leaked style ref — it never fired in documents.js only because that corpus never produced one. A wrapper is the only shape a ref can sit on, so its absence is the stronger statement anyway.

Also added, closing gaps the old suite left:

  • flatten.test.ts — resolution semantics on trees a caller hands in directly (chain order, gap-fill never overwriting), plus the two refusals nothing covered before: a ref with no styles table behind it, and a formula package holding other than exactly one ContentFormula.
  • A workers-suite case running the whole assemble/mint/flatten round trip inside workerd. The transform is the one part of the published surface that's behaviour rather than schema, so "no platform API anywhere in it" is now a runtime-checked fact.
  • Smoke-test assertions that all six new names load from both the ESM and CJS builds.

Public surface

assemblePackage, decompose, flattenPackage, factorStyles, ConstructMarkerImbalanceError, and the PackageChildren type — the same list documents.js re-exports today. Named exports rather than the export * every other module uses, because that curates which names this index barrel re-exports as the intended six, rather than re-exporting whatever decompose/factor-styles/flatten happen to define. It does not make the modules' other helpers (decompose's per-container helpers, factor-styles' mint, the canonicaliser behind tuple identity) unreachable: per the README's "every module is also importable directly", package.json's "./*" subpath export already resolves each source module on its own, so e.g. document-schema.js/decompose and document-schema.js/factor-styles publish those helpers regardless of what the barrel re-exports. That subpath behaviour predates this PR and applies to every module in the package, not something this PR could scope down without turning off the wildcard export for the whole package.

Follow-up, not in this PR

documents.js still has its own copies. Once this releases, it should import from here and delete src/convert/{decompose,flatten,factor-styles,canonicalise}.ts, keeping its bijection suite pointed at the relocated implementation — that's the run that proves the real-format corpus still passes. Tracked in ExaDev/documents.js#650.

pnpm typecheck, lint, test, test:workers, build, and test:smoke are all green.

Generated by Claude Code

…lates

decompose, flattenPackage, factorStyles, and assemblePackage convert a flat
ContentDocument to and from the DocumentPackage tree: the two encodings this
package already defines, and the transform whose correctness contract is the
three laws src/package.ts states.

They belong here because ooxml.js, odf.js, markdown-codec, and pdf-codec all
depend on this package and none of them depends on documents.js, where the
transform previously lived. A codec whose public read/write functions speak
DocumentPackage directly can only reach the transform if the transform sits at
or below the schema layer; anywhere higher and it is a dependency cycle.

This amends the "schemas only" charter deliberately and narrowly. The transform
is purely structural and does no I/O: no XML, ZIP, PDF, fonts, layout, or bytes,
and nothing format-specific. canonicalise stays module-private -- it exists to
give minting one tuple-identity recipe, and publishing it would turn an internal
determinism device into an API guarantee about sort order.
…e laws

decompose.test.ts and factor-styles.test.ts pin the tree shape and the minting
mechanism: mandatory section groups, per-container heading/list stacks, the
never-cross-a-shape-boundary rule, construct-boundary promotion and its
imbalance refusal, the ban list, frozen keys, chain-scoped stripping, entry
ordering, and idempotence.

bijection.test.ts runs the three laws over a corpus spanning every document
kind, every leaf the tree vocabulary admits, and every grouping signal decompose
reads. Its anti-vacuity guards keep it honest: at least one entry must mint a
real styles table, every construct entry but the deliberately empty one must
mint a ref onto its own construct group, and every ContentDocument kind must be
represented.

Law (ii) checks for a surviving { node, children } group wrapper rather than for
any key named "style". A ContentStroke carries its own style field
(solid/dashed/dotted/double) as ordinary drawing content, so a key-name scan
reads a dashed line on a draw page as a leaked ref; a wrapper is the only shape
a ref can sit on, so its absence is the stronger statement anyway.

flatten.test.ts covers what only a direct entry reaches: which chain a position
resolves against, that gap-fill never overwrites a carried value, and the two
refusals a hand-built tree can trigger -- a ref with no styles table behind it,
and a formula package holding other than exactly one ContentFormula.
assemblePackage, decompose, flattenPackage, factorStyles,
ConstructMarkerImbalanceError, and the PackageChildren type join the barrel.
Purely additive: no existing export changes shape.

Named exports rather than the `export *` every other module uses, because these
modules carry genuine internals -- decompose's per-container helpers that
factor-styles calls, factor-styles' own mint entry point, and the canonicaliser
behind tuple identity. Adding a name to the public surface should be a decision,
not a side effect of exporting it from its module.

The smoke test asserts all six load from both the ESM and CJS builds, and the
workers suite runs the whole assemble/mint/flatten round trip inside workerd:
the transform is the one part of the published surface that is behaviour rather
than schema, so "no platform API anywhere in it" becomes a runtime-checked fact
rather than a design claim.
Five comments and the package description asserted that decompose, flatten, and
styles minting live in documents.js, and the README's charter line said "only
Zod schemas ... no behaviour". Both are now wrong: the transform is here.

Adds a "The package boundary" README section covering the four entry points, the
imbalance refusal, why the charter amendment is narrow (structural, zero-I/O,
format-agnostic -- everything the charter guards against stays out), and the
split between the law suite here and the real-format corpus documents.js keeps
running the same harness over.
…xport

The comments in src/index.ts and src/canonicalise.ts claimed decompose's per-container
helpers, factor-styles' mint, and canonicalise/canonicalKey stay outside the package's
public API because index.ts only re-exports six curated names.
That's false as published: package.json's "./*" export maps every dist module to its own
subpath, so document-schema.js/decompose, document-schema.js/factor-styles, and
document-schema.js/canonicalise already publish those symbols regardless of what the index
barrel re-exports, exactly as the README's "every module is also importable directly"
section already documents for every module in the package.
Reworded both comments to state what curating the barrel's named exports actually achieves
-- a smaller index.ts surface, not a smaller published surface.
@Mearman
Mearman merged commit 2401b66 into main Aug 19, 2026
11 checks passed
@Mearman
Mearman deleted the feat/relocate-package-boundary branch August 19, 2026 06:00
@github-actions

Copy link
Copy Markdown

🎉 This PR is included in version 4.3.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant