Skip to content
This repository was archived by the owner on Aug 20, 2026. It is now read-only.

feat: build per-kind outlines over any ContentDocument - #1

Merged
Mearman merged 11 commits into
mainfrom
feat/outline-all-kinds
Aug 17, 2026
Merged

feat: build per-kind outlines over any ContentDocument#1
Mearman merged 11 commits into
mainfrom
feat/outline-all-kinds

Conversation

@Mearman

@Mearman Mearman commented Aug 17, 2026

Copy link
Copy Markdown
Member

Resolves the expanded scope of ExaDev/document-schema.js#14: a new sibling package, document-outline.js, depending only on document-schema.js (and zod), that builds a hierarchical outline over any ContentDocument — all five kinds — plus the kind-agnostic tree-walking helpers.

Closes ExaDev/document-schema.js#14

Per-kind hierarchy

  • wordprocessing — heading paragraphs open groups nested by headingLevel with stack semantics: a heading nests under the deepest open group with a strictly shallower level and pops equal-or-deeper groups closed, so an H4 after an H2 becomes its direct child (no synthetic intermediates) and an H1 after an H3 pops to the root. headingLevel is the only heading signal read — a Heading style without it does not group. Inside a group, paragraphs carrying list membership nest further by list.level on the same stack semantics (level 0 items are the group's children, level 1 under the last level 0, a 0→2 jump nests directly under the 0). Non-paragraph blocks attach as leaves at the current depth without changing it; a plain paragraph sits flat at its scope and closes the list nesting — the rule that keeps flattened leaf order identical to document order.
  • presentation — one group per slide labelled Slide N (1-based, matching the Markdown renderer's convention); the slide's paragraphs, across its shapes in shape order, nest under the group by list.level (PowerPoint-outline-view semantics). headingLevel is not read here: slides have no heading hierarchy of their own. Non-paragraph blocks attach at the current depth; a slide with no list levels is flat under its group.
  • spreadsheet — one group per sheet labelled with the sheet's name; children are the sheet's images then its embedded objects. Cells never appear (addressable data, not outline content).
  • drawing — one group per page labelled Page N; children are the page's shape blocks in shape order, then its vectors (textless leaves so structural diffing still sees them).
  • formula — a single node whose one leaf child is the ContentFormula itself, labelled with its LaTeX linearisation when present.

API and helpers

  • buildOutline(doc): OutlineChild[] — the root scope's children; the root is deliberately not a synthetic node, so pre-heading content appears as leaves directly in the returned array.
  • OutlineNode (text/level/children) with OutlineNodeSchema as a z.custom over a hand-written recursive guard — the family's established pattern, since z.lazy() collapses recursive schemas to unknown in the pinned zod 4. level carries the source signal verbatim (headingLevel 1-based, list.level 0-based, synthetic groups 1); render indentation from the nesting, not from level.
  • flattenOutline (leaves in document order), outlineLeafText (a leaf's own text: paragraph runs, table cell paragraphs, image altText, formula LaTeX), leafContentHash (key-order canonicalisation → JSON → UTF-8 → hand-rolled SHA-256 → lowercase hex; Worker-isomorphic, deterministic, pinned against the FIPS 180-4 vectors).

Notes for review

  • Fixture builders assert every per-kind document against ContentDocumentSchema.parse first, so the tested shapes are the real schema 3.3.0 shapes (this caught a 0–1 float colour channel during development).
  • The workers suite runs the whole surface inside a real workerd isolate, complementing the ESLint Worker-isomorphism guard on runtime src/.
  • Dependency automation matches the family: sibling-dependency-update.yml (the ExaDev/.github reusable-workflow stub), dependabot.yml, and dependabot-auto-merge.yml are ooxml.js's verbatim, so a schema release dispatching sibling-released opens and lands a bump PR here like in every other dependent repo.
  • pnpm-workspace.yaml carries the family's uniform minimumReleaseAge: 60 with the family-wide sibling exemption list: a same-day schema release ages past the window within the hour (a frozen-lockfile install inside it fails once, the retry passes), and third-party dependencies stay behind the freshness gate.

Generated by Claude Code

… guard

OutlineNode is a group (text/level/children) whose children mix nested
groups and leaf payloads drawn from every ContentDocument kind: block
flow, sheet images, embedded documents, vectors, and the formula.
The recursion lives in a plain function guard behind z.custom -- the
same pattern document-schema's ContentBlock uses -- because z.lazy()
collapses a recursive schema's static type to unknown in the pinned
zod 4. Leaf validation delegates to document-schema's own exported
schemas so the leaf shapes stay owned by the schema package.
buildOutline dispatches on doc.kind and returns the root scope's
children. Wordprocessing headings nest by headingLevel with stack
semantics (H4 after H2 nests directly, H1 after H3 pops to root,
headingLevel is the only heading signal read); list paragraphs nest
inside a heading scope by list.level on the same semantics, and a
plain paragraph sits flat at its scope, closing the list nesting so
flattened order stays document order. Presentations get one group
per slide (Slide N, matching the Markdown renderer) with paragraphs
nested by list.level and non-paragraph blocks attached at the current
depth. Spreadsheets get one group per sheet over images and embedded
objects, drawings one group per page over shape blocks and vectors,
and a formula document a single node whose leaf is the ContentFormula.
Test fixtures are asserted against ContentDocumentSchema so the shapes
tested are the real schema 3.3.0 shapes.
flattenOutline walks the tree depth-first and yields every leaf in
document order; outlineLeafText returns a leaf's own text (paragraph
runs, table cell paragraphs, image altText, formula LaTeX, empty for
the textless leaves); leafContentHash canonicalises key order,
JSON-serialises, UTF-8 encodes, and digests with a hand-rolled
SHA-256 -- Worker-isomorphic, since node:crypto is banned in runtime
src and SubtleCrypto is async -- pinned against the FIPS 180-4
vectors. The public barrel lands here, with the whole surface
present.
Runs buildOutline for all five kinds plus the helpers under
@cloudflare/vitest-pool-workers, turning the zero-Node-API design
into a runtime-checked fact: any node:* or Buffer use in the module
graph (including the zod and document-schema dependencies and the
hand-rolled SHA-256) would make the isolate throw instead of the
tests pass.
The README records what each kind's hierarchy looks like, the level
semantics (source signal carried verbatim, render from nesting), the
flat-and-close rule that keeps flattened order equal to document
order, and every step of the published hash recipe. The agent
instruction entry points symlink README.md, matching the family
convention.
@Mearman
Mearman force-pushed the feat/outline-all-kinds branch from 74f9309 to 6791a5a Compare August 17, 2026 21:53
…alias

The schema repo publishes five alias names alongside its primary
document-schema.js, and the family's sibling-released automation bumps
dependents by running pnpm add document-schema.js@$VERSION. Against the
alias this repo imported, that bump would install a second copy under
the canonical name and never update the imported module, so the
dependency, every import specifier, and the README now use
document-schema.js, matching ooxml.js, odf.js, and markdown-codec.
A schema release dispatches sibling-released to every dependent repo,
which needs a repository_dispatch listener here to receive it: without
this stub the dispatch finds no workflow, so no bump PR can ever open
and a new document-schema.js would strand this package on its pinned
version. The three files are ooxml.js's verbatim: the listener stub
delegating to ExaDev/.github's reusable workflow, the dependabot config
with the sibling cooldown exemption, and the auto-merge policy whose
sibling list already names document-schema.js. The dependabot-ignore
rule commitlint.config.ts already carries now backs a workflow that
exists here instead of pointing at nothing.
…mptions

minimumReleaseAge: 0 disabled the freshness gate for every dependency,
broader than the same-day-sibling need that motivated it, and deviated
from the uniform 60 every family repo carries. 60 minutes keeps the
third-party gate while a same-day schema release ages past the window
within the hour; the family-wide exemption list covers the sibling
packages on the pnpm add path the sibling-dependency-update workflow
bumps through.
…anonicaliser with a guard

The spreadsheet, drawing, and formula children spreads infer
(ContentSheetImage | ContentEmbeddedObject)[], (ContentBlock |
ContentVector)[], and ContentFormula[] -- each already assignable to
OutlineChild[], so the as OutlineChild[] annotations asserted nothing
the compiler did not know. canonicalise narrowed its object branch with
a cast instead; it now uses the same isRecord type guard node.ts and
document-schema.js's own content guards use for exactly this narrowing.
…el edges

The suite covered jumps deeper (H2 to H4) and pops to the root (H3 to
H1) but not a step back by one: H2 after H1/H3 must pop exactly the H3
and land beside it as H1's child, the case a refactor of the stack loop
is most likely to regress by popping too much or too little. The schema
leaves headingLevel unbounded, so a headingLevel 10 under an H2 must
nest as its direct child with the level carried verbatim -- the builder
must not clamp to the format conventions (ODF's ten levels, Word's
nine) that the schema deliberately does not enforce.
@Mearman
Mearman merged commit 065bd65 into main Aug 17, 2026
9 checks passed
@github-actions

Copy link
Copy Markdown

🎉 This PR is included in version 1.0.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

New sibling package: build a heading-based hierarchy over ContentDocument

1 participant