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
45 changes: 32 additions & 13 deletions packages/chronicle/src/lib/source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,41 +118,60 @@ export function invalidate() {
cachedNavMap = null;
}

function getOrder(node: Node, orderMap: Map<string, number>): number | undefined {
if (node.type === 'page') return orderMap.get(node.url);
if (node.type === 'folder' && node.index) return orderMap.get(node.index.url);
function getOrder(node: Node, pageOrderMap: Map<string, number>, folderOrderMap: Map<string, number>): number | undefined {
if (node.type === 'page') return pageOrderMap.get(node.url);
if (node.type === 'folder') {
if (node.index) {
const fromMeta = folderOrderMap.get(node.index.url);
if (fromMeta !== undefined) return fromMeta;
return pageOrderMap.get(node.index.url);
}
}
return undefined;
}
Comment on lines +121 to 131
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 | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify whether fumadocs Folder nodes expose any path/url identifier independent of `index`.
rg -nP --type=ts -C3 '\bFolder\b' node_modules/fumadocs-core/dist 2>/dev/null | head -200
fd -i 'page-tree' node_modules/fumadocs-core 2>/dev/null | head -20

Repository: raystack/chronicle

Length of output: 44


🏁 Script executed:

# First, check the full source.ts file to understand the context
wc -l packages/chronicle/src/lib/source.ts

Repository: raystack/chronicle

Length of output: 103


🏁 Script executed:

# Read the relevant section around getOrder and buildFolderOrderMap
sed -n '115,160p' packages/chronicle/src/lib/source.ts

Repository: raystack/chronicle

Length of output: 1712


🏁 Script executed:

# Check for Folder/Node type definitions in the repository
fd -i 'types\|index' packages/chronicle/src --type f | head -20

Repository: raystack/chronicle

Length of output: 44


🏁 Script executed:

# Look for where Node type is defined
rg -n 'type Node\b|interface Node' packages/chronicle/src

Repository: raystack/chronicle

Length of output: 44


🏁 Script executed:

head -50 packages/chronicle/src/lib/source.ts

Repository: raystack/chronicle

Length of output: 1709


🏁 Script executed:

# Search for Node, Folder, Root type definitions in the entire src directory
rg -n 'type Node|interface Node|type Folder|interface Folder|type Root|interface Root' packages/chronicle/src --type ts

Repository: raystack/chronicle

Length of output: 44


🏁 Script executed:

# Look in node_modules for fumadocs types
find node_modules -name "*.d.ts" -type f 2>/dev/null | xargs grep -l "type Folder\|interface Folder" 2>/dev/null | head -5

Repository: raystack/chronicle

Length of output: 44


🏁 Script executed:

# Find fumadocs-core in node_modules
find node_modules/fumadocs-core -name "*.d.ts" -type f 2>/dev/null | grep -E "(page-tree|index)" | head -10

Repository: raystack/chronicle

Length of output: 44


🏁 Script executed:

# Look for page-tree type definitions
find node_modules -path "*fumadocs-core*page-tree*" -type f 2>/dev/null | head -20

Repository: raystack/chronicle

Length of output: 44


🏁 Script executed:

# Try to find the actual fumadocs-core types
fd -t f "fumadocs-core" node_modules 2>/dev/null | head -20

Repository: raystack/chronicle

Length of output: 44


🏁 Script executed:

# Check if fumadocs-core is in package.json
rg 'fumadocs-core' packages/chronicle/package.json

Repository: raystack/chronicle

Length of output: 93


🏁 Script executed:

# Also check root package.json
rg 'fumadocs-core' package.json

Repository: raystack/chronicle

Length of output: 44


🌐 Web query:

fumadocs-core Folder type definition page-tree

💡 Result:

In fumadocs-core, the “folder” node in the Page Tree is defined as a TypeScript interface with the node type discriminator type: 'folder'. A Folder node contains name, children, and optional folder-specific metadata like index, description, and collapsible/root/defaultOpen/icon settings. [1][2] Example shape (from the “Page Tree API” docs): interface Folder { $id?: string; $ref?: { metaFile?: string }; type: 'folder'; name: ReactNode; description?: ReactNode; root?: boolean; defaultOpen?: boolean; collapsible?: boolean; index?: Item; icon?: ReactNode; children: Node[]; } [1] How this maps to folder behavior: - A page tree is described as navigation links that includes separators and folders. [2] - A Folder node is explicitly “a node containing multiple children nodes.” [2] - Folder-specific “meta.json” options (title/icon/root/defaultOpen/collapsible/description/pages) are part of the meta data used to control folder behavior in the page tree. [3][4] Related utility types/docs: - The “Page Tree Utils” docs show that fumadocs-core exports helpers operating on the page tree root type Root from fumadocs-core/page-tree. [5] Citations: [1] https://www.mintlify.com/fuma-nama/fumadocs/api/core/page-tree [2] https://www.fumadocs.dev/docs/headless/page-tree [5] https://www.fumadocs.dev/docs/headless/utils/page-tree [3] https://www.fumadocs.dev/docs/headless/page-conventions [4] https://www.mintlify.com/fuma-nama/fumadocs/api/core/source

Citations:


Folder order in meta.json is silently ignored when the folder has no index page.

getOrder only resolves a folder's order via node.index?.url, but buildFolderOrderMap keys entries by the folder's URL derived from meta.path. For folders without an index.mdx/readme.mdx, node.index is undefined, so the meta-based order never gets consulted and the folder falls into the "no order" bucket (sorted last). The PR's stated goal of using order in meta.json to control folder sorting therefore only holds for folders that happen to have an index page.

The Folder type from fumadocs-core has no standalone path/url property—only index and children. To fix this, the comparator needs the folder's own URL (built and passed down during tree traversal) rather than relying on node.index.url.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/chronicle/src/lib/source.ts` around lines 121 - 131, The comparator
currently ignores folder-level order from meta.json when a Folder node has no
index because getOrder(node, pageOrderMap, folderOrderMap) only looks up folder
order via node.index.url; update the logic to use the folder's own URL (built
during tree traversal) when resolving folder order. Change the call sites that
compute ordering (where getOrder is invoked during traversal/sorting) to pass
the folderUrl (the folder path derived from meta.path) and then modify getOrder
(or add a new parameter) so when node.type === 'folder' and node.index is
undefined it looks up folderOrderMap.get(folderUrl) before falling back to
pageOrderMap. Ensure you reference and update buildFolderOrderMap keys (which
are derived from meta.path) and the Node/Folder handling so folder meta.order is
applied even without an index file.


