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
Conversation
…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.
|
🎉 This PR is included in version 1.0.0 🎉 The release is available on: Your semantic-release bot 📦🚀 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements the phase-1 scope of #2, which stays open as the tracker for phase 2: the
PackageNodetree types with hand-written structural guards,decompose(pkg)/flatten(nodes, envelope)over the current flat package shape, theeffective()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, deprecatingbuildOutline) deliberately stays out of this PR.The tree follows the amended shape from ExaDev/document-schema.js#20's errata: groups are
{ node, children }wherenodeembeds an anchor paragraph (heading/list groups carry the fullContentParagraph— never a projected text label) or a container descriptor (section/slide/sheet/drawPage); a shape is its own group overContentShape-minus-blocks; bare leaves carrykindand neverchildren; discrimination is structural onnode+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.flattenis the exact inverse. Its second parameter is aDocumentEnvelope({ kind, metadata, symbolTable? }) rather than the issue's shorthandmetadata?:metadataalone cannot rebuild aContentDocument(the field is required),symbolTablemust round-trip for exactness, and carryingkindexplicitly 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.tsover 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-emptyembeddedObjectssheet, plus both empty-document edges):flatten(decompose(pkg))reproducespkg.contentexactly — compared viacanonicalise+ one JSON cycle against astructuredClonesnapshot, never identity (decompose shares node references) — up to one declared normalisation: a present-but-emptyembeddedObjectsarray 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.effective/effectiveTreeso 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.decompose(flatten(decompose(pkg))) === decompose(pkg).Because the laws alone would also pass for a degenerate flat-children decomposition,
decompose.test.tspins 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.tspins the discrimination rule behind the tree: every corpus root round-trips throughPackageNodeSchema.parseunchanged, and the near-miss rejections — a plain paragraph posing as an anchor, a rawContentShapeposing as its own descriptor, garbage under a valid descriptor, a defect nested one level down, the group/leaf cross-class confusions — are all asserted.buildOutlineis 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.