docs/plans/2026-05-22-refactor-docs-to-fumadocs-plan.md
Move Plate's public docs source pipeline from Contentlayer to Fumadocs MDX, using ../ui/apps/v4 as the reference implementation while preserving Plate-specific behavior: /docs/* URLs, /cn/docs/* fallback semantics, registry-backed component/example pages, local docs registry generation, LLM copy/raw markdown features, and existing MDX components.
This should be a hard source-pipeline replacement, not a half-state where Contentlayer and Fumadocs both own public docs rendering.
Plate's docs are organized as raw MDX under root content/, but the app still depends on contentlayer2 for document discovery, compiled MDX code, slug generation, raw body access, and type generation.
That makes Plate diverge from shadcn's current docs architecture:
apps/v4/source.config.ts with defineDocs({ dir: "content/docs" }).apps/v4/lib/source.ts using loader({ source: docs.toFumadocsSource(), baseUrl: "/docs" }).pageTree and meta.json.Plate instead hand-rolls these pieces:
apps/www/contentlayer.config.js reads ../../content/**/*.mdx, strips grouping folders like (plugins), and emits allDocs.apps/www/src/app/(app)/docs/[[...slug]]/page.tsx queries allDocs and falls back to registry items.apps/www/src/app/cn/docs/[[...slug]]/page.tsx duplicates the same logic with .cn.mdx fallback rules.apps/www/src/config/docs.ts, docs-plugins.ts, and docs-api.ts hard-code most sidebar and pager structure.apps/www/scripts/build-docs-registry.mts separately walks content/ and manually mimics Contentlayer's path normalization for registry docs.The result works, but it is brittle. The source parser, route tree, sidebar order, registry docs export, Chinese fallback, and LLM/raw markdown features are all separate truths.
Introduce Fumadocs MDX as the canonical docs source and route tree for Plate, then adapt Plate's registry-specific pages around that source.
Target architecture:
apps/www/source.config.ts exporting a Plate docs collection via defineDocs.apps/www/.source through fumadocs-mdx.apps/www/src/lib/source.ts exporting a Fumadocs source.source.getPage(), source.generateParams(), source.getPages(), and page.data.getText("raw").meta.json files so page tree order is data-driven instead of only TS-config-driven.Preferred end state:
apps/www/
source.config.ts
.source/
src/lib/source.ts
content/
meta.json
index.mdx
installation/
meta.json
plate-ui.mdx
next.mdx
api/
meta.json
core.mdx
plugins/
meta.json
...
Plate does not have to move content under apps/www/content/docs to use Fumadocs. The plan should first prove whether defineDocs({ dir: "../../content" }) works correctly from apps/www/source.config.ts. If Fumadocs rejects or makes that awkward, move public content to apps/www/content/docs in the same PR and update registry docs export paths explicitly.
Do not keep group-directory routing magic hidden in parser code. Either:
content/(plugins)/(elements)/table.mdx by physically reorganizing docs into Fumadocs folders, orMy recommendation: physically reorganize public docs into Fumadocs folders. It is more churn up front, but it kills the most fragile part of the current pipeline.
Add a Plate equivalent of shadcn's setup:
// apps/www/source.config.ts
import { defineConfig, defineDocs } from 'fumadocs-mdx/config';
export const docs = defineDocs({
dir: '../../content',
});
export default defineConfig({
mdxOptions: {
rehypePlugins: (plugins) => {
// Port existing rehypeSlug, rehypeComponent, pretty-code, npm-command behavior.
return plugins;
},
},
});
// apps/www/src/lib/source.ts
import { docs } from '@/.source';
import { loader } from 'fumadocs-core/source';
export const source = loader({
baseUrl: '/docs',
source: docs.toFumadocsSource(),
});
Use Fumadocs' generated .source folder, not a custom generated map file. Add the @/.source alias to apps/www/tsconfig.json.
Wrap apps/www/next.config.ts with createMDX from fumadocs-mdx/next, preserving the existing Next config:
import { createMDX } from 'fumadocs-mdx/next';
const withMDX = createMDX({});
export default withMDX(nextConfig);
Keep Plate's existing Turbopack workspace aliases, tracing includes, redirects, and registry route config. Do not copy shadcn's config wholesale.
Replace allDocs reads in:
apps/www/src/app/(app)/docs/[[...slug]]/page.tsxapps/www/src/app/cn/docs/[[...slug]]/page.tsxEnglish route:
generateStaticParams() should combine source.generateParams() plus existing registry component/example params.getDocFromParams() should use source.getPage(params.slug).const MDX = page.data.body with the existing Plate mdxComponents ported to Fumadocs' component model.await page.data.getText("raw").Chinese route:
/cn/docs/*..cn.mdx equivalent exists.If Fumadocs does not naturally model .cn.mdx variants in one collection, create two collections in source.config.ts: docs and docsCn. Do not encode CN fallback by string searching generated private fields.
Plate has enough custom nav metadata that a one-shot swap to source.pageTree is risky.
Phase it:
docsConfig and getPagerForDoc() for the first rendering cutover.meta.json files from the existing nav config and commit them once.docsConfig URLs against source.getPages() during the transition.docsConfig as sidebar source and use Fumadocs source.pageTree.Do not leave docsConfig and meta.json as permanent competing truths.
This is the migration trap.
apps/www/scripts/build-docs-registry.mts currently:
../../content.cn.mdxDIRECTORY_PATTERN_REGEXregistry-docs.jsonfumadocs registry item with docs files and MDX component filesUpdate it after the content layout is final:
meta.json filescontent/docs/plate/... targets expected by consumerspublic/r/registry-docs.json still includes docs, fumadocs, and representative docs pagesRegistry docs are consumer-facing. Breaking them quietly would be a real regression.
Port the existing MDX component registry from apps/www/src/components/mdx-components.tsx.
Must preserve:
ComponentPreviewComponentPreviewProComponentSourcePackageInfoAPI, APIItem, APIOptions, and related API docs helpersCallout, Steps, Tabs, Cards, FrameworkDocsReleaseIndexFumadocs compiles MDX differently than Contentlayer. Audit all custom MDX tags with:
rg -o "<[A-Z][A-Za-z0-9]*" content -g "*.mdx" | sort -u
Every tag in that list needs either a registered MDX component or a deliberate removal.
Preserve current LLM affordances:
LLMCopyButtonViewOptions.md rewrites if present or added laterapps/www/src/lib/llm-context.ts docs registry referencesFumadocs exposes raw text through page.data.getText("raw"); use that instead of doc.body.raw.
After the migration is complete:
contentlayer2next-contentlayer2apps/www/contentlayer.config.js.contentlayer tsconfig alias and includebuild:contentlayer and prebuild coupling from apps/www/package.jsonAdd:
fumadocs-mdxfumadocs-ui only if Plate adopts Fumadocs UI components directly; otherwise keep headless/source onlypostinstall or an explicit app build pre-step that runs fumadocs-mdxUse the same package-manager discipline as the repo: run pnpm install after manifest changes.
allDocs and registry params into a local JSON fixture./docs, /docs/installation, /docs/components/editor, /docs/table, /docs/api/core, /docs/releases, and /cn/docs/table.content/**/*.mdx.@/.contentlayer/generated, next-contentlayer2, or doc.body.code/raw.registry-docs.json.Success criteria:
source.config.ts and src/lib/source.ts..source alias to apps/www/tsconfig.json.createMDX.../../content is viable or whether public docs must move under apps/www/content/docs.Success criteria:
fumadocs-mdx generates .source.getText("raw").DocContent shell during the first cutover.Success criteria:
.cn.mdx preference..cn.mdx is missing./cn/docs/*.Success criteria:
/cn/docs/table works when a Chinese file exists./cn/docs/<english-only-page> falls back to English.meta.json files matching current sidebar order.Success criteria:
build-docs-registry.mts for the final Fumadocs content layout.meta.json files in the docs registry item.fumadocs registry item working for downstream local-docs installs.Success criteria:
pnpm --filter www rd produces valid docs registry JSON.content/docs/plate/... expectations.apps/www/contentlayer.config.js..contentlayer aliases/includes.next-contentlayer2/hooks usage.Success criteria:
rg "contentlayer|next-contentlayer|\\.contentlayer|allDocs" apps/www content package.json only finds intentional migration notes or no matches.Required commands after implementation:
pnpm install
pnpm --filter www fumadocs-mdx
pnpm --filter www rd
pnpm turbo build --filter=./apps/www
pnpm turbo typecheck --filter=./apps/www
pnpm lint:fix
Browser Use checks:
/docs/docs/installation/docs/components/editor/docs/table/docs/api/core/docs/releases/cn/docs/table/docs/examples/*If package graph imports fail during package-scoped typecheck, follow repo policy: run root pnpm build, then rerun the same typecheck before treating unresolved workspace imports as real debt.
Developer opens a normal docs page under /docs/*.
Developer opens a registry-backed component or example page.
Chinese reader opens /cn/docs/*.
Consumer installs Plate local docs from the registry.
registry-docs.json includes docs MDX, meta files, and Fumadocs support files.Maintainer adds a new docs page.
meta.json entry.Critical:
content/ or moves under apps/www/content/docs.docsConfig is removed in the same PR or only after a transitional parity gate.Important:
meta.json ownership. If generated once, commit it and make it source of truth. If generated every time, document the generator and verification.Minor:
Default assumptions if unanswered:
.cn.mdx is awkward.docsConfig only after meta/pageTree parity is verified.Functional:
/docs/* routes render from Fumadocs source, not Contentlayer./cn/docs/* preserves translated-page preference and English fallback.ComponentPreview, ComponentSource, PackageInfo, and API docs tags render.getText("raw").registry-docs.json still publishes installable Plate docs.Quality:
meta.json is the long-term navigation source.Non-goals:
Risk: Fumadocs route generation does not match Contentlayer's grouping-folder stripping.
Risk: CN fallback depends on Contentlayer private fields.
Risk: registry docs export silently drops files or helper dependencies.
Risk: server route imports pull client-only registry code into the RSC graph.
Risk: custom MDX tags compile but render wrong under Fumadocs.
Internal:
apps/www/contentlayer.config.js - current Contentlayer source, slug normalization, MDX plugins.apps/www/src/app/(app)/docs/[[...slug]]/page.tsx - English docs route and registry fallback.apps/www/src/app/cn/docs/[[...slug]]/page.tsx - CN docs route and fallback.apps/www/src/config/docs.ts - current sidebar source.apps/www/scripts/build-docs-registry.mts - local docs registry export.apps/www/src/components/mdx-components.tsx - custom MDX component registry.../ui/apps/v4/source.config.ts - shadcn Fumadocs source config.../ui/apps/v4/lib/source.ts - shadcn Fumadocs loader.../ui/apps/v4/app/(app)/docs/[[...slug]]/page.tsx - shadcn route pattern.Institutional learnings:
docs/solutions/developer-experience/2026-04-27-mdx-generated-markers-must-use-jsx-comments.md - generated MDX markers must use JSX comments.docs/solutions/developer-experience/2026-04-06-next-turbopack-needs-client-boundaries-at-react-package-entrypoints.md - registry server paths must not import client-only helpers as server-safe code.docs/solutions/developer-experience/2026-04-06-registry-helper-refactors-must-update-template-registry-dependencies.md - registry metadata, not app source existence, is the source of truth for generated consumers.External:
defineDocs and defineConfig: https://github.com/fuma-nama/fumadocs/blob/dev/apps/docs/content/docs/(framework)/integrations/content/local-md.mdxgetText("raw"): https://github.com/fuma-nama/fumadocs/blob/dev/apps/docs/content/blog/mdx-12.mdx.source and @/.source alias: https://github.com/fuma-nama/fumadocs/blob/dev/apps/docs/content/blog/mdx-v13.mdxShould this migration physically reorganize content/ into Fumadocs folders in the same PR?
Should CN docs remain .cn.mdx siblings or move to a separate content-cn/ tree?
Should the first implementation keep docsConfig for sidebar/pager until visual parity is proven?
meta.json parity passes.Should generated registry docs preserve existing item names exactly?
codex/fumadocs-migration.main before branching.../ui/apps/v4 uses route-group folders such as content/docs/(root), so Plate's existing grouped folders under content/ should be validated before any physical content move.docs/solutions/patterns/critical-patterns.md was not present in this repo.../../content; route-group folders are stripped by the Fumadocs loader, so no physical content move is needed in this slice.rehype-utils. Splitting runtime registry component lookup into src/lib/registry-component.tsx kept MDX source generation server-safe.pnpm install prunes removed dependencies.docsConfig for this cutover. That preserves the current docs UX while Fumadocs owns page discovery, MDX compilation, raw markdown, and localized fallback.pnpm installpnpm --filter www exec fumadocs-mdxpnpm --filter www exec next buildpnpm turbo typecheck --filter=./apps/wwwpnpm lint:fix/docs, /docs/installation, /docs/components/editor, /docs/table, /docs/api/core, /docs/releases, /cn/docs/table, and /docs/examples/slate-to-htmlnext build still reports the existing Turbopack NFT warning through src/lib/rehype-utils.ts and /api/registry/[name]. It does not block the build.pnpm --filter www rd because repo instructions forbid local registry builds outside CI.pnpm --filter www exec fumadocs-mdxpnpm --filter www exec next buildpnpm turbo typecheck --filter=./apps/wwwpnpm lint:fixdocs/solutions/developer-experience/2026-05-22-fumadocs-mdx-migrations-need-server-safe-source-config-and-mdx-boundaries.md.docsConfig to Fumadocs meta.json/pageTreebuild:registry outside CI.apps/www/scripts/check-docs-source-parity.mts and wired it into www typecheck. It verifies:
docsConfig route resolves through generated Fumadocs source, app-only docs routes, or registry fallback.cn.mdx files are present and representative translated docs are generateddocs, fumadocs, table-docs, and skips translated .cn.mdx exports without writing public/rRegistry / Validate Registry failed while building templates/plate-playground-template.pnpm templates:update --local uses shadcn@latest, which generated components/ui/calendar.tsx with classNames.table while the template dependency graph installed [email protected]. React Day Picker v9.14 exposes month_grid, not table.react-day-picker to 10.0.1, where initialFocus is removed from DayPickerProps.templates/** by hand.