Skip to content

feat: carry constructs in the flat form as additive construct-boundary markers - #26

Merged
Mearman merged 7 commits into
mainfrom
feat/construct-boundary-marker
Aug 18, 2026
Merged

feat: carry constructs in the flat form as additive construct-boundary markers#26
Mearman merged 7 commits into
mainfrom
feat/construct-boundary-marker

Conversation

@Mearman

@Mearman Mearman commented Aug 18, 2026

Copy link
Copy Markdown
Member

4.1.0 added the six construct descriptor kinds but wired them onto the package tree only, where a construct is a { node: <descriptor>, children } group. The flat ContentDocument — the shape every codec reads and writes — had no way to carry construct data at all, so the descriptor vocabulary was unreachable from the only encoding a codec produces. Two codec lanes (markdown-codec footnotes, ooxml.js docx fields/bookmarks) stopped before writing any code because of this. This closes that gap, additively: nothing existing changes shape, and a 4.1.0 document still parses identically.

The markers

ContentBlock gains two kinds:

{ kind: 'constructStart', descriptor: ConstructDescriptor }
{ kind: 'constructEnd' }

A matched pair brackets the blocks the construct spans, and documents.js's decompose promotes each pair into the construct group the tree already has.

Pairing is ordinary bracket matching within one block list — a close ends the nearest still-open start — with no id, name, or other pairing key on either half. An id would have to be minted by whichever producer emitted the pair and then reproduced byte-for-byte by flatten to satisfy flatten(decompose(x)) === x, and a construct group carries a descriptor and its children and nothing else, so the id would have no home on the tree side and no deterministic way back. A bare bracket has nothing to reproduce and nothing to get wrong, and bracket matching already generalises to arbitrary nesting depth and to mixed construct kinds nested inside each other.

Neither half carries frames, sourcePath, or style: a boundary renders nothing and has no position, and refs are a tree-only, table-compression concept that never resolves onto a construct anyway (a construct group is a wrapper with no anchor of its own — it only extends the chain passed to its children, which already carry their own resolved properties).

Matching never straddles a block list. A pair opened in a section's blocks closes in that array; a pair opened inside a table cell closes inside that cell — which is also the only way a construct inside a table is expressible in either encoding, since decomposition treats a table as one leaf.

Bracket balance is necessary but not sufficient: a pair's extent must also not cross a heading-group or list-group scope boundary (a paragraph inside the extent that would close a heading or list scope open before the pair started). Balance alone doesn't determine a tree there — two different, equally legal-looking decompose implementations could nest the construct group inside the closing scope or hoist it out past the scope's own close, and neither is more correct than the other. This package documents the rule (src/content.ts, README.md) but cannot check it — unlike balance, detecting a crossing means walking the same heading/list nesting decompose already builds, so decompose is the sole enforcement point and must reject a crossing extent exactly as it already rejects an unbalanced one.

Two additions beyond the prescribed design

Both follow from the design rather than departing from it, but neither was in the brief, so flagging them explicitly.

findConstructMarkerImbalance(blocks) (src/content.ts) returns { kind: 'unmatchedEnd' | 'unclosedStart', index } for the first fault or undefined when a list balances. An unbalanced list is malformed input that decompose must throw on rather than silently repair — but decompose lives in documents.js, and every codec emitting pairs needs the identical rule. Balance is a property of a list's sequence, not of any block in it, so no schema can express it; without one shared definition, three-plus consumers each write their own matcher and they drift. Same reasoning that puts the style-resolution helpers in src/definitions.ts. Deliberately non-recursive: each block list is its own bracket scope, so a caller walking nested lists calls it once per list.

PackageBlockLeaf (src/package-node.ts) is ContentBlock minus the two marker kinds, and every block-flow child position of the tree now takes it. This one is a correctness fix, not a nicety: adding members to ContentBlock automatically added them to SectionChild/ShapeChild/ListChild, which would have made a marker a legal tree leaf. That puts one fact in two encodings inside a single tree and breaks the encoding pair's first law outright — decompose promotes a matched pair into a group, so flatten(decompose(x)) could never reproduce a tree that already held a marker leaf. Enforced in the runtime guards as well as the types, since those guards are the untrusted-input boundary. Table cells are unaffected: a cell's blocks are flat in both encodings.

This narrowing is a second, distinct type-level break beyond the exhaustive-switch one the new ContentBlock members already cause on their own: SectionChild/ShapeChild/ListChild/PackageLeaf now resolve their block-leaf member to PackageBlockLeaf rather than ContentBlock, so code that assigns an already-typed ContentBlock/ContentBlock[] value into one of those positions — e.g. const children: SectionChild[] = blocks where blocks: ContentBlock[] — stops compiling on upgrade, whether or not it switches exhaustively over anything. Flagging this explicitly since the additive framing above (data shape is genuinely unchanged) could otherwise read as "nothing existing breaks", which isn't true at the type level.

The published JSON Schema files follow the same split — the tree's wrappers point at a new PackageBlockLeaf fragment, ContentBlock carries the markers, and a table cell's blocks keep pointing at ContentBlock. Otherwise the CDN-published schema would advertise marker leaves as legal to every non-TypeScript consumer while documentFromJson rejected them.

Out of scope

The residue/channel-2 facility stays on #22 — raw-XML fidelity loss is a different problem from construct semantics. Constructs remain block-scoped: no run-level extent, consistent with 4.1.0.

Tests

