Skip to content

feat: read and write GitHub footnotes, with definitions as anchor constructs - #69

Merged
Mearman merged 11 commits into
mainfrom
feat/footnote-constructs
Aug 18, 2026
Merged

feat: read and write GitHub footnotes, with definitions as anchor constructs#69
Mearman merged 11 commits into
mainfrom
feat/footnote-constructs

Conversation

@Mearman

@Mearman Mearman commented Aug 18, 2026

Copy link
Copy Markdown
Member

Implements the footnote row of #66, now that document-schema.js 4.2.0 has put the construct descriptor vocabulary within reach of a flat ContentDocument. Reader and writer both, with round-trip tests.

Before merging: close #68 first. It is the sibling-automation bump to document-schema.js 4.2.0, which this branch subsumes (first commit here does the same bump). Merging this while that one is open has bitten before — the heal job auto-merges the now-stale bump PR on the merge push and downgrades the dependency back.

Why the earlier attempt stopped, and what changed

The last run at this reported (in a comment on #66) that every 4.1.0 addition — the six construct descriptors and the definitions/layers/attachments/destinations tables — was wired exclusively into DocumentPackage/PackageNode, with ContentDocument gaining nothing, so a codec that produces a flat document had no path to any of it. That was correct and it was the right call to stop.

4.2.0 closes exactly that gap for the block-scoped half: ContentBlock gained a matched constructStart/constructEnd pair, the flat form's encoding of the construct group the package tree already had. That is enough for a footnote definition. It is not enough for a footnote reference, and the rest of this description is mostly about that split.

Reading AnchorDescriptor before designing the mapping

definition is z.string().optional(), and its comment is unambiguous about what the string is: "the definitions-table key holding this marker's body". So it is a key into a package-level definitions table, not the body itself — and DefinitionsTableSchema hangs off DocumentPackageSchema's root, not off ContentDocumentSchema, which has no root fields beyond kind/metadata/symbolTable/sections. There is no table for a flat document to key into, so definition stays absent.

That is not a workaround for the field being too small. A footnote body is genuinely block content — several paragraphs, a code block, a list — which a string could not have carried whichever table it pointed at. The construct's own extent is the right home, and AnchorDescriptorSchema's own note says so directly: "A point anchor — a footnote reference, a bookmark with no range — is a group with no children; a ranged anchor — a docx bookmark pair, a comment extent — wraps the blocks it spans."

So a definition lowers to:

constructStart { kind: 'anchor', anchorType: 'footnote', name: '1' }
  <the definition's own lowered body blocks>
constructEnd

and a bodyless [^1]: lowers to that same pair with nothing between it — the point anchor the note describes, not a special case. A consumer that later factors these documents into a package is free to move the body into a definitions entry and populate definition then; nothing here has to be undone for that.

The reference site is a real, still-open schema gap

A reference is not a second anchor instance, and this is the one part I want a second opinion on.

src/construct.ts states the extent scope once and binds every kind to it: "a construct group wraps BLOCK-scoped extents… It does not wrap a sub-sequence of one paragraph's runs, because a run-level extent is not expressible without changing ContentParagraph's own shape". And it names this exact case as deferred: "the inline field/bookmark/tracked-change cases wait on a run-level extent mechanism rather than being forced into a block wrapper that would split the paragraph they sit inside."

A footnote reference sits between two runs inside a paragraph. Bracketing it with block-level markers means splitting the paragraph in two, which loses the paragraph — so I did not do it. ContentRun has no field for it either: no styleId, no anchor, and hyperlink is documented as a resolved external URI (an internal target is what LinkTarget's internal arm exists for, and that is unreachable from a run).

What ships instead is the same degrade this package already uses twice: the reference becomes a ContentRun keeping its own [^1] spelling, marked with FOOTNOTE_REFERENCE_FONT_MARKER, and md/footnote-reference-preserved-as-text reports it. The marker is load-bearing rather than decorative — escapeMarkdownText escapes [, ^, and ], so a genuine reference and a literal \[^1\] an author escaped on purpose are the same run text by the time they reach the writer, and only a non-pattern-based marker separates them. (This is the identical trap that made the pattern-based approach to inline math get reverted; the constant's own comment records it.)

fontFamily carrying a semantic fact is not great and I would rather not be doing it. The value is Word's own character-style name for exactly this run (FootnoteReference) rather than an invented sentinel, so a consumer resolving it as a font finds none and falls back — visible, not wrong. The right fix is a run-level extent mechanism in document-schema.js, and this stays a marked run until there is one. That gap is unchanged by this PR and I have not papered over it: it has a named diagnostic, a README section, and a note on #66.

The marker contract constrains where definitions can live

ContentBlockSchema's comment states that balance is necessary but not sufficient: an extent must also not cross a heading- or list-group scope boundary, decompose is the sole enforcement point, and a producer "must never open one inside a heading or list scope that some other block inside the extent then closes." Two consequences here, both deliberate:

  • Definitions are recognised only at the document's top level. Inside a list item, the body would have to carry the item's ContentListMembership on every block, and a body table or image cannot carry one at all — closing the item's list scope from inside the pair. A block quote is the same shape one level along (its > prefix is recovered per-paragraph from indentLeftPt, and a label line has no paragraph to carry it). In either container the text stays an ordinary paragraph, exactly as before.
  • A heading inside a definition body is flattened to literal ATX text (md/footnote-body-heading-flattened). A heading inside the extent both closes whatever heading scope was open outside and leaves one standing at the closing marker. Deliberately unconditional rather than "only when a shallower heading is actually open outside": a level comparison would make one footnote's fidelity depend on which heading happens to precede it, so the same body would lower two different ways in two documents.

Round trips either way — # is escaped on the way out and unescaped identically on the way back.

Writer

emitBlocks now recovers a block list's marker pairs as a tree before rendering. Balance is validated through document-schema.js's own findConstructMarkerImbalance (the shared definition every codec and decompose must agree on) and an unbalanced list throws MarkdownUnbalancedConstructMarkersError rather than being repaired — with the pairing broken there is no extent to render and no correct guess about which blocks were meant.

A construct kind markdown has no syntax for renders transparently: its extent still appears in place, only the construct's identity is lost. Dropping the extent with the wrapper would lose real content arriving from another codec (an odt division, a docx content control, a tracked-change wrapper).

renderTopLevelBlock takes Exclude<ContentBlock, ContentConstructStart | ContentConstructEnd> rather than carrying two unreachable switch arms, so the compiler is what guarantees a marker never reaches content rendering.

Explicitly out of scope

Both of these are named so the gap stays visible rather than getting quietly counted as done:

Reference-link definitions are also still unmapped, for the same definitions-table-is-package-only reason that governs AnchorDescriptor.definition above.

Verification

pnpm lint, pnpm typecheck, pnpm test (982), pnpm test:workers (3, incl. a new footnote round trip under workerd), pnpm test:smoke (18), pnpm build — all green. Conformance is unchanged: neither vendored corpus contains a [^ anywhere, and the CommonMark suite switches footnotes off with the four GFM toggles, so the exclusion list neither grew nor shrank.

Related to #66 and #63. Not closing either — footnotes is one row of #66's checklist.

🤖 Generated with Claude Code

4.2.0 adds construct boundary markers -- a matched constructStart/constructEnd
pair -- to ContentBlock, which is the flat form's encoding of the construct
groups the package tree already carried. Until now the whole construct
descriptor vocabulary was reachable only from DocumentPackage, so a codec
producing a flat ContentDocument had no path to it at all.

The addition is schema-additive but TS-breaking in two ways this package
feels: an exhaustive switch over ContentBlock gains two arms, and
SectionChild/ShapeChild/ListChild narrow from ContentBlock to
PackageBlockLeaf.
`[^label]: body` now opens a container block whose body is ordinary block
content, continued by four columns of indentation exactly as a list item's
is, so a multi-paragraph note parses as several blocks rather than one run
of prose. `[^label]` in running text becomes a reference node, but only
when the document also carries a definition under that exact label -- an
unmatched label stays ordinary text, which keeps a bracketed aside from
becoming a note pointing at nothing.

The label grammar lives in src/inline/footnote.ts, shared by both phases
the way src/inline/link.ts already shares the link-label grammar: the
grammar is inline-shaped, but when a definition is recognised is a
block-structure question. Labels match exactly, with no case folding --
footnotes sit outside both CommonMark and the GFM spec document, so a
normalisation rule would be this package's own invention.

Definitions are recognised only as direct children of the document, and
never interrupt a paragraph. Both restrictions are about what the mapping
downstream can represent: a definition inside a list item or block quote
would put a construct's extent inside a scope the enclosing container had
already opened.

Recognition is behind `footnotes`, defaulting on alongside the four GFM
toggles; the CommonMark conformance suite switches it off with them.
…rence to a marked run

A definition now becomes a constructStart carrying
{ kind: 'anchor', anchorType: 'footnote', name }, its lowered body blocks,
and a constructEnd. The body rides the construct's own extent rather than
AnchorDescriptor.definition: that field names a key in a package-level
definitions table, and a flat ContentDocument -- the only shape any codec
in this family produces -- has no root to carry one. A body is genuinely
block content that a string field could not have held either way, and the
descriptor's own note says a ranged anchor wraps the blocks it spans. A
bodyless `[^1]:` lowers to a pair with nothing between it, the point
anchor that note describes.

A reference site cannot become an anchor: a construct's extent is
block-scoped by the schema's own definition, and a reference sits between
two runs inside a paragraph, which no block-level boundary marker can
bracket without splitting the paragraph in two. The schema parks that case
on a run-level extent mechanism it has not shipped, so the reference is
carried as a run keeping its own `[^label]` spelling plus
FOOTNOTE_REFERENCE_FONT_MARKER, and md/footnote-reference-preserved-as-text
reports the gap. The marker is load-bearing rather than cosmetic: text
escaping turns a deliberately-escaped literal `\[^1\]` into the same run
text as a genuine reference, and only a non-pattern-based marker separates
them.

A heading inside a definition body is flattened to literal ATX text
(md/footnote-body-heading-flattened): a marker pair's extent may neither
close a heading scope opened outside it nor leave one standing at the
closing marker, and the schema states a producer must never emit such a
pair.
The writer now recovers a block list's marker pairs as a tree before
rendering anything, since the flat form encodes a construct as a matched
pair bracketing the blocks it spans. An anchor construct's footnote arm
renders as `[^label]: body`, every body line after the first indented to
the four-column continuation the reader measures against. A run carrying
FOOTNOTE_REFERENCE_FONT_MARKER renders its `[^label]` text unescaped, so a
reference reparses as a reference while an ordinary run that merely looks
like one still escapes to literal text.

A construct kind markdown has no syntax for renders transparently rather
than being dropped: its extent still appears in place and only the
construct's own identity is lost, because a ContentDocument arriving from
another codec carries real content inside markers markdown cannot spell,
and dropping the wrapper's content with the wrapper would lose the
document.

Balance is checked up front through document-schema.js's own
findConstructMarkerImbalance -- the one shared definition of that contract
-- and an unbalanced list throws MarkdownUnbalancedConstructMarkersError
rather than being repaired: with the pairing broken there is no extent to
render and no correct guess about which blocks the producer meant.

The two marker kinds are excluded from renderTopLevelBlock's own input
type rather than handled as unreachable switch arms, so the compiler is
what guarantees a marker never reaches content rendering.
One file across all four stages rather than four scattered additions: the
point of the feature is that a footnote's two halves are carried by two
different mechanisms and still have to reproduce each other, which no
single-stage test can show.

The round-trip assertion is that read -> write -> read -> write reaches a
fixed point, not that write reproduces the source byte for byte. That is
not a weaker bar chosen for convenience: this package normalises freely on
the way out (escaping ASCII punctuation, regenerating code fences, picking
its own bullet glyph), so byte equality with arbitrary source text is not
a property writeMarkdown has for any construct.

Adds the four new diagnostic codes to the reachability sweep, and a
footnote round trip to the workerd suite, since the definition path now
runs through document-schema.js's marker helpers.
Records why a definition and a reference land on different mechanisms --
one is a block-scoped anchor construct, the other cannot be one until the
schema grows a run-level extent -- alongside the new diagnostic codes, the
`footnotes` option, and the write-side balance check.
…p-level list

continueBlock reports a `list` node as continued unconditionally regardless
of what the current line is, so a `[^label]:` line right after a top-level
list's last item sees that LIST as its matched container in step 1, not the
document -- even though the list is one line away from closing. The block
start's own guard rejected anything other than a literal `document`
container, so this exact case fell through to an ordinary paragraph.

That paragraph then went through extractDefinitions, which reads
`[label]: dest` as a link reference definition regardless of the leading
`^`, silently turning a footnote definition into a hyperlink target and, for
the common case of a single-line body, discarding the whole definition (a
paragraph holding nothing but a reference definition leaves no block
behind).

tryFootnoteDefinitionStart's guard now walks up through any `list`
ancestors before checking for the document, mirroring what addChild already
does once a definition is actually opened: it finalises whatever the tip
can't contain, closing a bare top-level list exactly like any other block
start does. A list nested inside a block quote or a list item still walks
up to that container rather than the document, so the existing restriction
against defining a footnote inside either continues to hold.
… as something else

AnchorDescriptorSchema.name is a bare z.string() with no grammar constraint
of its own, so it can carry whitespace or a "]" when it arrives from a
producer other than this package's own reader (ooxml.js, odf.js, or any
other codec sharing the ContentDocument pivot). renderFootnoteDefinition
spelled that name straight into a "[^name]:" marker with no check against
the [^label] grammar src/inline/footnote.ts already defines, so such a name
emitted markdown this package's own reader cannot parse back as a footnote:
whitespace reopens the "[^ see above]" ambiguity, and a "]" ends the marker
early. Depending on the body, reading the result back either drops the
whole document (the malformed marker line reparses as a link reference
definition and eats everything after it) or degrades the definition to a
literal paragraph, in both cases with no diagnostic.

isValidFootnoteLabel in src/inline/footnote.ts tests a candidate name
against the same grammar, and renderConstruct now falls back to the
transparent unrepresented-construct degrade -- render the extent, report
CONSTRUCT_UNREPRESENTED -- for a footnote anchor whose name fails it, the
same degrade already used for a construct kind markdown has no syntax for
at all.
…hor name

Adds the exact text -> list -> definition shape that previously fell
through tryFootnoteDefinitionStart's guard, plus its own round-trip source,
and a matching case that confirms a definition indented into a list item's
content is still correctly refused. Also covers a footnote anchor whose
name contains whitespace or a "]": both now degrade transparently instead
of emitting markdown that reparses into a different document entirely.
@Mearman
Mearman merged commit 5509b0f into main Aug 18, 2026
11 checks passed
@Mearman
Mearman deleted the feat/footnote-constructs branch August 18, 2026 22:13
@github-actions

Copy link
Copy Markdown
Contributor

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