Skip to content
This repository was archived by the owner on Aug 20, 2026. It is now read-only.

feat: decompose/flatten over DocumentPackage with the three bijection laws (phase 1) - #3

Merged
Mearman merged 10 commits into
mainfrom
feat/decompose-flatten
Aug 18, 2026
Merged

feat: decompose/flatten over DocumentPackage with the three bijection laws (phase 1)#3
Mearman merged 10 commits into
mainfrom
feat/decompose-flatten

Conversation

@Mearman

@Mearman Mearman commented Aug 18, 2026

Copy link
Copy Markdown
Member

Implements the phase-1 scope of #2, which stays open as the tracker for phase 2: the PackageNode tree types with hand-written structural guards, decompose(pkg) / flatten(nodes, envelope) over the current flat package shape, the effective() resolution seam, and the three-law bijection property tests. The phase-2 work named in the issue (porting the pair into documents.js's package boundary, deprecating buildOutline) deliberately stays out of this PR.

The tree follows the amended shape from ExaDev/document-schema.js#20's errata: groups are { node, children } where node embeds an anchor paragraph (heading/list groups carry the full ContentParagraph — never a projected text label) or a container descriptor (section/slide/sheet/drawPage); a shape is its own group over ContentShape-minus-blocks; bare leaves carry kind and never children; discrimination is structural on node+children. Section groups are mandatory, and grouping never crosses container boundaries — each slide/drawPage shape groups its own inner blocks, a sheet's grid rides on the sheet descriptor, embedded documents stay intact as leaves.

flatten is the exact inverse. Its second parameter is a DocumentEnvelope ({ kind, metadata, symbolTable? }) rather than the issue's shorthand metadata?: metadata alone cannot rebuild a ContentDocument (the field is required), symbolTable must round-trip for exactness, and carrying kind explicitly is what keeps the bijection total for the legal empty documents (a presentation with no slides decomposes to an empty root array, and an inferred kind would be undefined exactly there). documentEnvelope(pkg.content) extracts it in one call.

The laws, pinned in src/outline/bijection.test.ts over an outline-local five-kind corpus (embedded formula object as the recursive arm, multi-section wordprocessing with distinct geometry and a symbolTable, a multi-frame wrapped run, two list-nested shapes on one slide, shapes+vectors on one drawing page, a declared-empty embeddedObjects sheet, plus both empty-document edges):

  1. flatten(decompose(pkg)) reproduces pkg.content exactly — compared via canonicalise + one JSON cycle against a structuredClone snapshot, never identity (decompose shares node references) — up to one declared normalisation: a present-but-empty embeddedObjects array on a sheet (schema-legal, emitted by no codec) round-trips to the field absent, because the tree's concatenated children cannot distinguish declared-empty from absent. The comparator applies the normalisation to both sides, so the law stays an equivalence over canonical forms, and the corpus carries a declared-empty sheet so the phase-3 documents.js gate inherits the normalisation as a rule rather than discovering it as a failure.
  2. Effective properties identical in both encodings — compared through effective/effectiveTree so the styles round-trip assertions slot in when the styles major lands without rewriting this test, alongside the flat encoding's zero-style-refs invariant.
  3. Minting idempotence: decompose(flatten(decompose(pkg))) === decompose(pkg).

Because the laws alone would also pass for a degenerate flat-children decomposition, decompose.test.ts pins the tree shape itself: per-section stack reset, the shape boundary, the grid on the sheet node, vectors as leaves, flatten's fail-loud envelope matching, and the normalisation's direction (declared-empty round-trips to absent). package-node.test.ts pins the discrimination rule behind the tree: every corpus root round-trips through PackageNodeSchema.parse unchanged, and the near-miss rejections — a plain paragraph posing as an anchor, a raw ContentShape posing as its own descriptor, garbage under a valid descriptor, a defect nested one level down, the group/leaf cross-class confusions — are all asserted. buildOutline is untouched — nothing the new types force changes it, and rewriting its cross-section/per-shape TOC semantics over per-container groups would change its published output shape, which is phase 2's job.