Schema-level round trips over nested constructs, ContentDocumentSchema accepting a document carrying them, markers inside a table cell, every balance case (nested, sibling, unmatched close, unclosed open, both at once), a pinned assertion that ContentDocumentSchema.parse still accepts a section with an unmatched marker (balance is findConstructMarkerImbalance's job, not the schema's, for the JSON Schema parity reason above), the tree's rejection of markers at every block-flow leaf position, the two new fragments registered in the JSON-schema drift test against a live z.toJSONSchema(), and a workerd case for the flat form plus the balance helper. Smoke assertions over the two published files now list union members rather than counting them.

Typecheck, lint, test, test:workers, test:smoke, and build all green. Context: #24.

Generated by Claude Code

ContentBlock gains two kinds, constructStart (carrying a ConstructDescriptor)
and constructEnd (carrying nothing but its kind), so a construct region is
expressible in the shape every codec actually reads and writes.
4.1.0 wired the descriptor vocabulary onto the package tree alone, where a
construct is a `{ node, children }` group -- but a flat block list has no
wrapper to hang an extent off, so no codec could emit construct data at all.

Markers pair by ordinary bracket matching within one block list: a close ends
the nearest still-open start.
There is deliberately no id on either half.
An id would have to be minted by the producer and then reproduced byte-for-byte
by flatten to satisfy flatten(decompose(x)) === x, and a construct group carries
a descriptor and its children and nothing else, so the id would have no home on
the tree side; a bare bracket has nothing to reproduce.
Neither half carries frames, sourcePath, or a style ref, because a boundary
renders nothing and has no position, and refs are a tree-only concept that never
resolves onto a construct anyway.

findConstructMarkerImbalance is the one shared definition of the balance check,
returning the first unmatchedEnd or unclosedStart index or undefined.
Balance is a property of a block list's sequence rather than of any block in it,
so no schema can express it, and the codecs emitting pairs and the decompose that
promotes them into groups must all agree on exactly one answer.
PackageBlockLeaf is ContentBlock minus the two boundary marker kinds, and every
block-flow child position of the tree now takes it in place of ContentBlock.
A construct is a group in the tree and a marker pair in the flat form: two
encodings of one fact.
Admitting a marker as a tree leaf would put both inside one tree and break the
encoding pair's first law, since decompose promotes a matched pair into a
construct group and flatten(decompose(x)) could never reproduce a tree that
already carried the marker.

The exclusion is enforced by the runtime guards as well as the types, because
those guards are the untrusted-input boundary: a marker smuggled in by a producer
that mixed the two encodings has to fail where documentFromJson can report it,
rather than parse into a package no round trip can reproduce.

Table cells are unaffected.
A table is one leaf, decomposition never descends into its cells, so a cell's
block list is flat in both encodings and a construct inside one is a marker pair
there either way.
…SON Schema fragments

The two marker fragments join the hand-authored ContentBlock union, and the tree's
own block-flow wrappers point at a new PackageBlockLeaf fragment holding the five
content kinds without them.
Reusing ContentBlock at those positions would advertise marker leaves as legal to
every non-TypeScript consumer while documentFromJson rejected them, which is the
same guard-versus-published-face misalignment the leaf style-ref check exists to
prevent.
A table cell's blocks keep pointing at ContentBlock, since a cell's list is flat in
both encodings.

Both marker schemas are plain objects reaching no opaque node, so both are
registered in the drift test and held to a live z.toJSONSchema() comparison rather
than needing hand re-verification.
The smoke assertions over the two published files now list the union members
instead of counting them, so a member added or dropped names itself in the diff.
…tching contract

The fidelity-constructs section covered the tree's construct groups only, leaving
the encoding a codec actually emits into undocumented.
Adds the marker pair, the bracket-matching rule and its per-block-list scope, why
neither half carries an id, style, sourcePath, or frames, the balance check every
consumer shares, and the tree's refusal of markers at leaf positions.
…ist scope boundary

Bracket balance alone does not determine a tree: a constructStart/constructEnd
pair whose extent contains a paragraph that closes a heading or list scope open
before the pair started leaves decompose no single correct place to put the
construct group, since nesting it inside the closing scope strands that
paragraph and hoisting it out silently moves later blocks out of a scope they
belonged to.

States the rule the bracket-matching contract was missing, defines what a
scope boundary is in the flat encoding (a heading paragraph shallow enough to
close an open heading scope, or a paragraph that exits an open list scope),
and names decompose as the sole enforcement point, since only it walks the
heading/list nesting needed to detect a crossing.
…marker addition also causes

Adding constructStart/constructEnd to ContentBlock breaks a consumer's
exhaustive switch over the union, but PackageBlockLeaf's own exclusion of the
two marker kinds from SectionChild, ShapeChild, ListChild, and PackageLeaf is
a second, separate type-level break: those types now resolve their block-leaf
member to PackageBlockLeaf rather than ContentBlock, so code that assigns an
already-typed ContentBlock/ContentBlock[] value into one of those positions
(e.g. `const children: SectionChild[] = blocks`) stops compiling on upgrade,
whether or not it switches exhaustively over anything.

The comments beside both types previously described only the exhaustive-switch
case, which understated the change's real surface for anyone upgrading.
Balance is checked by findConstructMarkerImbalance and by decompose, never by
the schema: a Zod refinement would validate against a rule the published
content-document.schema.json fragment cannot express (JSON Schema has no way
to state that array members pair up), so adding one would silently diverge
from that published face.

Adds the assertion that was missing for this deliberate gap, so a future
change reintroducing balance as a schema-level refinement fails a test rather
than sliding in unnoticed.
@Mearman
Mearman merged commit dae959d into main Aug 18, 2026
11 checks passed
@Mearman
Mearman deleted the feat/construct-boundary-marker branch August 18, 2026 21:13
@github-actions

Copy link
Copy Markdown

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