Skip to content

chore: type the workspace against @types/unist v3 and real mdast/hast - #217

Merged
josemarluedke merged 1 commit into
mainfrom
chore/unist-v3-types
Aug 21, 2026
Merged

chore: type the workspace against @types/unist v3 and real mdast/hast#217
josemarluedke merged 1 commit into
mainfrom
chore/unist-v3-types

Conversation

@josemarluedke

Copy link
Copy Markdown
Owner

Removes the @types/unist: 2.0.3 override that #213 deliberately left in place. This was the one wart that PR documented rather than fixed: Docfy runs on unified 11 / mdast v4 / hast v3 at runtime while the entire workspace type-checked against unist v2. It only worked because v2's Node carried an index signature, so loose accesses like node.value and node.depth type-checked on a bare node.

Design: generic over the tree, not a union

PageContent.ast holds an mdast tree before Docfy's mdast→hast transform and a hast tree after it. Rather than typing it as MdastRoot | HastRoot, PageContent and Context are now generic over the tree, and each plugin hook declares the tree it actually receives:

runBefore?:    PluginHandler<T, MdastRoot>;
runWithMdast?: PluginHandler<T, MdastRoot>;
runWithHast?:  PluginHandler<T, HastRoot>;
runAfter?:     PluginHandler<T, HastRoot>;

Inside a handler, page.ast is a real mdast.Root or hast.Root, so unist-util-visit infers precise node types with no narrowing. Both types default to MdastRoot | HastRoot, so unannotated code keeps compiling. DocfyResult.content is now PageContent<HastRoot>[], which is what it always actually was.

What this deletes

Every hand-rolled LinkNode, DefinitionNode, LinkReferenceNode, ImageNode, ImageReferenceNode, HeadingNode, NodeWithMeta, and the local Resource/Association/Literal shims. They disagreed with mdast on nullability — title?: string where mdast says string | null | undefined — which is precisely why they existed. CodeNode survives as type CodeNode = Code to keep the public export. data.id / data.docfyDelete are now registered once by augmenting mdast's HeadingData instead of being re-declared per plugin.

Both dead dependencies are inlined: unist-builder became a three-line html() returning a real mdast Html node (html is an mdast type, so the synthetic-untyped-node problem disappears), and unist-util-find became a typed findHeading() over unist-util-visit + EXIT, removing the .depth/.data mutation casts at four call sites.

Three casts remain, each commented

  1. marker.type = 'div' — retyping a Paragraph and putting block content in it. Isolated into one helper per package so the cast appears once instead of twice.
  2. Splicing into parent.children where visit widens parent to the union of all mdast parents, making children[] element types incompatible.
  3. component.description.ast as unknown as RootContent — nesting a whole Root inside another tree's children, which mdast-util-to-hast's root handler renders inline.

These are places the code intentionally plays loose with node types, which is legitimate remark practice; they are not expressible in strict mdast.

Verification

pnpm -r run compile clean across all 6 packages. @docfy/ember-vite 60/60, @docfy/plugin-with-prose 2/2, @docfy/core at its documented baseline. Both test apps build; test-app-vite emits real Docfy routes and 31 hljs-* classes.

The important check is no rendered output change. The built bundle is byte-identical between baseline and this change (same sha1, and Vite's content-hashed filename is unchanged). I verified this a second, independent way: built in this worktree and diffed the generated .gjs templates against main's committed ones — every changed line pairs up exactly once the worktree directory name is normalised away, i.e. 10 insertions and 10 deletions that are all the same substitution. A green build alone would not have told us this.

Breaking change note

Anyone who explicitly annotated a hook as runWithMdast(ctx: Context) will find ctx.pages[i].ast widened to the union and should drop the annotation to get the precise type. This came up twice inside @docfy/ember-vite and is fixed there. Worth a line in the release notes.

Depends on nothing; stacks cleanly with #214, #215 and #216.

🤖 Generated with Claude Code

…hast

Docfy has run on unified 11 / mdast v4 / hast v3 since the ESM migration, but
the workspace still type-checked against unist v2 via a pin in
pnpm-workspace.yaml. That only worked because v2's `Node` carried an index
signature, so loose accesses like `node.depth` or `node.value` type-checked on
a bare node. Drop the pin and type the plugins honestly.

`PageContent` and `Context` are now generic over the tree they hold, and each
`Plugin` hook is declared with the tree it actually receives: `runBefore` and
`runWithMdast` get `mdast.Root`, `runWithHast` and `runAfter` get `hast.Root`.
Plugin handlers therefore see real node types from `unist-util-visit` with no
narrowing, and `PageContent`/`Context` still default to the union of both so
existing annotations keep working.

This lets the hand-rolled `LinkNode` / `DefinitionNode` / `ImageNode` /
`HeadingNode` / `CodeNode` interfaces go away — they disagreed with mdast about
nullability anyway. `data.id` and `data.docfyDelete` are registered by
augmenting mdast's `HeadingData` instead of being re-declared per plugin.

`unist-builder` and `unist-util-find` are both unmaintained and were only used
for `u('html', value)` and "find the first heading". Both are now local: an
`html()` helper returning a real mdast `Html` node, and a `findHeading()` built
on `unist-util-visit` that returns a typed `Heading` instead of a bare `Node`.

Three spots still need a cast, each commented in place: retyping a demo marker
paragraph to `div`, splicing raw `html` nodes into a parent whose `children`
type `visit` widens to a union, and nesting a demo's `Root` inside another
tree's children. All three are deliberate deviations from mdast that
mdast-util-to-hast handles at runtime.

Runtime output is unchanged: test-app-vite's bundle is byte-identical before
and after (sha1 3da66c3f), and the generated .gjs templates are untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@josemarluedke
josemarluedke merged commit 267934d into main Aug 21, 2026
9 checks passed
@josemarluedke
josemarluedke deleted the chore/unist-v3-types branch August 21, 2026 21:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant