Skip to content

4.0.0: tree-form DocumentPackage, definitions tables, $schema versioning, LayoutDocument demotion - #23

Merged
Mearman merged 6 commits into
mainfrom
feat/tree-package
Aug 18, 2026
Merged

4.0.0: tree-form DocumentPackage, definitions tables, $schema versioning, LayoutDocument demotion#23
Mearman merged 6 commits into
mainfrom
feat/tree-package

Conversation

@Mearman

@Mearman Mearman commented Aug 18, 2026

Copy link
Copy Markdown
Member

Closes #20
Closes #21

The coordinated major. Four changes that share files, so they land together:

The tree. DocumentPackageSchema is now the single hierarchical artefact: the root carries kind (moved up from the retired flat content field), metadata, the document-level symbolTable, optional pages, optional styles/definitions tables, and children — one group per top-level container. The node vocabulary lives in src/package-node.ts and follows document-outline.js's phase-1 reference implementation (its package-node.ts/decompose.ts/flatten.ts, merged on main) node for node: { node, children } groups with container descriptors or full anchor paragraphs as the node payload, bare leaves carrying kind and never children, section groups mandatory, grouping never crossing container boundaries, discrimination structural on node+children. Phase 2's port into documents.js (documents.js#623) should be mechanical.

Deviations from the reference shape, all forced by this package's zod-first conventions or its untrusted-input role:

  • The descriptors are real z.objects built by omit().extend().strict() from the content schemas, not TS Omit types over hand guards — same single-sourcing intent, the schema package's native spelling. Strictness is deliberate: a flat container smuggled in as a descriptor (a ContentShape still carrying blocks) is rejected rather than silently stripped.
  • The group guards validate children per kind (a slide holds shape groups only, a heading never appears under a list, …) where the reference's isPackageGroup checks children generically. The reference walks trees it constructed itself; this schema is the boundary validator for trees it did not, so an illegal child position rejects here.
  • style?: string sits on every group wrapper (the reference predates styles). No ref field exists anywhere on content nodes, per Package-local referenced styles and group inheritance: compression-only, materialised on flatten #21's errata.
  • The root is a five-arm discriminated union so children's type says exactly which root group each kind takes — the reference expresses the same narrowing through PackageRoot; zod says it through the union.

Definitions tables + styles. The generic facility: a tenant-generic definitions record (entries tagged kind, open body — future link/footnote tenants, see #22 and markdown-codec#63) alongside the styles tenant's own table. Style entries are strict objects of resolved canonical properties only — frames/sourcePath/styleId are rejected by schema shape, not documented away. Pure helpers (overlayStyleEntries, resolveStyleChain, applyParagraphStyleProperties, applyRunStyleProperties) export for documents.js's flatten; minting stays documents.js's (#21's errata). resolveStyleChain throws on an unresolvable ref; ref/table consistency is producer responsibility, same as pages/frames always was.

Versioning by $schema. DOCUMENT_PACKAGE_FORMAT_VERSION, CONTENT_FORMAT_VERSION, and (with the demotion) LAYOUT_FORMAT_VERSION retire. Today's CDN URIs were already release-pinned (@__PACKAGE_VERSION__), so no new path segment was needed — what changed is that documentFromJson now enforces it: the URI's major must match the installed release's major (same major parses — semver-compatible generation; older major throws SchemaVersionMismatchError naming the formatVersion/tree change; newer major throws the upgrade pointer). A bare DocumentPackageSchema.parse does not version-discriminate — that contract is documented in src/schema-io.ts and the README. Content hashes exclude $schema (envelope metadata — noted on the stamping functions).

Layout demotion (the schema side of pdf-codec#65 — that issue closes in pdf-codec's own PR, not here): src/layout.ts + test deleted, LayoutCodec gone from codec.ts, the LayoutDocument kind gone from schema-io/generator/smoke, with the tombstone kept — a layout-document $schema URI from any release throws LayoutSchemaDemotedError pointing at pdf-codec. pdf-codec takes the file in W3; dependents stay on 3.x via semver until their own majors, so this is not a cascade-breaker.

Also in: content-json-schema-defs.ts transcribes the recursive PackageNode set and the new tables — 27 of the new fragments have real z.object counterparts and are live-compared in the regression test (52 entries now); only the seven group wrappers and ContentEmbeddedObject remain hand-verified alone. The generator splices the one $defs block into both published files so each resolves its local pointers (the only cross-file $ref stays the embedded-object cycle). document-outline.js#2 is the reference implementation this tracks.

Review attention, in order of how much I'd want a second pair of eyes: the per-kind child predicates in src/package-node.ts (they are new enforcement the reference doesn't have — check nothing legal got rejected), the version-gate semantics in src/schema-io.ts (same-major acceptance is the semver reading of "URI → exact schema"; a stricter exact-match reading would refuse patch-release dumps), and the both-files $defs splice in the generator.

…by $schema URI

The DocumentPackage envelope becomes the single hierarchical artefact
(#20): the root carries kind, metadata, the document-level symbolTable,
optional rendered pages, optional package-level styles/definitions
tables, and children -- one group per top-level container, with the
node vocabulary in src/package-node.ts following document-outline.js's
phase-1 reference shape exactly. Section groups are mandatory, grouping
never crosses container boundaries, and discrimination is structural on
node+children rather than on the presence of a kind.

The generic package-level definitions-table facility lands with styles
as its first tenant (#21): style entries carry strict paragraph/run
sub-objects of resolved canonical properties only -- the
frames/sourcePath/styleId ban is enforced by schema shape (strictObject
rejects those keys outright), never by convention -- tree groups carry
style refs into the table, ContentDocument nodes carry none so the flat
codec-exchange form stays fully materialised, and the pure
overlay/resolve/apply helpers export for the documents.js package
boundary. Minting stays documents.js's behaviour.

Versioning moves entirely to the serialised-artefact boundary: both
DocumentPackage's and ContentDocument's formatVersion literals retire,
and the release-pinned $schema URI a dumper stamps is the version.
documentFromJson is the enforcement point for untrusted input -- same
major parses, an older major throws the named-change error (the
retired formatVersion envelope and flat package shape), a newer major
throws the upgrade pointer, and a layout-document URI throws the
pdf-codec tombstone (#65's schema side). A bare DocumentPackageSchema
parse structurally validates without version-discriminating; the
dispatch contract is documented in src/schema-io.ts. Content hashes
exclude $schema -- it is envelope metadata naming the dumper, not
content.

content-json-schema-defs.ts transcribes the recursive PackageNode set
(descriptors, anchors, the seven group wrappers, sheet-image/vector
leaves, the definitions tables), with live z.toJSONSchema() comparison
coverage for every fragment that has a real z.object counterpart; the
generator splices the one $defs block into both published schema files
so each resolves its local pointers.
The layout item model (LayoutDocument, LayoutPage, LayoutItem and its
text/image/rect/line/ellipse/path/link variants, LayoutImageAsset,
LAYOUT_FORMAT_VERSION) moves to pdf-codec, where the only codec that
ever read or wrote it owns it outright -- the family pattern every
other format already follows. pdf-codec's own PR takes the file and
flips its imports; its issue closes there.

The LayoutCodec interface goes with it: it modelled the single format
that produces layout cheaply on read (PDF), and a schema-package
interface for one private implementation was an accident of pdf-codec
predating the content pivot. ContentCodec stays, unchanged in shape.

schema-io, the generator, and the smoke tests already carry the
schema-file side of the demotion (two published .schema.json files,
the URI-pattern tombstone recognising old layout-document dumps);
this commit removes the model itself and retires the last stale
comment references to it in geometry, metadata, and style.

Dependents stay on document-schema.js 3.x via semver until their own
majors, so this is not a cascade-breaker.
README gains "The package tree" (the group/leaf vocabulary, the
container rules, and the three laws as the contract with
document-outline.js), "Definitions tables and styles" (the
tenant-generic facility with styles as first tenant, the schema-shape
ban list, and the overlay chain), and "Versioning by $schema" (the
release-pinned URI as the version, the documentFromJson dispatch
contract, and the hash-exclusion rule). The Usage, Codecs, and JSON
Schema sections are rewritten for the tree form, the two published
schema files, and ContentCodec alone; the layout family is documented
as pdf-codec-private.
…n bare package-tree leaves

The runtime guards now enforce what the published JSON Schema fragments
have always declared: every group wrapper fragment carries
additionalProperties: false over exactly { node, style, children }, so
isGroupWrapper rejects a wrapper with any fourth key instead of letting
an unknown key ride through unvalidated.

The leaf arms of the five child predicates reject a value carrying a
top-level style key before delegating to the content schemas. A style
ref is legal only on a group wrapper (resolution walks group
ancestors, never leaf payloads), and the shared content schemas
deliberately accept-and-ignore unknown keys -- tightening them to
strict would change flat ContentDocument parsing far beyond the
package tree -- so without this check a leaf-position ref parsed, sat
inert through resolution, and was still rejected by the published
.schema.json leaf fragments: a tree documentFromJson accepted that the
CDN-published schema forbids.
decompose emits a single ContentFormula and flatten requires exactly
one (document-outline.js's phase-1 reference throws on any other
count), so the schema states the cardinality the bijection needs
instead of admitting trees that cannot round-trip. The generated
document-package.schema.json picks up minItems/maxItems 1 alongside
the existing items $ref, keeping the published face in agreement.
Nothing consumes the const or its ContentDocumentKind type: package.ts
spells its own z.literal kind per discriminatedUnion arm, content.ts
spells its own, and no test or script references either name, so the
export was a third parallel copy of the five-kind list rather than the
single shared declaration its comment promised. Reintroduce the day a
real consumer exists.
@Mearman
Mearman marked this pull request as ready for review August 18, 2026 10:36
@Mearman
Mearman merged commit e0e5bb0 into main Aug 18, 2026
11 checks passed
@Mearman
Mearman deleted the feat/tree-package branch August 18, 2026 10:36
@github-actions

Copy link
Copy Markdown

🎉 This PR is included in version 4.0.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

1 participant