function sortNodes(nodes: Node[], orderMap: Map<string, number>): Node[] {
function sortNodes(nodes: Node[], pageOrderMap: Map<string, number>, folderOrderMap: Map<string, number>): Node[] {
return [...nodes]
.map(n =>
n.type === 'folder'
? ({ ...n, children: sortNodes(n.children, orderMap) } as Folder)
? ({ ...n, children: sortNodes(n.children, pageOrderMap, folderOrderMap) } as Folder)
: n
)
.sort(
(a, b) =>
(getOrder(a, orderMap) ?? Number.MAX_SAFE_INTEGER) -
(getOrder(b, orderMap) ?? Number.MAX_SAFE_INTEGER)
(getOrder(a, pageOrderMap, folderOrderMap) ?? Number.MAX_SAFE_INTEGER) -
(getOrder(b, pageOrderMap, folderOrderMap) ?? Number.MAX_SAFE_INTEGER)
);
}

function sortTreeByOrder(tree: Root, pages: { url: string; data: unknown }[]): Root {
const orderMap = new Map<string, number>();
function buildFolderOrderMap(metaFiles: { path: string; data: Record<string, unknown> }[]): Map<string, number> {
const map = new Map<string, number>();
for (const meta of metaFiles) {
const order = meta.data.order as number | undefined;
if (order === undefined) continue;
const folderUrl = '/' + meta.path.replace(/\/meta\.json$/, '');
map.set(folderUrl, order);
}
return map;
}

function sortTreeByOrder(tree: Root, pages: { url: string; data: unknown }[], metaFiles: { path: string; data: Record<string, unknown> }[]): Root {
const pageOrderMap = new Map<string, number>();
for (const page of pages) {
const d = page.data as Record<string, unknown>;
const order = d.order as number | undefined;
if (order !== undefined) orderMap.set(page.url, order);
if (page.url === '/') orderMap.set('/', order ?? 0);
if (order !== undefined) pageOrderMap.set(page.url, order);
if (page.url === '/') pageOrderMap.set('/', order ?? 0);
}
return { ...tree, children: sortNodes(tree.children, orderMap) };
const folderOrderMap = buildFolderOrderMap(metaFiles);
return { ...tree, children: sortNodes(tree.children, pageOrderMap, folderOrderMap) };
}

export async function getPageTree(): Promise<Root> {
if (cachedTree) return cachedTree;
const s = await getSource();
cachedTree = sortTreeByOrder(s.pageTree as Root, s.getPages());
const metaFiles = buildFiles().filter(f => f.type === 'meta') as { path: string; data: Record<string, unknown> }[];
Comment thread
rohilsurana marked this conversation as resolved.
cachedTree = sortTreeByOrder(s.pageTree as Root, s.getPages(), metaFiles);
return cachedTree;
}

Expand Down
21 changes: 20 additions & 1 deletion packages/chronicle/src/pages/DocsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,18 @@ function getFirstPageUrl(nodes: Node[]): string | null {
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);
Comment thread
rohilsurana marked this conversation as resolved.
const found = findFolderFirstPage(node.children, pathname);
if (found) return found;
}
}
return null;
}
Comment on lines +19 to +29
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 | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm fumadocs Folder type has no intrinsic url/path field independent of `index`.
fd -i 'page-tree' node_modules/fumadocs-core 2>/dev/null
rg -nP --type=ts -C5 '(interface|type)\s+Folder\b' node_modules/fumadocs-core 2>/dev/null | head -120

