From f25417ebda294dafc60953e154cd5ba891e7ee9d Mon Sep 17 00:00:00 2001 From: Luke Bennett Date: Tue, 11 Aug 2026 11:23:23 +1000 Subject: [PATCH] Fix left nav for props pages --- .../docs-tree-pathname-provider.tsx | 51 +++++++++++++++++++ .../src/lib/component-page-navigation.test.ts | 16 +++++- .../docs/src/lib/component-page-navigation.ts | 5 ++ apps/docs/src/lib/component-page-tree.test.ts | 30 +++++++++++ apps/docs/src/routes/$.tsx | 29 ++++++----- docs/DOCUMENTATION.md | 4 +- 6 files changed, 120 insertions(+), 15 deletions(-) create mode 100644 apps/docs/src/components/docs-tree-pathname-provider.tsx diff --git a/apps/docs/src/components/docs-tree-pathname-provider.tsx b/apps/docs/src/components/docs-tree-pathname-provider.tsx new file mode 100644 index 00000000..ddc7f1d0 --- /dev/null +++ b/apps/docs/src/components/docs-tree-pathname-provider.tsx @@ -0,0 +1,51 @@ +import { Link, useParams, useRouter, useRouterState } from '@tanstack/react-router'; +import { FrameworkProvider } from 'fumadocs-core/framework'; +import type { ComponentProps, ReactNode } from 'react'; +import { useMemo, useRef } from 'react'; +import { getDocsTreePathname } from '../lib/component-page-navigation.js'; + +const framework = { + Link({ href, prefetch = true, ...props }: ComponentProps<'a'> & { prefetch?: boolean }) { + return ; + }, + usePathname() { + const { isLoading, pathname } = useRouterState({ + select: (state) => ({ + isLoading: state.isLoading, + pathname: state.location.pathname, + }), + }); + const activePathname = useRef(pathname); + return useMemo(() => { + if (isLoading) return getDocsTreePathname(activePathname.current); + activePathname.current = pathname; + return getDocsTreePathname(pathname); + }, [isLoading, pathname]); + }, + useRouter() { + const router = useRouter(); + return useMemo( + () => ({ + push(url: string) { + void router.navigate({ href: url }); + }, + refresh() { + void router.invalidate(); + }, + }), + [router], + ); + }, + useParams() { + return useParams({ strict: false }); + }, +}; + +/** + * Wraps Fumadocs' framework context so the sidebar tree inside `DocsLayout` matches + * Props pages to their Guide pathname. Props pages are hidden from the page tree, so + * without this remap Fumadocs falls back to the full tree instead of the Components root. + */ +export function DocsTreePathnameProvider({ children }: { children: ReactNode }) { + return {children}; +} diff --git a/apps/docs/src/lib/component-page-navigation.test.ts b/apps/docs/src/lib/component-page-navigation.test.ts index 05594e28..78407532 100644 --- a/apps/docs/src/lib/component-page-navigation.test.ts +++ b/apps/docs/src/lib/component-page-navigation.test.ts @@ -1,5 +1,5 @@ import { expect, test } from 'vite-plus/test'; -import { getComponentPageNavigation } from './component-page-navigation.js'; +import { getComponentPageNavigation, getDocsTreePathname } from './component-page-navigation.js'; test('returns Guide and Props links for a component guide', () => { expect(getComponentPageNavigation('/components/actions/button')).toEqual({ @@ -22,3 +22,17 @@ test('does not add component navigation to other docs pages', () => { expect(getComponentPageNavigation('/components/actions')).toBeNull(); expect(getComponentPageNavigation('/components/actions/button/examples')).toBeNull(); }); + +test('maps a Props pathname to its Guide pathname for tree matching', () => { + expect(getDocsTreePathname('/components/actions/button/props')).toBe( + '/components/actions/button', + ); +}); + +test('leaves a Guide pathname unchanged for tree matching', () => { + expect(getDocsTreePathname('/components/actions/button')).toBe('/components/actions/button'); +}); + +test('leaves other docs pathnames unchanged for tree matching', () => { + expect(getDocsTreePathname('/docs/installation')).toBe('/docs/installation'); +}); diff --git a/apps/docs/src/lib/component-page-navigation.ts b/apps/docs/src/lib/component-page-navigation.ts index a477ae22..b4a551eb 100644 --- a/apps/docs/src/lib/component-page-navigation.ts +++ b/apps/docs/src/lib/component-page-navigation.ts @@ -18,3 +18,8 @@ export function getComponentPageNavigation(pageUrl: string): ComponentPageNaviga propsUrl: `${guideUrl}/props`, }; } + +/** Pathname Fumadocs should use for page-tree matching. Props pages map to their guide. */ +export function getDocsTreePathname(pathname: string): string { + return getComponentPageNavigation(pathname)?.guideUrl ?? pathname; +} diff --git a/apps/docs/src/lib/component-page-tree.test.ts b/apps/docs/src/lib/component-page-tree.test.ts index 349ed882..8b88d2df 100644 --- a/apps/docs/src/lib/component-page-tree.test.ts +++ b/apps/docs/src/lib/component-page-tree.test.ts @@ -1,7 +1,9 @@ +import { searchPath } from 'fumadocs-core/breadcrumb'; import type { Item, Node, Root } from 'fumadocs-core/page-tree'; import { loader } from 'fumadocs-core/source'; import { expect, test } from 'vite-plus/test'; import { docs } from '../../.source/server.js'; +import { getDocsTreePathname } from './component-page-navigation.js'; const source = loader({ baseUrl: '/', @@ -57,6 +59,34 @@ const components: ReadonlyArray<{ group: string; name: string }> = [ { group: 'primitives', name: 'visually-hidden' }, ]; +/** The root folder in a `searchPath` result, per `getBreadcrumbItemsFromPath` in `fumadocs-core/breadcrumb`. */ +function findRootFolder(path: ReadonlyArray) { + return path.find((node) => node.type === 'folder' && node.root); +} + +test('a Props URL is hidden from the tree, so Fumadocs cannot match it directly', () => { + const tree = source.getPageTree(); + const nodes = [...allNodes(tree)]; + const propsUrl = '/components/actions/button/props'; + + expect(searchPath(nodes, propsUrl)).toBeNull(); +}); + +test('remapping a Props URL to its Guide URL keeps tree matching under the Components root', () => { + const tree = source.getPageTree(); + const nodes = [...allNodes(tree)]; + const guideUrl = '/components/actions/button'; + const propsUrl = `${guideUrl}/props`; + + const guidePath = searchPath(nodes, guideUrl); + const remappedPath = searchPath(nodes, getDocsTreePathname(propsUrl)); + + expect(guidePath).not.toBeNull(); + expect(remappedPath).not.toBeNull(); + expect(findRootFolder(remappedPath ?? [])?.name).toBe('Components'); + expect(findRootFolder(remappedPath ?? [])?.name).toBe(findRootFolder(guidePath ?? [])?.name); +}); + for (const { group, name } of components) { test(`the sidebar entry for /components/${group}/${name} is clickable and hides its Props page`, () => { const tree = source.getPageTree(); diff --git a/apps/docs/src/routes/$.tsx b/apps/docs/src/routes/$.tsx index cfa97aba..44eaf86d 100644 --- a/apps/docs/src/routes/$.tsx +++ b/apps/docs/src/routes/$.tsx @@ -9,6 +9,7 @@ import defaultMdxComponents from 'fumadocs-ui/mdx'; import { Suspense } from 'react'; import * as z from 'zod'; import browserCollections from '../../.source/browser'; +import { DocsTreePathnameProvider } from '../components/docs-tree-pathname-provider.js'; import { ExampleBlock } from '../components/example-block'; import { IconGallery } from '../components/icon-gallery'; import { PageActions } from '../components/page-actions'; @@ -158,18 +159,20 @@ function Page() { const data = useFumadocsLoader(Route.useLoaderData()); return ( - - - {clientLoader.useContent(data.path, { - className: 'pb-16 md:pb-20 xl:pb-24', - componentNavigation: data.componentNavigation, - githubUrl: data.githubUrl, - markdownUrl: data.markdownUrl, - reactAriaUrl: data.reactAriaUrl, - sourceUrl: data.sourceUrl, - storybookUrl: data.storybookUrl, - })} - - + + + + {clientLoader.useContent(data.path, { + className: 'pb-16 md:pb-20 xl:pb-24', + componentNavigation: data.componentNavigation, + githubUrl: data.githubUrl, + markdownUrl: data.markdownUrl, + reactAriaUrl: data.reactAriaUrl, + sourceUrl: data.sourceUrl, + storybookUrl: data.storybookUrl, + })} + + + ); } diff --git a/docs/DOCUMENTATION.md b/docs/DOCUMENTATION.md index acbc4d72..ec56d068 100644 --- a/docs/DOCUMENTATION.md +++ b/docs/DOCUMENTATION.md @@ -98,7 +98,9 @@ Do not document exports that are not public API. frontmatter by `scripts/generate-props-pages.ts`. `props.mdx` is the API reference at `/components///props`. `meta.json` uses `"pages": ["!props"]` and `"collapsible": false` so the component stays one ordinary sidebar link. It sets -`"pagesIndex": "../"` so the folder's sidebar entry points to the sibling guide. +`"pagesIndex": "../"` so the folder's sidebar entry points to the sibling guide. The shared +docs route remaps a Props pathname to its Guide URL for Fumadocs' sidebar tree matching, so Guide +and Props share the Components sidebar. Keep the guide and Props frontmatter titles and descriptions identical. The component generator leaves editorial descriptions out instead of adding placeholder copy. Add one useful description to -- 2.51.2