From 3dd0b3fb01b26d9c29fcd3ca6a334c78cfa752be Mon Sep 17 00:00:00 2001 From: Rishabh Date: Thu, 14 May 2026 11:14:26 +0530 Subject: [PATCH 1/6] feat: SSR redirects for index pages and folder navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move redirect logic to entry-server.tsx for SSR 307 redirects - Shared tree-utils.ts used by both SSR and client-side DocsPage - /apis → first API endpoint - /docs (no index) → first page or index_page from config - /docs/folder → first child page (derived from child URLs) - Add http-status-codes for readable status constants - 16 tests for tree-utils (getFirstPageUrl, findFolderFirstPage, resolveDocsRedirect) Co-Authored-By: Claude Opus 4.6 (1M context) --- bun.lock | 3 + packages/chronicle/package.json | 1 + packages/chronicle/src/lib/tree-utils.test.ts | 113 ++++++++++++++++++ packages/chronicle/src/lib/tree-utils.ts | 52 ++++++++ packages/chronicle/src/pages/DocsPage.tsx | 38 +----- .../chronicle/src/server/entry-server.tsx | 19 +++ 6 files changed, 191 insertions(+), 35 deletions(-) create mode 100644 packages/chronicle/src/lib/tree-utils.test.ts create mode 100644 packages/chronicle/src/lib/tree-utils.ts diff --git a/bun.lock b/bun.lock index 1da4d45..e9306aa 100644 --- a/bun.lock +++ b/bun.lock @@ -42,6 +42,7 @@ "glob": "^11.0.0", "gray-matter": "^4.0.3", "h3": "^2.0.1-rc.16", + "http-status-codes": "^2.3.0", "lodash-es": "^4.17.23", "mermaid": "^11.13.0", "nitro": "3.0.260311-beta", @@ -678,6 +679,8 @@ "html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="], + "http-status-codes": ["http-status-codes@2.3.0", "", {}, "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA=="], + "httpxy": ["httpxy@0.3.1", "", {}, "sha512-XjG/CEoofEisMrnFr0D6U6xOZ4mRfnwcYQ9qvvnT4lvnX8BoeA3x3WofB75D+vZwpaobFVkBIHrZzoK40w8XSw=="], "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], diff --git a/packages/chronicle/package.json b/packages/chronicle/package.json index a7fe134..3e2b14d 100644 --- a/packages/chronicle/package.json +++ b/packages/chronicle/package.json @@ -59,6 +59,7 @@ "glob": "^11.0.0", "gray-matter": "^4.0.3", "h3": "^2.0.1-rc.16", + "http-status-codes": "^2.3.0", "lodash-es": "^4.17.23", "mermaid": "^11.13.0", "nitro": "3.0.260311-beta", diff --git a/packages/chronicle/src/lib/tree-utils.test.ts b/packages/chronicle/src/lib/tree-utils.test.ts new file mode 100644 index 0000000..e95ac18 --- /dev/null +++ b/packages/chronicle/src/lib/tree-utils.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, test } from 'bun:test' +import type { Node } from 'fumadocs-core/page-tree' +import { getFirstPageUrl, findFolderFirstPage, resolveDocsRedirect } from './tree-utils' + +function page(url: string, name = 'Page'): Node { + return { type: 'page', name, url } as Node +} + +function folder(name: string, children: Node[], indexUrl?: string): Node { + return { + type: 'folder', + name, + children, + ...(indexUrl ? { index: { url: indexUrl } } : {}), + } as Node +} + +describe('getFirstPageUrl', () => { + test('returns first page url', () => { + expect(getFirstPageUrl([page('/docs/intro')])).toBe('/docs/intro') + }) + + test('returns first page from nested folder', () => { + const nodes = [folder('Guides', [page('/docs/guides/install')])] + expect(getFirstPageUrl(nodes)).toBe('/docs/guides/install') + }) + + test('skips empty folders', () => { + const nodes = [folder('Empty', []), page('/docs/hello')] + expect(getFirstPageUrl(nodes)).toBe('/docs/hello') + }) + + test('returns null for empty list', () => { + expect(getFirstPageUrl([])).toBeNull() + }) + + test('returns null for folders with no pages', () => { + expect(getFirstPageUrl([folder('Empty', [])])).toBeNull() + }) +}) + +describe('findFolderFirstPage', () => { + test('finds folder by index url', () => { + const nodes = [ + folder('Guides', [page('/docs/guides/install'), page('/docs/guides/config')], '/docs/guides'), + ] + expect(findFolderFirstPage(nodes, '/docs/guides')).toBe('/docs/guides/install') + }) + + test('finds folder without index by child page path', () => { + const nodes = [ + folder('Guides', [page('/docs/guides/install'), page('/docs/guides/config')]), + ] + expect(findFolderFirstPage(nodes, '/docs/guides')).toBe('/docs/guides/install') + }) + + test('finds nested folder', () => { + const nodes = [ + folder('Docs', [ + folder('Advanced', [page('/docs/advanced/perf'), page('/docs/advanced/debug')]), + ]), + ] + expect(findFolderFirstPage(nodes, '/docs/advanced')).toBe('/docs/advanced/perf') + }) + + test('returns null for non-matching path', () => { + const nodes = [folder('Guides', [page('/docs/guides/install')])] + expect(findFolderFirstPage(nodes, '/docs/api')).toBeNull() + }) + + test('returns null for empty folder', () => { + const nodes = [folder('Empty', [])] + expect(findFolderFirstPage(nodes, '/docs/empty')).toBeNull() + }) +}) + +describe('resolveDocsRedirect', () => { + const tree = { + children: [ + page('/docs/intro'), + folder('Guides', [page('/docs/guides/install')]), + ] as Node[], + } + + test('redirects to index_page when set', () => { + expect(resolveDocsRedirect(['docs'], tree, { dir: 'docs', index_page: 'getting-started' })) + .toBe('/docs/getting-started') + }) + + test('redirects content root to first page', () => { + expect(resolveDocsRedirect(['docs'], tree, { dir: 'docs' })) + .toBe('/docs/intro') + }) + + test('redirects folder to first child', () => { + expect(resolveDocsRedirect(['docs', 'guides'], tree, { dir: 'docs' })) + .toBe('/docs/guides/install') + }) + + test('returns null for non-matching path', () => { + expect(resolveDocsRedirect(['docs', 'nonexistent'], tree, { dir: 'docs' })) + .toBeNull() + }) + + test('returns null without content config', () => { + expect(resolveDocsRedirect(['other'], tree)).toBeNull() + }) + + test('index_page takes priority over first page', () => { + expect(resolveDocsRedirect(['docs'], tree, { dir: 'docs', index_page: 'custom' })) + .toBe('/docs/custom') + }) +}) diff --git a/packages/chronicle/src/lib/tree-utils.ts b/packages/chronicle/src/lib/tree-utils.ts new file mode 100644 index 0000000..16ee5da --- /dev/null +++ b/packages/chronicle/src/lib/tree-utils.ts @@ -0,0 +1,52 @@ +import type { Node } from 'fumadocs-core/page-tree'; + +export function getFirstPageUrl(nodes: Node[]): string | null { + for (const node of nodes) { + if (node.type === 'page') return node.url; + if (node.type === 'folder') { + const url = getFirstPageUrl(node.children); + if (url) return url; + } + } + return null; +} + +function getFolderPath(node: Node): string | null { + if (node.type !== 'folder') return null; + if (node.index) return node.index.url; + const firstPage = getFirstPageUrl(node.children); + if (!firstPage) return null; + const parts = firstPage.split('/').filter(Boolean); + parts.pop(); + return '/' + parts.join('/'); +} + +export function findFolderFirstPage(nodes: Node[], pathname: string): string | null { + for (const node of nodes) { + if (node.type === 'folder') { + const folderPath = getFolderPath(node); + if (folderPath === pathname) return getFirstPageUrl(node.children); + const found = findFolderFirstPage(node.children, pathname); + if (found) return found; + } + } + return null; +} + +export function resolveDocsRedirect( + slug: string[], + tree: { children: Node[] }, + contentConfig?: { dir: string; index_page?: string }, +): string | null { + const isContentRoot = slug.length === 1 && slug[0] === contentConfig?.dir; + + if (contentConfig?.index_page) { + return `/${contentConfig.dir}/${contentConfig.index_page}`; + } + + if (isContentRoot) { + return getFirstPageUrl(tree.children); + } + + return findFolderFirstPage(tree.children, `/${slug.join('/')}`); +} diff --git a/packages/chronicle/src/pages/DocsPage.tsx b/packages/chronicle/src/pages/DocsPage.tsx index 768e3e0..6e49900 100644 --- a/packages/chronicle/src/pages/DocsPage.tsx +++ b/packages/chronicle/src/pages/DocsPage.tsx @@ -1,32 +1,9 @@ import { Navigate } from 'react-router'; import { Head } from '@/lib/head'; import { usePageContext } from '@/lib/page-context'; +import { resolveDocsRedirect } from '@/lib/tree-utils'; import { NotFound } from '@/pages/NotFound'; import { getTheme } from '@/themes/registry'; -import type { Node } from 'fumadocs-core/page-tree'; - -function getFirstPageUrl(nodes: Node[]): string | null { - for (const node of nodes) { - if (node.type === 'page') return node.url; - if (node.type === 'folder') { - const url = getFirstPageUrl(node.children); - if (url) return url; - } - } - return null; -} - -function findFolderFirstPage(nodes: Node[], pathname: string): string | null { - for (const node of nodes) { - if (node.type === 'folder') { - const folderUrl = node.index?.url; - if (folderUrl === pathname) return getFirstPageUrl(node.children); - const found = findFolderFirstPage(node.children, pathname); - if (found) return found; - } - } - return null; -} interface DocsPageProps { slug: string[]; @@ -36,18 +13,9 @@ export function DocsPage({ slug }: DocsPageProps) { const { config, tree, page, isLoading, errorStatus } = usePageContext(); if (errorStatus === 404) { - const pathname = `/${slug.join('/')}`; const contentConfig = config.content?.find(c => c.dir === slug[0]); - const isContentRoot = slug.length === 1 && slug[0] === contentConfig?.dir; - if (contentConfig?.index_page) { - return ; - } - if (isContentRoot) { - const firstUrl = getFirstPageUrl(tree.children); - if (firstUrl) return ; - } - const folderFirstUrl = findFolderFirstPage(tree.children, pathname); - if (folderFirstUrl) return ; + const redirectUrl = resolveDocsRedirect(slug, tree, contentConfig); + if (redirectUrl) return ; return ; } if (errorStatus) return ; diff --git a/packages/chronicle/src/server/entry-server.tsx b/packages/chronicle/src/server/entry-server.tsx index bc9da4e..17fc44b 100644 --- a/packages/chronicle/src/server/entry-server.tsx +++ b/packages/chronicle/src/server/entry-server.tsx @@ -10,6 +10,9 @@ import { loadApiSpecs } from '@/lib/openapi'; import { PageProvider } from '@/lib/page-context'; import { resolveRoute, RouteType } from '@/lib/route-resolver'; import { getPageTree, getPage, getPageNav, loadPageModule, extractFrontmatter, getRelativePath, getOriginalPath } from '@/lib/source'; +import { getFirstApiUrl } from '@/lib/api-routes'; +import { StatusCodes } from 'http-status-codes'; +import { resolveDocsRedirect } from '@/lib/tree-utils'; import { useNitroApp } from 'nitro/app'; import { App } from './App'; @@ -45,6 +48,22 @@ export default { getPageTree(), route.type === RouteType.DocsPage ? getPage(route.slug) : Promise.resolve(null), ]); + // SSR redirects for index pages + if (route.type === RouteType.ApiIndex) { + const firstUrl = getFirstApiUrl(apiSpecs); + if (firstUrl) { + return new Response(null, { status: StatusCodes.TEMPORARY_REDIRECT, headers: { Location: firstUrl } }); + } + } + + if (route.type === RouteType.DocsPage && !page) { + const contentConfig = config.content?.find((c: { dir: string }) => c.dir === route.slug[0]); + const redirectUrl = resolveDocsRedirect(route.slug, tree, contentConfig); + if (redirectUrl) { + return new Response(null, { status: StatusCodes.TEMPORARY_REDIRECT, headers: { Location: redirectUrl } }); + } + } + const nav = page ? await getPageNav(pageSlug) : { prev: null, next: null }; const relativePath = page ? getRelativePath(page) : null; From 3c64d64150d6374a5b518c9ab6b3782241dbdae3 Mon Sep 17 00:00:00 2001 From: Rishabh Date: Thu, 14 May 2026 11:17:30 +0530 Subject: [PATCH 2/6] refactor: use NodeType constants instead of string literals Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/chronicle/src/lib/tree-utils.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/chronicle/src/lib/tree-utils.ts b/packages/chronicle/src/lib/tree-utils.ts index 16ee5da..76f9185 100644 --- a/packages/chronicle/src/lib/tree-utils.ts +++ b/packages/chronicle/src/lib/tree-utils.ts @@ -1,9 +1,15 @@ import type { Node } from 'fumadocs-core/page-tree'; +export const NodeType = { + Page: 'page', + Folder: 'folder', + Separator: 'separator', +} as const; + export function getFirstPageUrl(nodes: Node[]): string | null { for (const node of nodes) { - if (node.type === 'page') return node.url; - if (node.type === 'folder') { + if (node.type === NodeType.Page) return node.url; + if (node.type === NodeType.Folder) { const url = getFirstPageUrl(node.children); if (url) return url; } @@ -12,7 +18,7 @@ export function getFirstPageUrl(nodes: Node[]): string | null { } function getFolderPath(node: Node): string | null { - if (node.type !== 'folder') return null; + if (node.type !== NodeType.Folder) return null; if (node.index) return node.index.url; const firstPage = getFirstPageUrl(node.children); if (!firstPage) return null; @@ -23,7 +29,7 @@ function getFolderPath(node: Node): string | null { export function findFolderFirstPage(nodes: Node[], pathname: string): string | null { for (const node of nodes) { - if (node.type === 'folder') { + if (node.type === NodeType.Folder) { const folderPath = getFolderPath(node); if (folderPath === pathname) return getFirstPageUrl(node.children); const found = findFolderFirstPage(node.children, pathname); From 3f1d29b99d6a255514ae9b29638a3c9a0de3953e Mon Sep 17 00:00:00 2001 From: Rishabh Date: Thu, 14 May 2026 11:18:27 +0530 Subject: [PATCH 3/6] fix: only redirect to index_page at content root, not all missing slugs Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/chronicle/src/lib/tree-utils.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/chronicle/src/lib/tree-utils.ts b/packages/chronicle/src/lib/tree-utils.ts index 76f9185..2e6d0ad 100644 --- a/packages/chronicle/src/lib/tree-utils.ts +++ b/packages/chronicle/src/lib/tree-utils.ts @@ -46,11 +46,10 @@ export function resolveDocsRedirect( ): string | null { const isContentRoot = slug.length === 1 && slug[0] === contentConfig?.dir; - if (contentConfig?.index_page) { - return `/${contentConfig.dir}/${contentConfig.index_page}`; - } - if (isContentRoot) { + if (contentConfig?.index_page) { + return `/${contentConfig.dir}/${contentConfig.index_page}`; + } return getFirstPageUrl(tree.children); } From 0a1d6e150a960a76b5c0e402354bfcb0470974ac Mon Sep 17 00:00:00 2001 From: Rishabh Date: Thu, 14 May 2026 13:04:37 +0530 Subject: [PATCH 4/6] fix: handle versioned site redirects in SSR Strip version prefix from slug before resolving redirect, use version-specific content config, prepend version prefix to redirect URL. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/chronicle/src/server/entry-server.tsx | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/chronicle/src/server/entry-server.tsx b/packages/chronicle/src/server/entry-server.tsx index 17fc44b..f57861a 100644 --- a/packages/chronicle/src/server/entry-server.tsx +++ b/packages/chronicle/src/server/entry-server.tsx @@ -57,10 +57,18 @@ export default { } if (route.type === RouteType.DocsPage && !page) { - const contentConfig = config.content?.find((c: { dir: string }) => c.dir === route.slug[0]); - const redirectUrl = resolveDocsRedirect(route.slug, tree, contentConfig); + const versionPrefix = route.version.urlPrefix; + const slugWithoutVersion = versionPrefix && route.slug[0] === route.version.dir + ? route.slug.slice(1) + : route.slug; + const contentEntries = route.version.dir + ? config.versions?.find(v => v.dir === route.version.dir)?.content ?? config.content + : config.content; + const contentConfig = contentEntries?.find((c: { dir: string }) => c.dir === slugWithoutVersion[0]); + const redirectUrl = resolveDocsRedirect(slugWithoutVersion, tree, contentConfig); if (redirectUrl) { - return new Response(null, { status: StatusCodes.TEMPORARY_REDIRECT, headers: { Location: redirectUrl } }); + const fullUrl = versionPrefix ? `${versionPrefix}${redirectUrl}` : redirectUrl; + return new Response(null, { status: StatusCodes.TEMPORARY_REDIRECT, headers: { Location: fullUrl } }); } } From 64ea9972e9d28042699bc27623d9428f5b75fcf5 Mon Sep 17 00:00:00 2001 From: Rishabh Date: Thu, 14 May 2026 13:40:32 +0530 Subject: [PATCH 5/6] refactor: use StatusCodes for 404 and 200 in entry-server Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/chronicle/src/server/entry-server.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/chronicle/src/server/entry-server.tsx b/packages/chronicle/src/server/entry-server.tsx index f57861a..aa9f1ae 100644 --- a/packages/chronicle/src/server/entry-server.tsx +++ b/packages/chronicle/src/server/entry-server.tsx @@ -147,7 +147,7 @@ export default { const renderDuration = performance.now() - renderStart; - const status = route.type === RouteType.DocsPage && !page ? 404 : 200; + const status = route.type === RouteType.DocsPage && !page ? StatusCodes.NOT_FOUND : StatusCodes.OK; // biome-ignore lint/correctness/useHookAtTopLevel: useNitroApp is a Nitro DI accessor, not a React hook useNitroApp().hooks.callHook('chronicle:ssr-rendered', pathname, status, renderDuration); From b1286e3630b935193a9b50d0cc292625e32813b6 Mon Sep 17 00:00:00 2001 From: Rishabh Date: Thu, 14 May 2026 13:41:24 +0530 Subject: [PATCH 6/6] refactor: use StatusCodes.NOT_FOUND in DocsPage Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/chronicle/src/pages/DocsPage.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/chronicle/src/pages/DocsPage.tsx b/packages/chronicle/src/pages/DocsPage.tsx index 6e49900..2052cc7 100644 --- a/packages/chronicle/src/pages/DocsPage.tsx +++ b/packages/chronicle/src/pages/DocsPage.tsx @@ -1,4 +1,5 @@ import { Navigate } from 'react-router'; +import { StatusCodes } from 'http-status-codes'; import { Head } from '@/lib/head'; import { usePageContext } from '@/lib/page-context'; import { resolveDocsRedirect } from '@/lib/tree-utils'; @@ -12,7 +13,7 @@ interface DocsPageProps { export function DocsPage({ slug }: DocsPageProps) { const { config, tree, page, isLoading, errorStatus } = usePageContext(); - if (errorStatus === 404) { + if (errorStatus === StatusCodes.NOT_FOUND) { const contentConfig = config.content?.find(c => c.dir === slug[0]); const redirectUrl = resolveDocsRedirect(slug, tree, contentConfig); if (redirectUrl) return ;