Repository: raystack/chronicle

Length of output: 44


🏁 Script executed:

cat -n packages/chronicle/src/pages/DocsPage.tsx | head -60

Repository: raystack/chronicle

Length of output: 2553


🏁 Script executed:

rg -n "type Node|interface Node|type Folder|interface Folder" packages/chronicle --type=ts --type=tsx -A 3

Repository: raystack/chronicle

Length of output: 90


🏁 Script executed:

rg -n "findFolderFirstPage" packages/chronicle --type=ts --type=tsx -B 5 -A 5

Repository: raystack/chronicle

Length of output: 90


🏁 Script executed:

rg -n "type Node|interface Node" packages/chronicle --type=ts -A 5

Repository: raystack/chronicle

Length of output: 44


🏁 Script executed:

cat package.json | grep -A 5 -B 5 fumadocs

Repository: raystack/chronicle

Length of output: 44


🏁 Script executed:

# Try to find Node definition in node_modules if available
find node_modules -name "page-tree*" -type f 2>/dev/null | head -20

Repository: raystack/chronicle

Length of output: 44


🏁 Script executed:

cat packages/chronicle/package.json | grep -E "name|fumadocs|dependencies" -A 30

Repository: raystack/chronicle

Length of output: 2271


🏁 Script executed:

cat package.json | grep -E "fumadocs" -B 2 -A 2

Repository: raystack/chronicle

Length of output: 44


🏁 Script executed:

# Examine the logic: how pathname is constructed and used
rg -n "pathname|slug" packages/chronicle/src/pages/DocsPage.tsx -B 2 -A 2

Repository: raystack/chronicle

Length of output: 1493


🌐 Web query:

fumadocs-core page-tree Node Folder type structure url properties

💡 Result:

In fumadocs-core, the page-tree is a hierarchical structure for navigation, defined by the Node type which is a union: type Node = Item | Separator | Folder [1][2][3]. Folders represent collapsible sections containing child nodes. Folder interface [1][2]: interface Folder { $id?: string; $ref?: { metaFile?: string }; type: 'folder'; name: ReactNode; description?: ReactNode; root?: boolean; // Marks as root folder, affects navigation visibility [4][3] defaultOpen?: boolean; collapsible?: boolean; index?: Item; // Index page for the folder (not used for root folders) [2] icon?: ReactNode; children: Node[]; // Recursive child nodes } Item (page) interface, relevant for index and leaf pages [1]: interface Item { $id?: string; $ref?: { file: string }; type: 'page'; name: ReactNode; url: string; // URL/path of the page external?: boolean; description?: ReactNode; icon?: ReactNode; } The structure is built from content directories and meta.json files, where folders can define pages array for custom ordering/exclusions [4]. URLs derive from file paths/slugs [4][5]. Root is typically { type: 'root', children: Node[] } or array of root Folders [6].

Citations:


🏁 Script executed:

# Search for other usages of Node/Folder in the codebase to understand structure
rg -n "node\." packages/chronicle/src/pages/DocsPage.tsx --type=ts -B 1 -A 1

Repository: raystack/chronicle

Length of output: 560


🏁 Script executed:

# Look for page-tree usage elsewhere in the codebase
rg -n "page-tree|Node\[" packages/chronicle/src --type=ts -C 3

Repository: raystack/chronicle

Length of output: 8376


findFolderFirstPage cannot match folders without an index page — the PR's stated scenario fails.

The match condition is node.index?.url === pathname. According to fumadocs-core type definitions, Folder has no intrinsic url field; the only way to access a folder's URL is via its optional index?.url property. For folders without an index page, node.index is undefined, making folderUrl undefined and the comparison undefined === pathname always false.

The PR targets the case where visiting /docs/guides (a folder without an index page) should redirect to its first child page. Since such folders have no node.index, the redirect logic cannot trigger.

A path-aware traversal that constructs the folder's URL from accumulated path segments is needed to match and redirect folders without index pages.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/chronicle/src/pages/DocsPage.tsx` around lines 19 - 29,
findFolderFirstPage fails for folders that lack an index because it only
compares node.index?.url to pathname; update findFolderFirstPage to be
path-aware by adding a currentPath (or prefix) parameter and construct the
folder's URL when index is missing (e.g., folderUrl = node.index?.url ??
`${currentPath}/${nodeSlugOrName}`), then compare that folderUrl to pathname;
pass the updated currentPath into recursive calls for children and use
getFirstPageUrl(node.children) unchanged when a match is found. Ensure you
update all call sites to supply the initial empty/current path.


