Skip to content

perf: precompile Markdoc into React components at build time#9

Open
dogmar wants to merge 29 commits intomainfrom
claude/precompile-markdown-react-JbpgR
Open

perf: precompile Markdoc into React components at build time#9
dogmar wants to merge 29 commits intomainfrom
claude/precompile-markdown-react-JbpgR

Conversation

@dogmar
Copy link
Copy Markdown
Owner

@dogmar dogmar commented Apr 15, 2026

Summary

  • Precompile Markdoc markdown into .tsx React component files at build time, eliminating the @markdoc/markdoc runtime from the client bundle
  • Replace import.meta.glob dynamic imports with a statically-generated registry (index.gen.ts) for better caching and tree-shaking
  • Remove createServerFn calls from doc routes in favor of plain loaders since all content is now statically imported
  • Check generated doc files into the repo and extend the sync-generated CI workflow to auto-commit them on PRs

Details

Build pipeline (plugins/generate-docs.ts): The Vite plugin now converts Markdoc AST into JSX source code via a recursive renderToJsx() function, emitting .doc.gen.tsx files and an index.gen.ts barrel. Each generated file does import * as Tags from "#/components/markdoc-tags" — tree-shaking eliminates unused components.

Components: Extracted Heading and Paragraph from MarkdocRenderer.tsx into standalone files. Added markdoc-tags.ts barrel that re-exports all Markdoc rendering components. Deleted MarkdocRenderer.tsx.

Routes: docs/$slug.tsx and docs.tsx now use plain loaders with static imports from the generated registry instead of createServerFn + import.meta.glob.

CI: sync-generated.yml runs vp run website#build on PRs that touch markdown source or generation config, auto-committing any diff in website/src/docs-data/.

Test plan

  • pnpm --filter website build succeeds
  • All 4 doc pages render correctly via SSR (verified with curl)
  • Navigation sidebar works
  • 404 page works for invalid slugs
  • HMR works: editing .md files regenerates .tsx and Vite picks up the change
  • Verify @markdoc/markdoc is not in the client JS bundle

https://claude.ai/code/session_01JMFZZFw8V1WhJ1Uv8qLs9i

claude added 7 commits April 15, 2026 20:39
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
@netlify
Copy link
Copy Markdown

netlify Bot commented Apr 15, 2026

Deploy Preview for colander-cal ready!

Name Link
🔨 Latest commit caab087
🔍 Latest deploy log https://app.netlify.com/projects/colander-cal/deploys/69e2138d88489c0008e04c0c
😎 Deploy Preview https://deploy-preview-9--colander-cal.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@dogmar dogmar changed the title Precompile Markdoc into React components at build time perf: precompile Markdoc into React components at build time Apr 15, 2026
Comment thread website/src/routes/docs/$slug.tsx Outdated
if (!doc) throw notFound();
return doc;
});
import { docs } from "../../docs-data/index.gen";
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

claude and others added 20 commits April 16, 2026 20:15
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants