perf: precompile Markdoc into React components at build time#9
perf: precompile Markdoc into React components at build time#9
Conversation
Instead of shipping Markdoc ASTs as JSON to the client and rendering them at runtime with @markdoc/markdoc, the generate-docs Vite plugin now emits .tsx component files and a static registry (index.gen.ts). This eliminates the Markdoc runtime from the client bundle and replaces import.meta.glob dynamic imports with static imports. - Rewrite generate-docs plugin to emit .tsx React components from Markdoc AST via a recursive JSX code generator - Generate index.gen.ts registry with static imports of all doc components and frontmatter - Extract Heading and Paragraph into standalone component files - Remove MarkdocRenderer.tsx (no longer needed) - Replace createServerFn calls in doc routes with plain loaders - Move @markdoc/markdoc to devDependencies (build-time only) - Add explicit fenceNode transform for predictable CodeBlock AST output https://claude.ai/code/session_01JMFZZFw8V1WhJ1Uv8qLs9i
Generated by vite-plus during pnpm install, not source code. https://claude.ai/code/session_01JMFZZFw8V1WhJ1Uv8qLs9i
Replace per-component import tracking with a single `import * as Tags from "#/components/markdoc-tags"` in each generated doc file. Tree-shaking still eliminates unused components from the bundle. This removes collectComponents() and the componentImports map from the plugin. https://claude.ai/code/session_01JMFZZFw8V1WhJ1Uv8qLs9i
Vite's built-in HMR already detects writes to generated .tsx files in
src/docs-data/ and reloads the affected modules automatically. The
manual server.ws.send({ type: "full-reload" }) calls were redundant.
https://claude.ai/code/session_01JMFZZFw8V1WhJ1Uv8qLs9i
Generated .doc.gen.tsx and index.gen.ts are now committed to the repo, matching the pattern used for TanStack Router's routeTree.gen.ts. The sync-generated CI workflow regenerates them on PRs that touch markdown source, markdoc config, or the generation plugin, and auto-commits any diff. - Remove docs-data from .gitignore and check in generated files - Export generateAll from the plugin for standalone use - Add website/scripts/generate-docs.ts for CLI/CI invocation - Extend sync-generated.yml with doc regeneration step https://claude.ai/code/session_01JMFZZFw8V1WhJ1Uv8qLs9i
Remove the standalone generate-docs script — vite build already triggers the plugin via configResolved, so there's no need for separate tooling. https://claude.ai/code/session_01JMFZZFw8V1WhJ1Uv8qLs9i
✅ Deploy Preview for colander-cal ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
| if (!doc) throw notFound(); | ||
| return doc; | ||
| }); | ||
| import { docs } from "../../docs-data/index.gen"; |
There was a problem hiding this comment.
@claude instead of importing this registry of docs, each generated docs page should just be placed in the appropriate location in the route file tree and rendered via the Outlet component in this file.
There was a problem hiding this comment.
Done — each generated doc is now a proper route file in src/routes/docs/ (e.g. getting-started.tsx, calendar-provider.tsx) with createFileRoute and head metadata. TanStack Router picks them up automatically and renders them through the Outlet in docs.tsx.
The $slug.tsx dynamic route and the index.gen.ts registry are deleted. A lightweight nav.gen.ts manifest provides frontmatter for the sidebar.
Generated by Claude Code
Each markdown doc is now generated as a proper TanStack Router route file in src/routes/docs/ instead of a component in src/docs-data/. The parent docs.tsx renders them via Outlet, matching the standard file-based routing pattern. - Delete $slug.tsx dynamic route and index.gen.ts registry - Generate route files with createFileRoute, head metadata, and content - Generate lightweight nav.gen.ts manifest for sidebar navigation - Update commit-if-changed.sh to accept multiple paths - Update docs/index.tsx to link via string paths https://claude.ai/code/session_01JMFZZFw8V1WhJ1Uv8qLs9i
The default GITHUB_TOKEN cannot push to protected branches. Use a fine-grained PAT (CI_PAT secret) in the checkout step instead. https://claude.ai/code/session_01JMFZZFw8V1WhJ1Uv8qLs9i
GitHub App tokens are scoped, short-lived, and not tied to a personal account. The workflow generates a token at runtime from the APP_ID and APP_PRIVATE_KEY secrets. https://claude.ai/code/session_01JMFZZFw8V1WhJ1Uv8qLs9i
- Eliminate double-parsing: processRoute now returns frontmatter alongside source, reused for nav manifest generation - Move page header to parent docs layout: child routes expose frontmatter via loader, parent reads it with useMatches() and renders the section/title/description header - Fix renderToJsx array join inconsistency (use empty string, not \n) - Handle file deletion: unlink watcher removes orphaned route files - Add DocFrontmatter type to generated nav.gen.ts https://claude.ai/code/session_01JMFZZFw8V1WhJ1Uv8qLs9i
Drop `order` (manifest is pre-sorted) and DocFrontmatter type import. Only slug, title, description, and section remain. https://claude.ai/code/session_01JMFZZFw8V1WhJ1Uv8qLs9i
- Extract render-jsx.ts from generate-docs.ts (pure AST → JSX logic) - Move markdoc-specific components (Heading, Paragraph, CodeBlock, Callout, ExampleBlock, InstallCmd) into components/markdoc/ - Barrel re-exports from components/markdoc/index.ts (ApiReference stays in components/ since it's also used directly) https://claude.ai/code/session_01JMFZZFw8V1WhJ1Uv8qLs9i
ApiReference now accepts a full ApiSymbol object as props instead of looking up by name from the complete symbols list. This eliminates the runtime import of all 122 symbols (~200KB) from pages that use API references. The generate-docs plugin resolves needed symbols from symbols.gen.json at build time and embeds only the relevant data in each generated route file. Pages without API references get no symbol data at all. https://claude.ai/code/session_01JMFZZFw8V1WhJ1Uv8qLs9i
Load the full symbol list dynamically in the /docs route loader instead of statically importing it. Vite emits symbols.gen.json as a separate chunk; TanStack Router caches the loader result across subroute navigation; browser HTTP cache holds the chunk across page loads. - Strip api-data.ts to types only (no JSON import or helpers) - Create useDocsSymbols hook reading from /docs match context - ApiReference and TypeLink read symbols from router context - Remove per-route symbol embedding from the generator - Remove createServerFn from docs/api/$symbol route https://claude.ai/code/session_01JMFZZFw8V1WhJ1Uv8qLs9i
Normalize the generated API data into a flat types dictionary. Each
type string appears once in the `types` array; symbol properties and
parameters reference them by numeric id. Consumers resolve ids to
strings at render time via the shared types list.
Savings: ~20% reduction in symbols.gen.json size (211KB → 169KB
unminified, 24KB → 23KB gzipped).
- extract-api.ts: intern type strings and emit { types, symbols }
- lib/api-data.ts: SymbolProperty.type is now a number (id)
- Rename useDocsSymbols → useApiData, returns { symbols, types }
- ApiReference, TypeLink: look up type strings via shared types list
https://claude.ai/code/session_01JMFZZFw8V1WhJ1Uv8qLs9i
react-start@1.167.16 transitively pinned router-core@1.168.9, whose ssr-server.js reads router.stores.activeMatchesSnapshot.state. The catalog already pulls @tanstack/react-router@^1.168.22, which uses router-core@1.168.15 — that version renamed the stores, so SSR dehydrate threw "Cannot read properties of undefined (reading 'state')" and every preview deploy returned 500. Bump react-start to ^1.167.41 so its transitive router-core matches. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The docs and formats generators produced output that diverged from `vp fmt`: generate-docs wrote raw JSON.stringify with no formatter pass, and generate-formats ran `npx oxfmt` (node_modules oxfmt 0.43) while `vp fmt` bundles 0.45 — different line-wrap behavior. Every `vp run dev` dirtied the tree and `vp run ready` couldn't reconcile it. Pipe the docs plugin output through `vp fmt --stdin-filepath`, and switch generate-formats to `vp check --fix` so both use the bundled toolchain. Regenerate the committed outputs in their new idempotent form. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
These were committed before the current vp fmt wrapping rules and drifted under `vp run ready`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
.tsxReact component files at build time, eliminating the@markdoc/markdocruntime from the client bundleimport.meta.globdynamic imports with a statically-generated registry (index.gen.ts) for better caching and tree-shakingcreateServerFncalls from doc routes in favor of plain loaders since all content is now statically importedsync-generatedCI workflow to auto-commit them on PRsDetails
Build pipeline (
plugins/generate-docs.ts): The Vite plugin now converts Markdoc AST into JSX source code via a recursiverenderToJsx()function, emitting.doc.gen.tsxfiles and anindex.gen.tsbarrel. Each generated file doesimport * as Tags from "#/components/markdoc-tags"— tree-shaking eliminates unused components.Components: Extracted
HeadingandParagraphfromMarkdocRenderer.tsxinto standalone files. Addedmarkdoc-tags.tsbarrel that re-exports all Markdoc rendering components. DeletedMarkdocRenderer.tsx.Routes:
docs/$slug.tsxanddocs.tsxnow use plain loaders with static imports from the generated registry instead ofcreateServerFn+import.meta.glob.CI:
sync-generated.ymlrunsvp run website#buildon PRs that touch markdown source or generation config, auto-committing any diff inwebsite/src/docs-data/.Test plan
pnpm --filter website buildsucceeds.mdfiles regenerates.tsxand Vite picks up the change@markdoc/markdocis not in the client JS bundlehttps://claude.ai/code/session_01JMFZZFw8V1WhJ1Uv8qLs9i