Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/chronicle/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
113 changes: 113 additions & 0 deletions packages/chronicle/src/lib/tree-utils.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
57 changes: 57 additions & 0 deletions packages/chronicle/src/lib/tree-utils.ts
Original file line number Diff line number Diff line change
@@ -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('/')}`);
}
41 changes: 5 additions & 36 deletions packages/chronicle/src/pages/DocsPage.tsx
Original file line number Diff line number Diff line change
@@ -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[];
Expand All @@ -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 <Navigate to={`/${contentConfig.dir}/${contentConfig.index_page}`} replace />;
}
if (isContentRoot) {
const firstUrl = getFirstPageUrl(tree.children);
if (firstUrl) return <Navigate to={firstUrl} replace />;
}
const folderFirstUrl = findFolderFirstPage(tree.children, pathname);
if (folderFirstUrl) return <Navigate to={folderFirstUrl} replace />;
const redirectUrl = resolveDocsRedirect(slug, tree, contentConfig);
if (redirectUrl) return <Navigate to={redirectUrl} replace />;
return <NotFound />;
}
if (errorStatus) return <NotFound />;
Expand Down
29 changes: 28 additions & 1 deletion packages/chronicle/src/server/entry-server.tsx
Comment thread
rsbh marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
Loading