Skip to content
Open
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
5 changes: 5 additions & 0 deletions bun.lock

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

2 changes: 1 addition & 1 deletion docs/chronicle.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ description: Config-driven documentation framework
content: .

theme:
name: paper
name: default

navigation:
links:
Expand Down
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,8 @@
"dev:docs": "./packages/chronicle/bin/chronicle.js dev --config docs/chronicle.yaml",
"start:docs": "./packages/chronicle/bin/chronicle.js start --config docs/chronicle.yaml",
"build:docs": "./packages/chronicle/bin/chronicle.js build --config docs/chronicle.yaml"
},
"dependencies": {
"std-env": "^4.0.0"
}
}
25 changes: 21 additions & 4 deletions packages/chronicle/src/components/ui/client-theme-switcher.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,35 @@
'use client'

import { ThemeSwitcher } from '@raystack/apsara'
import { useState, useEffect } from 'react'
import { MoonIcon, SunIcon } from '@heroicons/react/24/outline'
import { IconButton, useTheme } from '@raystack/apsara'
import { useEffect, useState } from 'react'

interface ClientThemeSwitcherProps {
size?: number
}

export function ClientThemeSwitcher({ size }: ClientThemeSwitcherProps) {
export function ClientThemeSwitcher({ size = 16 }: ClientThemeSwitcherProps) {
const [isClient, setIsClient] = useState(false)
const { resolvedTheme, setTheme } = useTheme()

useEffect(() => {
setIsClient(true)
}, [])

return isClient ? <ThemeSwitcher size={size} /> : null
if (!isClient) return null

const isDark = resolvedTheme === 'dark'
return (
<IconButton
size={3}
aria-label={isDark ? 'Switch to light theme' : 'Switch to dark theme'}
onClick={() => setTheme(isDark ? 'light' : 'dark')}
>
{isDark ? (
<SunIcon width={size} height={size} />
) : (
<MoonIcon width={size} height={size} />
)}
</IconButton>
)
}
13 changes: 0 additions & 13 deletions packages/chronicle/src/components/ui/search.module.css
Original file line number Diff line number Diff line change
@@ -1,16 +1,3 @@
.trigger {
gap: 8px;
color: var(--rs-color-foreground-base-secondary);
cursor: pointer;
}

.kbd {
padding: 2px 6px;
border-radius: 4px;
border: 1px solid var(--rs-color-border-base-primary);
font-size: 12px;
}

.dialogContent {
max-width: 600px;
padding: 0;
Expand Down
39 changes: 13 additions & 26 deletions packages/chronicle/src/components/ui/search.tsx
Original file line number Diff line number Diff line change
@@ -1,28 +1,16 @@
import { DocumentIcon, HashtagIcon } from '@heroicons/react/24/outline';
import { Button, Command, Dialog, Text } from '@raystack/apsara';
import { cx } from 'class-variance-authority';
import {
DocumentIcon,
HashtagIcon,
MagnifyingGlassIcon
} from '@heroicons/react/24/outline';
import { Command, Dialog, IconButton, Text } from '@raystack/apsara';
import type { SortedResult } from 'fumadocs-core/search';
import { useDocsSearch } from 'fumadocs-core/search/client';
import { useCallback, useEffect, useState } from 'react';
import { useNavigate } from 'react-router';
import { MethodBadge } from '@/components/api/method-badge';
import styles from './search.module.css';

function SearchShortcutKey({ className }: { className?: string }) {
const [key, setKey] = useState('⌘');

useEffect(() => {
const isMac = navigator.platform?.toUpperCase().includes('MAC');
setKey(isMac ? '⌘' : 'Ctrl');
}, []);

return (
<kbd className={className} suppressHydrationWarning>
{key} K
</kbd>
);
}

interface SearchProps {
className?: string;
}
Expand Down Expand Up @@ -64,16 +52,15 @@ export function Search({ className }: SearchProps) {

return (
<>
<Button
variant='outline'
color='neutral'
size='small'
<IconButton
size={3}
aria-label='Search'
title='Search (⌘K)'
onClick={() => setOpen(true)}
className={cx(styles.trigger, className)}
trailingIcon={<SearchShortcutKey className={styles.kbd} />}
className={className}
>
Search...
</Button>
<MagnifyingGlassIcon width={16} height={16} />
</IconButton>
Comment thread
coderabbitai[bot] marked this conversation as resolved.

<Dialog open={open} onOpenChange={setOpen}>
<Dialog.Content className={styles.dialogContent}>
Expand Down
28 changes: 14 additions & 14 deletions packages/chronicle/src/lib/page-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,14 @@ import {
} from 'react';
import { useLocation } from 'react-router';
import type { ApiSpec } from '@/lib/openapi';
import type { ChronicleConfig, Frontmatter, Root, TableOfContents } from '@/types';
import type { ChronicleConfig, Frontmatter, Page, Root, TableOfContents } from '@/types';

export type MdxLoader = (relativePath: string) => Promise<{ content: ReactNode; toc: TableOfContents }>;

interface PageData {
slug: string[];
frontmatter: Frontmatter;
content: ReactNode;
toc: TableOfContents;
}

interface PageContextValue {
config: ChronicleConfig;
tree: Root;
page: PageData | null;
page: Page | null;
errorStatus: number | null;
apiSpecs: ApiSpec[];
}
Expand All @@ -46,7 +39,7 @@ export function usePageContext(): PageContextValue {
interface PageProviderProps {
initialConfig: ChronicleConfig;
initialTree: Root;
initialPage: PageData | null;
initialPage: Page | null;
initialApiSpecs: ApiSpec[];
loadMdx: MdxLoader;
children: ReactNode;
Expand All @@ -56,7 +49,7 @@ function isApisRoute(pathname: string): boolean {
return pathname === '/apis' || pathname.startsWith('/apis/');
}

function getInitialErrorStatus(page: PageData | null, pathname: string): number | null {
function getInitialErrorStatus(page: Page | null, pathname: string): number | null {
if (page) return null;
if (pathname === '/' || isApisRoute(pathname)) return null;
return 404;
Expand All @@ -72,7 +65,7 @@ export function PageProvider({
}: PageProviderProps) {
const { pathname } = useLocation();
const [tree] = useState<Root>(initialTree);
const [page, setPage] = useState<PageData | null>(initialPage);
const [page, setPage] = useState<Page | null>(initialPage);
const [errorStatus, setErrorStatus] = useState<number | null>(getInitialErrorStatus(initialPage, pathname));
const [apiSpecs, setApiSpecs] = useState<ApiSpec[]>(initialApiSpecs);
const [currentPath, setCurrentPath] = useState(pathname);
Expand Down Expand Up @@ -112,12 +105,19 @@ export function PageProvider({
}
return res.json();
})
.then(async (data: { frontmatter: Frontmatter; relativePath: string; originalPath?: string } | undefined) => {
.then(async (data: { frontmatter: Frontmatter; relativePath: string; originalPath?: string; prev?: Page['prev']; next?: Page['next'] } | undefined) => {
if (cancelled.current || !data) return;
const { content, toc } = await loadMdx(data.originalPath || data.relativePath);
if (cancelled.current) return;
setErrorStatus(null);
setPage({ slug, frontmatter: data.frontmatter, content, toc });
setPage({
slug,
frontmatter: data.frontmatter,
content,
toc,
prev: data.prev ?? null,
next: data.next ?? null
});
})
.catch(() => {
if (!cancelled.current) {
Expand Down
19 changes: 18 additions & 1 deletion packages/chronicle/src/lib/source.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { loader } from 'fumadocs-core/source';
import { flattenTree } from 'fumadocs-core/page-tree';
import type { Root, Node, Folder } from 'fumadocs-core/page-tree';
import type { MDXContent } from 'mdx/types';
import type { TableOfContents } from 'fumadocs-core/toc';
import type { Frontmatter } from '@/types';
import type { Frontmatter, PageNav, PageNavLink } from '@/types';

const CONTENT_PREFIX = '../../.content/';

Expand Down Expand Up @@ -105,6 +106,22 @@ export async function getPage(slugs?: string[]) {
return s.getPage(slugs);
}

export async function getPageNav(slug: string[]): Promise<PageNav> {
const tree = await getPageTree();
const pages = flattenTree(tree.children);
const url = slug.length === 0 ? '/' : `/${slug.join('/')}`;
const i = pages.findIndex(p => p.url === url);
if (i < 0) return { prev: null, next: null };
const toLink = (p: (typeof pages)[number]): PageNavLink => ({
url: p.url,
title: typeof p.name === 'string' ? p.name : ''
});
Comment on lines +115 to +118
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm the shape of items returned by fumadocs-core/page-tree flattenTree in v16.6.15.
fd -t f 'package.json' node_modules/fumadocs-core 2>/dev/null | head -n 2
rg -nP --type=ts -C2 'export\s+(declare\s+)?(function|const)\s+flattenTree' node_modules/fumadocs-core 2>/dev/null
rg -nP --type=ts -C3 'name\s*:' node_modules/fumadocs-core/dist/page-tree 2>/dev/null | head -n 40

Repository: raystack/chronicle

Length of output: 44


🏁 Script executed:

# First, let's look at the actual source.ts file around lines 115-118
cat -n packages/chronicle/src/lib/source.ts | sed -n '100,130p'

Repository: raystack/chronicle

Length of output: 1318


🏁 Script executed:

# Check the package.json for fumadocs-core dependency
cat packages/chronicle/package.json | jq '.dependencies, .devDependencies' 2>/dev/null || cat packages/chronicle/package.json | grep -A 20 '"dependencies"'

Repository: raystack/chronicle

Length of output: 1633


🏁 Script executed:

# Look for type definitions and imports related to PageNavLink, PageTree
rg -n 'PageNavLink|PageTree' packages/chronicle/src/lib/source.ts -B 3 -A 3

Repository: raystack/chronicle

Length of output: 1076


🏁 Script executed:

# Check for fumadocs-core imports and usage
rg -n 'from.*fumadocs-core' packages/chronicle/src/lib/source.ts

Repository: raystack/chronicle

Length of output: 296


🏁 Script executed:

# Find and read the types file
find packages/chronicle/src -name 'types.ts' -o -name 'types.d.ts' -o -name 'index.d.ts' | head -5

Repository: raystack/chronicle

Length of output: 44


🏁 Script executed:

# Check the types directory or types file
cat packages/chronicle/src/types.ts 2>/dev/null || cat packages/chronicle/src/types/index.ts 2>/dev/null || find packages/chronicle/src -type f -name '*.ts' | xargs grep -l 'PageNavLink\|PageNav' | head -3

Repository: raystack/chronicle

Length of output: 136


🏁 Script executed:

# Search for PageNavLink definition
rg -n 'PageNavLink' packages/chronicle/src -B 2 -A 2

Repository: raystack/chronicle

Length of output: 2457


🏁 Script executed:

# Search for PageNav definition as well
rg -n 'interface PageNav|type PageNav' packages/chronicle/src -B 2 -A 5

Repository: raystack/chronicle

Length of output: 811


🏁 Script executed:

# List the types directory structure
ls -la packages/chronicle/src/types/

Repository: raystack/chronicle

Length of output: 470


🏁 Script executed:

# Check what Node type looks like when imported from fumadocs-core
rg -n 'Root|Node|Folder' packages/chronicle/src/lib/source.ts -A 3 | head -30

Repository: raystack/chronicle

Length of output: 1293


🏁 Script executed:

# Search for any usage of page.name or how Node type items are accessed
rg -n '\.name' packages/chronicle/src/lib/source.ts -B 2 -A 2

Repository: raystack/chronicle

Length of output: 210


🏁 Script executed:

# Let's check the fumadocs-core documentation or type stubs in the project
find packages/chronicle -name '*.d.ts' -o -name '*.ts' | xargs grep -l 'fumadocs' | xargs grep -l 'name.*ReactNode\|ReactNode.*name' 2>/dev/null | head -5

Repository: raystack/chronicle

Length of output: 44


🏁 Script executed:

# Look for any comments or usage patterns around page names
rg -n 'p\.name|pages\[.*\]\.name' packages/chronicle/src -B 3 -A 3

Repository: raystack/chronicle

Length of output: 1804


🌐 Web query:

fumadocs-core 16.6.15 PageTree Item name type ReactNode

💡 Result:

In fumadocs-core version 16.6.15, the PageTree.Item interface defines the "name" property as type ReactNode (import { ReactNode } from 'react'). This represents the display name of the page/navigation item, which can be a string, JSX element, or other React renderable content for flexible rendering in UI components like sidebars. The full Item interface is: interface Item { $id?: string; $ref?: { file: string }; type: 'page'; name: ReactNode; url: string; external?: boolean; description?: ReactNode; icon?: ReactNode; } This structure is consistent across fumadocs-core v16.x releases, including 16.6.15, as shown in official documentation and API references. PageTree nodes (Root, Folder, Separator) also use ReactNode for name and similar properties to support rich content.

Citations:


🌐 Web query:

fumadocs-core flattenTree return type name field documentation

💡 Result:

The flattenTree function from fumadocs-core/page-tree flattens a page tree into an array of page nodes. Its signature is function flattenTree(nodes: Node[]): Item[]. The return type is Item[], where Item is the interface for page nodes with fields like $id?: string, $ref?: { file: string }, type: 'page', name: ReactNode, url: string, external?: boolean, description?: ReactNode, and icon?: ReactNode. The name field in Item is of type ReactNode and represents the display name of the page.

Citations:


String(p.name) will stringify ReactNode names to "[object Object]".

In fumadocs-core 16.6.15, Item.name is typed as ReactNode, which can be a string, JSX element, or other React renderable content. When a page's name is a React element (e.g., decorated with an icon or custom formatting), String(...) produces "[object Object]" and that value flows through to PageNavLink.title.

The PageNavLink interface expects title: string. This is a latent bug that will surface whenever page names are anything other than simple strings.

Prefer deriving the title from the page's frontmatter title (matching what extractFrontmatter does) or guard for string-only names.

🛠 Proposed fix
-  const toLink = (p: (typeof pages)[number]): PageNavLink => ({
-    url: p.url,
-    title: String(p.name ?? '')
-  });
+  const toLink = (p: (typeof pages)[number]): PageNavLink => ({
+    url: p.url,
+    title: typeof p.name === 'string' ? p.name : (p.name == null ? '' : String(p.name))
+  });

Or, for full fidelity, resolve the title from source.getPages() by URL and use extractFrontmatter(match).title.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const toLink = (p: (typeof pages)[number]): PageNavLink => ({
url: p.url,
title: String(p.name ?? '')
});
const toLink = (p: (typeof pages)[number]): PageNavLink => ({
url: p.url,
title: typeof p.name === 'string' ? p.name : (p.name == null ? '' : String(p.name))
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/chronicle/src/lib/source.ts` around lines 115 - 118, toLink
currently forces PageNavLink.title by doing String(p.name) which will stringify
ReactNode names to "[object Object]"; instead guard and produce a proper string
title: if p.name is a string use it, otherwise resolve the page by URL from
source.getPages() (or the pages array) and use extractFrontmatter(match).title
as the fallback (and finally fallback to an empty string); update the toLink
mapping (referencing toLink, pages, PageNavLink.title, Item.name,
source.getPages(), and extractFrontmatter) to implement this lookup and
type-guard so non-string ReactNode names do not produce "[object Object]".

return {
prev: i > 0 ? toLink(pages[i - 1]) : null,
next: i < pages.length - 1 ? toLink(pages[i + 1]) : null
};
}

export function extractFrontmatter(page: { data: unknown }, fallbackTitle?: string): Frontmatter {
const d = page.data as Record<string, unknown>;
return {
Expand Down
7 changes: 1 addition & 6 deletions packages/chronicle/src/pages/DocsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,7 @@ export function DocsPage({ slug }: DocsPageProps) {
}}
/>
<Page
page={{
slug: page.slug,
frontmatter: page.frontmatter,
content: page.content,
toc: page.toc
}}
page={page}
config={config}
tree={tree}
/>
Expand Down
6 changes: 5 additions & 1 deletion packages/chronicle/src/server/api/page.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { defineHandler, HTTPError } from 'nitro';
import { getPage, extractFrontmatter, getRelativePath, getOriginalPath } from '@/lib/source';
import { getPage, getPageNav, extractFrontmatter, getRelativePath, getOriginalPath } from '@/lib/source';

export default defineHandler(async event => {
const slugParam = event.url.searchParams.get('slug') ?? '';
Expand All @@ -10,9 +10,13 @@ export default defineHandler(async event => {
throw new HTTPError({ status: 404, message: 'Page not found' });
}

const nav = await getPageNav(slug);

return {
frontmatter: extractFrontmatter(page, slug[slug.length - 1]),
relativePath: getRelativePath(page),
originalPath: getOriginalPath(page),
prev: nav.prev,
next: nav.next,
};
});
6 changes: 5 additions & 1 deletion packages/chronicle/src/server/entry-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { BrowserRouter } from 'react-router';
import { ReactRouterProvider } from 'fumadocs-core/framework/react-router';
import { mdxComponents } from '@/components/mdx';
import { PageProvider } from '@/lib/page-context';
import type { ChronicleConfig, Frontmatter, Root, TableOfContents } from '@/types';
import type { ChronicleConfig, Frontmatter, PageNavLink, Root, TableOfContents } from '@/types';
import type { ApiSpec } from '@/lib/openapi';
import type { ReactNode } from 'react';
import { App } from './App';
Expand All @@ -17,6 +17,8 @@ interface EmbeddedData {
frontmatter: Frontmatter;
relativePath: string;
originalPath?: string;
prev: PageNavLink | null;
next: PageNavLink | null;
}

const contentModules = import.meta.glob<{ default?: React.ComponentType<any>; toc?: TableOfContents }>(
Expand Down Expand Up @@ -60,6 +62,8 @@ async function hydrate() {
? {
slug: embedded!.slug,
frontmatter: embedded!.frontmatter,
prev: embedded!.prev,
next: embedded!.next,
...(await loadMdxModule(mdxPath)),
}
: null;
Expand Down
9 changes: 7 additions & 2 deletions packages/chronicle/src/server/entry-server.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { mdxComponents } from '@/components/mdx';
import { loadConfig } from '@/lib/config';
import { loadApiSpecs } from '@/lib/openapi';
import { PageProvider } from '@/lib/page-context';
import { getPageTree, getPage, loadPageModule, extractFrontmatter, getRelativePath, getOriginalPath } from '@/lib/source';
import { getPageTree, getPage, getPageNav, loadPageModule, extractFrontmatter, getRelativePath, getOriginalPath } from '@/lib/source';
import { useNitroApp } from 'nitro/app';
import { App } from './App';

Expand All @@ -26,9 +26,10 @@ export default {
? await loadApiSpecs(config.api).catch(() => [])
: [];

const [tree, page] = await Promise.all([
const [tree, page, nav] = await Promise.all([
getPageTree(),
getPage(slug),
getPageNav(slug),
]);

const relativePath = page ? getRelativePath(page) : null;
Expand All @@ -43,6 +44,8 @@ export default {
? React.createElement(mdxModule.default, { components: mdxComponents })
: null,
toc: mdxModule?.toc ?? [],
prev: nav.prev,
next: nav.next,
}
: null;

Expand All @@ -53,6 +56,8 @@ export default {
frontmatter: pageData?.frontmatter ?? null,
relativePath,
originalPath,
prev: pageData?.prev ?? null,
next: pageData?.next ?? null,
};
const safeJson = JSON.stringify(embeddedData).replace(/</g, '\\u003c');

Expand Down
Loading