Mearman added 10 commits August 18, 2026 10:02
…guards

The package tree for document-schema.js#20's promoted DocumentPackage
(phase 1 per document-outline.js#2): groups are {node, children} where
node embeds an anchor paragraph (heading and list groups carry the full
ContentParagraph, never a projected text label) or a container
descriptor -- section {kind, pageSize, margins}, slide {kind, size,
notes}, sheet {kind, name, cells, columns, rows, printSettings},
drawPage {kind, size} -- and a shape is its own group over
ContentShape-minus-blocks. Bare leaves carry their own kind and never
children; discrimination is structural on node+children (the amended
rule -- "anything with kind is a leaf" collided with kind-tagged
groups). Section groups are mandatory because ContentSection carries
pre-layout geometry a rendered-pages array cannot hold, and grouping
never crosses container boundaries. isPackageGroup/isPackageLeaf/
isPackageNode are hand-written recursive guards in the z.custom pattern
(z.lazy collapses recursive schemas to unknown under the pinned zod 4);
descriptor validation delegates to document-schema.js's own exported
schemas so this package never owns a second copy of a schema shape.
decompose(pkg) reads the flat ContentDocument and wraps it into the
package tree without copying content: the tree's leaves are the
document's own node objects, embedded. Sections, slides, sheets, and
draw pages each become one top-level group per container, and grouping
happens within one container's block flow only -- each slide/drawPage
shape is its own group with its inner blocks nested by list.level
inside it (never a slide's paragraphs regrouped across its shapes,
which is the outline's lossy TOC projection), a sheet's grid rides on
the sheet descriptor with images then embedded objects as children, a
drawing page's children are shape groups then vector leaves, and an
embedded document stays intact as one leaf. Wordprocessing keeps the
outline builder's reviewed stack semantics (headingLevel stack,
list.level sub-nesting, plain paragraphs resetting the list nesting)
but per section: the stacks reset at each section boundary instead of
flowing sections into one tree. A formula document is its single
ContentFormula node. Descriptors are built by rest-destructuring the
lifted arrays out, so schema fields this package never names still
ride the descriptor.
flatten(nodes, envelope) is decompose's exact inverse: a pre-order walk
reconstituting sections, slides, sheets, and pages in document order,
re-emitting every group-represented paragraph as an ordinary block and
passing leaves through as the same objects. The DocumentEnvelope
carries the document-level fields a tree cannot hold -- kind, the
required metadata, and the optional symbolTable -- with kind taken
from the envelope rather than inferred because the empty documents
are legal and an inferred kind would break the bijection for exactly
those; documentEnvelope(content) extracts it in one call. Container
reconstruction strips the tree-only kind tag by spread (a descriptor
field added upstream rides without this package naming it), rebuilds
a sheet's embeddedObjects only when the sheet actually carried any so
absent stays absent, and reverses the sibling-array concatenations by
type partition. Roots that do not match the envelope kind throw
rather than producing silently-wrong containers.
effective resolves one node to its effective properties and
effectiveTree walks a whole tree through it. Today both are the
identity: no style layer exists yet (referenced styles arrive with
the schema major that carries the tree promotion), so a node's
effective properties are its own. Exported now rather than later so
the consumers that must not care about the difference already route
through the one seam where overlay resolution will land --
leafContentHash now hashes effective(leaf) (behaviour unchanged
today, stable across serialisation choices once styles exist), and
the bijection law tests compare through effectiveTree so law ii is
resolve-then-compare from day one.
The three laws that gate the DocumentPackage promotion, run per corpus
entry: (i) flatten(decompose(pkg)) reproduces pkg.content exactly;
(ii) effective properties are identical in both encodings, compared
through effectiveTree so the styles round-trip assertions slot in
without rewriting (today resolution is identity and every corpus
document is styles-table-free by construction), alongside the flat
encoding's zero-style-refs invariant; (iii) minting idempotence,
decompose(flatten(decompose(pkg))) equals decompose(pkg). Comparison
is canonicalise (now exported from hash.ts so the comparator and the
hash recipe cannot drift) plus one JSON cycle, never identity, with a
structuredClone snapshot taken before the round trip -- decompose
shares node references, so identity would pass even for a mutating
implementation. The corpus is outline-local (this package cannot
import documents.js) and covers the recursive embedded-formula arm,
multi-section wordprocessing with distinct geometry and a symbolTable,
a multi-frame wrapped run, two list-nested shapes on one slide, a
drawing page mixing shapes and vectors, and the empty-document edges
where the envelope's kind is the only kind carrier left. decompose
tests pin the tree shape itself -- the laws alone would pass for a
degenerate flat-children decomposition -- including the per-section
stack reset, the shape boundary, the grid on the sheet descriptor,
and flatten's fail-loud envelope matching.
… isolate

The whole bijection corpus now round-trips inside the Cloudflare
Workers runtime: decompose builds each tree, isPackageNode validates
every root, flatten walks it back out through the envelope, and
effectiveTree resolves it. Any Node-only API on those paths (or in
their document-schema.js schema-parse dependencies) would throw in
the isolate rather than the test passing -- the runtime complement
to the static ESLint Worker-isomorphism guard, now covering the new
surface.
…oundary

A new section covers the pair's signatures, the group vocabulary
(anchor paragraphs and the four container descriptors), the
container-boundary rule per kind, the envelope (why kind travels
beside the tree rather than being inferred -- the empty documents),
the three bijection laws with the property tests as the promotion
gate, and the phase-2 port into documents.js's package boundary that
deprecates buildOutline. The module table and the package tagline
gain the new surface.
…iss tests

The exported PackageNodeSchema, PackageGroupSchema, and PackageLeafSchema faces and the
guards behind them had no direct tests -- the bijection corpus exercises only the
positive isPackageNode path over decompose output.

Mirroring node.test.ts's OutlineNodeSchema block: every corpus decompose root
round-trips through PackageNodeSchema.parse unchanged, and safeParse rejects the
near-misses the structural discrimination rule exists to catch -- a plain
paragraph (no headingLevel, no list) posing as a group anchor, a raw ContentShape
still carrying blocks posing as its own descriptor, a garbage child under a valid
section descriptor, the same defect nested one level down, and the group/leaf
cross-class confusions.
…s exact up to

A present-but-empty embeddedObjects array is schema-legal (the field is
z.array().optional(), though no codec emits it), but decompose concatenates a
sheet's images and embedded objects into one children array, so declared-empty
is indistinguishable from absent there and flatten rebuilt the field absent --
an undeclared law (i) failure on a legal spelling.

Declare the normalisation rather than carry a presence marker through the tree
(a marker would be a tree-only field flatten must strip out of the descriptor
spread, breaking the ride-along rule for future schema fields). The corpus gains
a declared-empty sheet, which law (i) fails without the comparator change; the
comparator applies the normalisation to both sides so the law stays an
equivalence over canonical forms; decompose.test.ts pins the direction outright;
and the law statement plus the decompose/flatten comments name the normalisation
so the documents.js phase-3 gate inherits a rule, not an undeclared failure.
Every other module in the README's table is re-exported from src/index.ts and
importable from the package root; outline/hash is reachable only through its
subpath, so its table row promised a named import the root does not export.

Drop the row and state the split explicitly: the hash module stays
subpath-internal as on main, keeping the root surface at the phase's mandated
size.
@Mearman
Mearman merged commit d6d3439 into main Aug 18, 2026
9 checks passed
@Mearman
Mearman deleted the feat/decompose-flatten branch August 18, 2026 09:29
@github-actions

Copy link
Copy Markdown

🎉 This PR is included in version 1.0.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant