chore: type the workspace against @types/unist v3 and real mdast/hast - #217
Merged
Conversation
…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>
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.
Removes the
@types/unist: 2.0.3override 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'sNodecarried an index signature, so loose accesses likenode.valueandnode.depthtype-checked on a bare node.Design: generic over the tree, not a union
PageContent.astholds an mdast tree before Docfy's mdast→hast transform and a hast tree after it. Rather than typing it asMdastRoot | HastRoot,PageContentandContextare now generic over the tree, and each plugin hook declares the tree it actually receives:Inside a handler,
page.astis a realmdast.Rootorhast.Root, sounist-util-visitinfers precise node types with no narrowing. Both types default toMdastRoot | HastRoot, so unannotated code keeps compiling.DocfyResult.contentis nowPageContent<HastRoot>[], which is what it always actually was.What this deletes
Every hand-rolled
LinkNode,DefinitionNode,LinkReferenceNode,ImageNode,ImageReferenceNode,HeadingNode,NodeWithMeta, and the localResource/Association/Literalshims. They disagreed with mdast on nullability —title?: stringwhere mdast saysstring | null | undefined— which is precisely why they existed.CodeNodesurvives astype CodeNode = Codeto keep the public export.data.id/data.docfyDeleteare now registered once by augmenting mdast'sHeadingDatainstead of being re-declared per plugin.Both dead dependencies are inlined:
unist-builderbecame a three-linehtml()returning a real mdastHtmlnode (htmlis an mdast type, so the synthetic-untyped-node problem disappears), andunist-util-findbecame a typedfindHeading()overunist-util-visit+EXIT, removing the.depth/.datamutation casts at four call sites.Three casts remain, each commented
marker.type = 'div'— retyping aParagraphand putting block content in it. Isolated into one helper per package so the cast appears once instead of twice.parent.childrenwherevisitwidensparentto the union of all mdast parents, makingchildren[]element types incompatible.component.description.ast as unknown as RootContent— nesting a wholeRootinside another tree's children, whichmdast-util-to-hast'sroothandler 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 compileclean across all 6 packages.@docfy/ember-vite60/60,@docfy/plugin-with-prose2/2,@docfy/coreat its documented baseline. Both test apps build;test-app-viteemits real Docfy routes and 31hljs-*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
.gjstemplates 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 findctx.pages[i].astwidened to the union and should drop the annotation to get the precise type. This came up twice inside@docfy/ember-viteand is fixed there. Worth a line in the release notes.Depends on nothing; stacks cleanly with #214, #215 and #216.
🤖 Generated with Claude Code