feat: support order in meta.json and folder redirect#62
Conversation
Set index_page in chronicle.yaml content entry to specify which page to redirect to when visiting a content root (e.g., /docs). Falls back to first page in tree when not set. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- meta.json order field sorts folders in sidebar - Visiting a folder URL redirects to its first sorted child page - Folder order from meta.json takes priority over index page frontmatter Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
📝 WalkthroughSummary by CodeRabbit
WalkthroughIndex page configuration and folder-aware tree ordering have been added. A new optional ChangesIndex Page Routing and Folder Ordering
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related PRs
Suggested reviewers
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 4
🤖 Prompt for all review comments with 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.
Inline comments:
In `@packages/chronicle/src/lib/source.ts`:
- Around line 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.
In `@packages/chronicle/src/pages/DocsPage.tsx`:
- Around line 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.
- Around line 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.
In `@packages/chronicle/src/types/config.ts`:
- 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.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: d47b4f9a-aded-4e63-be54-e65b09ee3896
📒 Files selected for processing (3)
packages/chronicle/src/lib/source.tspackages/chronicle/src/pages/DocsPage.tsxpackages/chronicle/src/types/config.ts
| 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; | ||
| } |
There was a problem hiding this comment.
🧩 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 -20Repository: 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.tsRepository: 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.tsRepository: 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 -20Repository: raystack/chronicle
Length of output: 44
🏁 Script executed:
# Look for where Node type is defined
rg -n 'type Node\b|interface Node' packages/chronicle/srcRepository: raystack/chronicle
Length of output: 44
🏁 Script executed:
head -50 packages/chronicle/src/lib/source.tsRepository: 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 tsRepository: 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 -5Repository: 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 -10Repository: 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 -20Repository: 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 -20Repository: raystack/chronicle
Length of output: 44
🏁 Script executed:
# Check if fumadocs-core is in package.json
rg 'fumadocs-core' packages/chronicle/package.jsonRepository: raystack/chronicle
Length of output: 93
🏁 Script executed:
# Also check root package.json
rg 'fumadocs-core' package.jsonRepository: 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:
- 1: https://www.mintlify.com/fuma-nama/fumadocs/api/core/page-tree
- 2: https://www.fumadocs.dev/docs/headless/page-tree
- 3: https://www.fumadocs.dev/docs/headless/page-conventions
- 4: https://www.mintlify.com/fuma-nama/fumadocs/api/core/source
- 5: https://www.fumadocs.dev/docs/headless/utils/page-tree
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 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; | ||
| } |
There was a problem hiding this comment.
🧩 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 -120Repository: raystack/chronicle
Length of output: 44
🏁 Script executed:
cat -n packages/chronicle/src/pages/DocsPage.tsx | head -60Repository: 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 3Repository: raystack/chronicle
Length of output: 90
🏁 Script executed:
rg -n "findFolderFirstPage" packages/chronicle --type=ts --type=tsx -B 5 -A 5Repository: raystack/chronicle
Length of output: 90
🏁 Script executed:
rg -n "type Node|interface Node" packages/chronicle --type=ts -A 5Repository: raystack/chronicle
Length of output: 44
🏁 Script executed:
cat package.json | grep -A 5 -B 5 fumadocsRepository: 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 -20Repository: raystack/chronicle
Length of output: 44
🏁 Script executed:
cat packages/chronicle/package.json | grep -E "name|fumadocs|dependencies" -A 30Repository: raystack/chronicle
Length of output: 2271
🏁 Script executed:
cat package.json | grep -E "fumadocs" -B 2 -A 2Repository: 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 2Repository: 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:
- 1: https://www.mintlify.com/fuma-nama/fumadocs/api/core/page-tree
- 2: https://www.mintlify.com/fuma-nama/fumadocs/concepts/page-tree
- 3: https://www.fumadocs.dev/docs/headless/page-tree
- 4: https://www.fumadocs.dev/docs/page-conventions
- 5: https://fumadocs.vercel.app/docs/headless/page-conventions
- 6: https://www.fumadocs.dev/docs/headless/utils/page-tree
🏁 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 1Repository: 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 3Repository: 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.
| 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 />; |
There was a problem hiding this comment.
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.
| 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.
| label: z.string().min(1), | ||
| description: z.string().optional(), | ||
| icon: z.string().optional(), | ||
| index_page: z.string().optional(), |
There was a problem hiding this comment.
🛠️ 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.
Summary
orderfield tometa.jsonfor sorting folders in sidebar/docs/guides) redirects to its first sorted child pageUsage
Test plan
ordertometa.jsonfiles — sidebar respects orderorderappears after ordered foldersorderfrontmatter still workstitlefrommeta.json🤖 Generated with Claude Code