feat: carry constructs in the flat form as additive construct-boundary markers - #26
Merged
Conversation
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.
|
🎉 This PR is included in version 4.2.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 join this conversation on GitHub.
Already have an account?
Sign in to comment
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.
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 flatContentDocument— 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
ContentBlockgains two kinds: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, orstyle: 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 orundefinedwhen 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 insrc/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) isContentBlockminus 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 toContentBlockautomatically added them toSectionChild/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, soflatten(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
ContentBlockmembers already cause on their own:SectionChild/ShapeChild/ListChild/PackageLeafnow resolve their block-leaf member toPackageBlockLeafrather thanContentBlock, so code that assigns an already-typedContentBlock/ContentBlock[]value into one of those positions — e.g.const children: SectionChild[] = blockswhereblocks: 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
PackageBlockLeaffragment,ContentBlockcarries the markers, and a table cell's blocks keep pointing atContentBlock. Otherwise the CDN-published schema would advertise marker leaves as legal to every non-TypeScript consumer whiledocumentFromJsonrejected 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,
ContentDocumentSchemaaccepting a document carrying them, markers inside a table cell, every balance case (nested, sibling, unmatched close, unclosed open, both at once), a pinned assertion thatContentDocumentSchema.parsestill accepts a section with an unmatched marker (balance isfindConstructMarkerImbalance'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 livez.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