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..2e6d0ad
--- /dev/null
+++ b/packages/chronicle/src/lib/tree-utils.ts
@@ -0,0 +1,57 @@
+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 === NodeType.Page) return node.url;
+ if (node.type === NodeType.Folder) {
+ const url = getFirstPageUrl(node.children);
+ if (url) return url;
+ }
+ }
+ return null;
+}
+
+function getFolderPath(node: Node): string | null {
+ if (node.type !== NodeType.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 === NodeType.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 (isContentRoot) {
+ if (contentConfig?.index_page) {
+ return `/${contentConfig.dir}/${contentConfig.index_page}`;
+ }
+ 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..2052cc7 100644
--- a/packages/chronicle/src/pages/DocsPage.tsx
+++ b/packages/chronicle/src/pages/DocsPage.tsx
@@ -1,32 +1,10 @@
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';
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[];
@@ -35,19 +13,10 @@ interface DocsPageProps {
export function DocsPage({ slug }: DocsPageProps) {
const { config, tree, page, isLoading, errorStatus } = usePageContext();
- if (errorStatus === 404) {
- const pathname = `/${slug.join('/')}`;
+ if (errorStatus === StatusCodes.NOT_FOUND) {
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..aa9f1ae 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,30 @@ 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 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) {
+ const fullUrl = versionPrefix ? `${versionPrefix}${redirectUrl}` : redirectUrl;
+ return new Response(null, { status: StatusCodes.TEMPORARY_REDIRECT, headers: { Location: fullUrl } });
+ }
+ }
+
const nav = page ? await getPageNav(pageSlug) : { prev: null, next: null };
const relativePath = page ? getRelativePath(page) : null;
@@ -120,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);