interface DocsPageProps {
slug: string[];
}
Expand All @@ -24,11 +36,18 @@ export function DocsPage({ slug }: DocsPageProps) {
const { config, tree, page, isLoading, errorStatus } = usePageContext();

if (errorStatus === 404) {
const isContentRoot = config.content?.some(c => slug.length === 1 && slug[0] === c.dir);
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 <Navigate to={`/${contentConfig.dir}/${contentConfig.index_page}`} replace />;
}
Comment thread
rohilsurana marked this conversation as resolved.
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 />;
Comment on lines 38 to +50
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 | 🔴 Critical | ⚡ Quick win

index_page redirect masks every 404 inside a configured content directory and risks a redirect loop.

The index_page check at lines 42–44 is not gated by isContentRoot. Any 404 whose first slug segment matches a configured content[].dir (e.g., a typo like /docs/intoduction, or a stale link like /docs/foo/bar) will be silently redirected to /${dir}/${index_page} instead of surfacing the real 404 to the user. The commit message itself scopes this behavior to "when visiting a content root", which matches what isContentRoot already encodes.

There is also a redirect-loop risk: if index_page itself doesn't resolve to a valid page, the redirect target lands on a path with the same first segment, comes back here as a 404, and <Navigate>s again to the same target.

🐛 Proposed fix — gate `index_page` on `isContentRoot`
   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 <Navigate to={`/${contentConfig.dir}/${contentConfig.index_page}`} replace />;
-    }
     if (isContentRoot) {
+      if (contentConfig?.index_page) {
+        return <Navigate to={`/${contentConfig.dir}/${contentConfig.index_page}`} replace />;
+      }
       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 />;
     return <NotFound />;
   }

Consider also validating that the index_page target resolves to a real page (or guarding against the redirect target equaling the current pathname) to avoid the loop scenario above.

📝 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
if (errorStatus === 404) {
const isContentRoot = config.content?.some(c => slug.length === 1 && slug[0] === c.dir);
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 <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 />;
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 (isContentRoot) {
if (contentConfig?.index_page) {
return <Navigate to={`/${contentConfig.dir}/${contentConfig.index_page}`} replace />;
}
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 />;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/chronicle/src/pages/DocsPage.tsx` around lines 38 - 50, The current
logic performs an unconditional redirect to contentConfig.index_page on any 404
whose first slug matches a content dir, which masks real 404s and can create a
redirect loop; update the block that checks contentConfig.index_page to only run
when isContentRoot is true and additionally guard the redirect target
(constructed from contentConfig.dir + contentConfig.index_page) to ensure it
resolves to a real page (or is not equal to the current pathname) before
returning <Navigate />. Locate the check around index_page in DocsPage
(references: slug, contentConfig, isContentRoot, pathname, tree,
getFirstPageUrl, findFolderFirstPage) and modify it so index_page redirect is
gated by isContentRoot and validated against existing pages or current pathname
to prevent loops.

return <NotFound />;
}
if (errorStatus) return <NotFound />;
Expand Down
1 change: 1 addition & 0 deletions packages/chronicle/src/types/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ const contentEntrySchema = z.object({
label: z.string().min(1),
description: z.string().optional(),
icon: z.string().optional(),
index_page: z.string().optional(),
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Naming inconsistency: prefer indexPage over index_page.

The rest of chronicle.yaml's public config schema uses camelCase (googleAnalytics, measurementId, basePath, serviceName). Once this field ships, renaming it will be a breaking change for user configs, so it's worth aligning now.

♻️ Proposed rename
-  index_page: z.string().optional(),
+  indexPage: z.string().optional(),

Update the corresponding read site in DocsPage.tsx (and any docs/examples) accordingly.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/chronicle/src/types/config.ts` at line 89, Rename the public config
field from index_page to indexPage in the schema (update the zod entry in
config.ts that currently uses index_page: z.string().optional()), and update all
consumers to the new camelCase name (notably the code that reads it in
DocsPage.tsx and any docs/examples) so configs remain consistent; ensure
backward compatibility if needed by optionally accepting index_page and mapping
it to indexPage during parsing/migration.

})

// Variants map to Apsara Badge color prop.
Expand Down
Loading