From 6b6640e145038fe41def747f84cd67d1c509e238 Mon Sep 17 00:00:00 2001 From: Ravi Suhag Date: Wed, 2 Sep 2026 17:56:43 -0500 Subject: [PATCH 01/10] fix: keep navigation inside the section it belongs to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A content directory is a section: its own folder, its own URL prefix, its own navigation. Four things were leaking across those boundaries or losing them. `filterPageTreeByContentDir` was called twice on the same tree — once by `entry-server` before serialising, once by `DocsLayout` on what it was handed — and the second pass had no way to tell an already narrowed tree from a wide one. It looked for a folder whose urls all start with the prefix, which on a narrowed tree matches the first sub-folder instead. So the docs site rendered an empty sidebar, the basic example showed only its `guides` folder, and a versioned site showed only its first content directory. Telling the two apart needs three signals, because url shapes alone are ambiguous: `root → [folder Docs (/docs/*)]` and `root → [folder guides (/docs/guides/*)]` look identical to a prefix test. A folder is the content directory when every url inside it belongs to the directory, when it holds every url in the tree that belongs to the directory, and when it has a page directly below the prefix rather than only pages nested deeper. A page sitting at the prefix settles it earlier: that is the directory's own page, so the tree is already its contents. `filterPageTreeByVersion` had the same double-call problem and now recognises an already narrowed tree too. Previous and next were chained across the whole site, so the last page of Docs offered the first page of Ops Guide — walking a reader out of the section they chose. Both implementations now chain per section, sharing `contentSectionPrefixes` and `sectionOf` so the server and the static build cannot drift apart. The fanfold header printed "DOCS / DOCS / GETTING STARTED": `getBreadcrumbItems` starts at the tree root, whose name is the section's own label. Any name that repeats the one before it is dropped, which also covers a folder whose index page carries the folder's title. Its workaround for the scoping bug goes with it — the filters are safe to apply twice now. --- .../src/cli/commands/static-generate.ts | 42 +++++++---- packages/chronicle/src/lib/source.ts | 27 +++++-- .../chronicle/src/lib/version-source.test.ts | 63 ++++++++++++++++ packages/chronicle/src/lib/version-source.ts | 74 ++++++++++++++++++- .../chronicle/src/themes/fanfold/Page.tsx | 37 ++++++---- 5 files changed, 207 insertions(+), 36 deletions(-) diff --git a/packages/chronicle/src/cli/commands/static-generate.ts b/packages/chronicle/src/cli/commands/static-generate.ts index 10760b2b..ee6e410e 100644 --- a/packages/chronicle/src/cli/commands/static-generate.ts +++ b/packages/chronicle/src/cli/commands/static-generate.ts @@ -22,7 +22,7 @@ import { buildLlmsTxt, type LlmsPage } from '@/lib/llms'; import { DEFAULT_WIDTH, DEFAULT_QUALITY, isLocalImage, isSvg, splitVersion } from '@/lib/image-utils'; import { isAnimatedImage } from '@/lib/image-animation'; import { getAssetVersion } from '@/lib/asset-version'; -import type { VersionContext } from '@/lib/version-source'; +import { contentSectionPrefixes, sectionOf, type VersionContext } from '@/lib/version-source'; import type { Frontmatter, PageNavLink } from '@/types'; import { buildAuthorIndex } from '@/lib/author-index'; import { normalizeAuthorList, resolveAuthors } from '@/lib/authors'; @@ -367,19 +367,33 @@ function flattenTreeUrls(tree: PageTreeRoot): { url: string; title: string }[] { return result; } -function computeNavigation(tree: PageTreeRoot): Map { +function computeNavigation( + tree: PageTreeRoot, + config: ChronicleConfig, +): Map { const navMap = new Map(); - const ordered = flattenTreeUrls(tree); - - for (let i = 0; i < ordered.length; i++) { - navMap.set(ordered[i].url, { - prev: i > 0 - ? { url: ordered[i - 1].url, title: ordered[i - 1].title } - : null, - next: i < ordered.length - 1 - ? { url: ordered[i + 1].url, title: ordered[i + 1].title } - : null, - }); + + // Chained per section, matching the dev server. See `getNavMap` in source.ts. + const prefixes = contentSectionPrefixes(config); + const bySection = new Map>(); + for (const entry of flattenTreeUrls(tree)) { + const key = sectionOf(entry.url, prefixes) ?? ''; + const group = bySection.get(key); + if (group) group.push(entry); + else bySection.set(key, [entry]); + } + + for (const ordered of bySection.values()) { + for (let i = 0; i < ordered.length; i++) { + navMap.set(ordered[i].url, { + prev: i > 0 + ? { url: ordered[i - 1].url, title: ordered[i - 1].title } + : null, + next: i < ordered.length - 1 + ? { url: ordered[i + 1].url, title: ordered[i + 1].title } + : null, + }); + } } return navMap; @@ -1064,7 +1078,7 @@ export async function generateStaticSite(options: StaticGenerateOptions): Promis const contentMirror = path.resolve(packageRoot, '.content'); const folderMeta = await scanFolderMeta(contentMirror, config); const tree = buildPageTree(pages, config, folderMeta); - const navMap = computeNavigation(tree); + const navMap = computeNavigation(tree, config); // Generate all static assets console.log(chalk.gray(' Generating page data files...')); diff --git a/packages/chronicle/src/lib/source.ts b/packages/chronicle/src/lib/source.ts index c377bd9a..6c58d291 100644 --- a/packages/chronicle/src/lib/source.ts +++ b/packages/chronicle/src/lib/source.ts @@ -19,9 +19,11 @@ import { loadConfig, } from './config'; import { + contentSectionPrefixes, filterPagesByVersion, filterPageTreeByVersion, resolveVersionFromUrl, + sectionOf, type VersionContext, } from './version-source'; import type { Frontmatter, PageNav, PageNavLink } from '@/types'; @@ -303,12 +305,27 @@ async function getNavMap(): Promise> { ? p.name : titleFromUrl(p.url) }); + + // Chain within a section, not across the whole site. The last page of "Docs" + // used to offer the first page of the next content directory as its next + // link, dropping the reader into a different audience's material. + const prefixes = contentSectionPrefixes(loadConfig()); + const bySection = new Map(); + for (const page of pages) { + const key = sectionOf(page.url, prefixes) ?? ''; + const group = bySection.get(key); + if (group) group.push(page); + else bySection.set(key, [page]); + } + const navMap = new Map(); - for (let i = 0; i < pages.length; i++) { - navMap.set(pages[i].url, { - prev: i > 0 ? toLink(pages[i - 1]) : null, - next: i < pages.length - 1 ? toLink(pages[i + 1]) : null - }); + for (const group of bySection.values()) { + for (let i = 0; i < group.length; i++) { + navMap.set(group[i].url, { + prev: i > 0 ? toLink(group[i - 1]) : null, + next: i < group.length - 1 ? toLink(group[i + 1]) : null + }); + } } cachedNavMap = navMap; return cachedNavMap; diff --git a/packages/chronicle/src/lib/version-source.test.ts b/packages/chronicle/src/lib/version-source.test.ts index 2953c104..e585f83c 100644 --- a/packages/chronicle/src/lib/version-source.test.ts +++ b/packages/chronicle/src/lib/version-source.test.ts @@ -126,6 +126,19 @@ describe('filterPageTreeByVersion', () => { ) expect(filtered.children).toEqual([]) }) + + test('leaves an already scoped tree alone', () => { + // `entry-server` scopes the tree before serialising it, and then a layout + // scopes what it is handed. The second pass used to mistake v1's first + // content folder for the version folder and drop the rest. + const scoped: Root = { name: 'root', children: v1Folder.children } + const filtered = filterPageTreeByVersion( + scoped, + { dir: 'v1', urlPrefix: '/v1' }, + config, + ) + expect(filtered.children).toEqual(v1Folder.children) + }) }) describe('filterPageTreeByContentDir', () => { @@ -160,4 +173,54 @@ describe('filterPageTreeByContentDir', () => { filterPageTreeByContentDir(tree, ctx, 'dev').children, ).toEqual(v1Dev.children) }) + + test('unwraps the content-dir folder on a single-content-dir site', () => { + // The whole tree is under /docs here, so an "every url matches the prefix" + // test would call it already scoped and leave the wrapper in place — its + // label then shows as a heading above every page in the sidebar. + const wrapped: Root = { + name: 'root', + children: [ + { + type: 'folder', + name: 'Docs', + index: page('/docs'), + children: [page('/docs/a'), page('/docs/b')], + } as Folder, + ], + } + const out = filterPageTreeByContentDir(wrapped, LATEST_CONTEXT, 'docs') + expect(out.children).toEqual([page('/docs/a'), page('/docs/b')]) + }) + + test('does not mistake a sub-folder for the content dir', () => { + // Already scoped, and its only child is a folder. `guides` sits at + // /docs/guides, not /docs, so it is not the wrapper. + const scoped: Root = { + name: 'root', + children: [folder('guides', [page('/docs/guides/a'), page('/docs/guides/b')])], + } + const out = filterPageTreeByContentDir(scoped, LATEST_CONTEXT, 'docs') + expect(out.children).toEqual(scoped.children) + }) + + test('leaves an already scoped flat tree alone', () => { + // A single content directory with no sub-folders: after `entry-server` + // scopes it the children are the pages themselves, and looking for a + // wrapping folder again found none and emptied the navigation. + const scoped: Root = { name: 'root', children: latestDocs.children } + const out = filterPageTreeByContentDir(scoped, LATEST_CONTEXT, 'docs') + expect(out.children).toEqual(latestDocs.children) + }) + + test('leaves an already scoped tree with sub-folders alone', () => { + // Same tree, but with sub-folders: the second pass used to match the first + // sub-folder — every url in it is under `/docs` — and show only its pages. + const scoped: Root = { + name: 'root', + children: [page('/docs'), folder('guides', [page('/docs/guides/a')])], + } + const out = filterPageTreeByContentDir(scoped, LATEST_CONTEXT, 'docs') + expect(out.children).toEqual(scoped.children) + }) }) diff --git a/packages/chronicle/src/lib/version-source.ts b/packages/chronicle/src/lib/version-source.ts index 1de0685b..5ddf6fec 100644 --- a/packages/chronicle/src/lib/version-source.ts +++ b/packages/chronicle/src/lib/version-source.ts @@ -1,5 +1,6 @@ import type { Folder, Node, Root } from 'fumadocs-core/page-tree' import type { ChronicleConfig } from '@/types' +import { getLatestContentRoots, getVersionContentRoots } from './config' export interface VersionContext { dir: string | null @@ -66,12 +67,30 @@ function nodeMatchesVersion( return urls.every((u) => !prefixes.some((pre) => isUnderPrefix(u, pre))) } +/** + * True when every page already in `tree` sits under `prefix`, so the tree has + * been narrowed to that prefix once already. + * + * Both filters below are called twice on the same tree: `entry-server` narrows + * it before serialising, and then a layout or theme narrows what it is handed. + * Without this check the second pass mistakes the first sub-folder for the + * thing it is looking for and returns only that folder's pages — or, for a flat + * directory with no sub-folder at all, returns nothing and empties the + * navigation. Recognising an already-narrowed tree makes both filters safe to + * apply as many times as callers like. + */ +function isAlreadyScoped(tree: Root, prefix: string): boolean { + const urls = tree.children.flatMap(nodeUrls) + return urls.length > 0 && urls.every((u) => isUnderPrefix(u, prefix)) +} + export function filterPageTreeByVersion( tree: Root, ctx: VersionContext, config: ChronicleConfig, ): Root { if (ctx.dir !== null) { + if (isAlreadyScoped(tree, ctx.urlPrefix)) return tree const versionFolder = tree.children.find( (n): n is Folder => n.type === 'folder' && nodeMatchesVersion(n, ctx, config), @@ -91,11 +110,60 @@ export function filterPageTreeByContentDir( ): Root { if (contentDir === null) return tree const expectedPrefix = `${ctx.urlPrefix}/${contentDir}` + + // A page sitting directly at or under the prefix is one of the content + // directory's own pages, so the tree is its contents, not a container of it. + const holdsOwnPages = tree.children.some( + (n) => n.type === 'page' && isUnderPrefix(n.url, expectedPrefix), + ) + if (holdsOwnPages) return tree + + // Otherwise look for the one folder that *is* this content directory. Three + // things have to hold, and a sub-folder — whose urls also all start with the + // prefix — fails the last two: + // 1. every url inside it belongs to the directory, + // 2. it holds every url in the tree that belongs to the directory, + // 3. it has a page of its own directly below the prefix, rather than only + // pages nested further down. + const depth = (url: string) => url.split('/').filter(Boolean).length + const prefixDepth = depth(expectedPrefix) + const underPrefix = tree.children + .flatMap(nodeUrls) + .filter((u) => isUnderPrefix(u, expectedPrefix)) const match = tree.children.find((n): n is Folder => { if (n.type !== 'folder') return false const urls = nodeUrls(n) - return urls.length > 0 && urls.every((u) => isUnderPrefix(u, expectedPrefix)) + if (urls.length === 0 || urls.length !== underPrefix.length) return false + if (!urls.every((u) => isUnderPrefix(u, expectedPrefix))) return false + return urls.some((u) => u === expectedPrefix || depth(u) === prefixDepth + 1) }) - if (!match) return { ...tree, children: [] } - return { ...tree, children: match.children } + if (match) return { ...tree, children: match.children } + + if (isAlreadyScoped(tree, expectedPrefix)) return tree + return { ...tree, children: [] } +} + +/** + * Every content section's URL prefix — `/docs`, `/dev`, `/v1/docs` — longest + * first, so `/v1/docs` is tested before `/v1` could ever shadow it. + */ +export function contentSectionPrefixes(config: ChronicleConfig): string[] { + const prefixes = [ + ...getLatestContentRoots(config).map((r) => r.urlPrefix), + ...(config.versions ?? []).flatMap((v) => + getVersionContentRoots(config, v.dir).map((r) => r.urlPrefix), + ), + ] + return prefixes.sort((a, b) => b.length - a.length) +} + +/** + * The section a page belongs to, or null if it sits outside every one. + * + * Previous and next links are chained inside a section rather than across the + * whole site: sections exist to keep separate audiences apart, so walking off + * the end of one into another undoes the point of having them. + */ +export function sectionOf(url: string, prefixes: string[]): string | null { + return prefixes.find((p) => isUnderPrefix(url, p)) ?? null } diff --git a/packages/chronicle/src/themes/fanfold/Page.tsx b/packages/chronicle/src/themes/fanfold/Page.tsx index 9ea0535a..5d44f8b5 100644 --- a/packages/chronicle/src/themes/fanfold/Page.tsx +++ b/packages/chronicle/src/themes/fanfold/Page.tsx @@ -81,20 +81,19 @@ export function Page({ page, config, tree }: ThemePageProps) { * The printed header reads as a report on one section, so the trail and the * page counter are both scoped to the content directory being read. * - * The tree may or may not already be scoped: `entry-server` unwraps it itself - * when a site has a single content directory. Scoping an unwrapped tree again - * silently returns its first sub-folder — or nothing, for a flat directory — - * which emptied the trail and dropped the counter. So the narrower tree is - * only taken when it still contains the page being rendered. + * The tree handed here may already be scoped — `entry-server` narrows it when + * a site has a single content directory — but both filters recognise that and + * hand such a tree back untouched. */ - const sectionTree = useMemo(() => { - const versioned = filterPageTreeByVersion(tree, version, config); - const scoped = filterPageTreeByContentDir(versioned, version, contentDir); - const holdsThisPage = flattenTree(scoped.children).some( - p => p.url === pathname - ); - return holdsThisPage ? scoped : versioned; - }, [tree, version, config, contentDir, pathname]); + const sectionTree = useMemo( + () => + filterPageTreeByContentDir( + filterPageTreeByVersion(tree, version, config), + version, + contentDir + ), + [tree, version, config, contentDir] + ); const shorts = useMemo( () => collectShortNames(sectionTree.children), @@ -119,7 +118,17 @@ export function Page({ page, config, tree }: ThemePageProps) { }, [sectionTree, pathname]); const title = page.frontmatter.title ?? ''; - const trail = [section, ...crumbs].filter(Boolean).join(' / '); + /** + * The trail leads with the section, then the crumbs. `getBreadcrumbItems` + * starts at the tree root, and that root's name is the section's own label, + * so the two met and the header read "DOCS / DOCS / GETTING STARTED". + * Dropping any name that repeats the one before it also covers a folder whose + * index page carries the folder's title. + */ + const trail = [section, ...crumbs] + .filter(Boolean) + .filter((name, i, all) => i === 0 || name !== all[i - 1]) + .join(' / '); /** * The lines under the trail. A page that states its own identifiers — the From aed1e2748a48884c000426e27bf393aad96cf908 Mon Sep 17 00:00:00 2001 From: Ravi Suhag Date: Wed, 2 Sep 2026 17:56:52 -0500 Subject: [PATCH 02/10] fix: give every page one title MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `RootHead` rendered the site title and every route then rendered its own, so two `` tags reached the document. A browser reads the first one, and `RootHead` renders before the page — so every tab in the site said "Chronicle" no matter which page was open. Dropped the one in `RootHead`, which keeps the site-level JSON-LD and nothing else. Every real route already renders `<Head>`; the two that did not were the 404 and the render-error page, which showed whatever title the previous page had left behind. Both name themselves now. --- packages/chronicle/src/pages/NotFound.tsx | 7 +++++++ packages/chronicle/src/pages/RenderError.tsx | 5 +++++ packages/chronicle/src/server/App.tsx | 21 +++++++++++--------- 3 files changed, 24 insertions(+), 9 deletions(-) diff --git a/packages/chronicle/src/pages/NotFound.tsx b/packages/chronicle/src/pages/NotFound.tsx index 7ae60c2a..e9dfe6e8 100644 --- a/packages/chronicle/src/pages/NotFound.tsx +++ b/packages/chronicle/src/pages/NotFound.tsx @@ -1,10 +1,17 @@ import { FileTextIcon } from '@/components/ui/icons'; import { EmptyState } from '@raystack/apsara'; +import { Head } from '@/lib/head'; +import { usePageContext } from '@/lib/page-context'; import styles from './NotFound.module.css'; export function NotFound() { + const { config } = usePageContext(); + return ( <div className={styles.emptyStateHost}> + {/* The page that was asked for renders no `<Head>` of its own, so + without this the tab would carry whatever the last page set. */} + <Head title='Page not found' config={config} /> <EmptyState icon={<FileTextIcon width={32} height={32} />} heading="404" diff --git a/packages/chronicle/src/pages/RenderError.tsx b/packages/chronicle/src/pages/RenderError.tsx index e797e18b..bd9fa813 100644 --- a/packages/chronicle/src/pages/RenderError.tsx +++ b/packages/chronicle/src/pages/RenderError.tsx @@ -1,10 +1,15 @@ import { WarningIcon } from '@/components/ui/icons'; import { EmptyState } from '@raystack/apsara'; +import { Head } from '@/lib/head'; +import { usePageContext } from '@/lib/page-context'; import styles from './NotFound.module.css'; export function RenderError({ message }: { message: string | null }) { + const { config } = usePageContext(); + return ( <div className={styles.emptyStateHost}> + <Head title='Failed to render page' config={config} /> <EmptyState icon={<WarningIcon width={32} height={32} />} heading="Failed to render page" diff --git a/packages/chronicle/src/server/App.tsx b/packages/chronicle/src/server/App.tsx index 667dd892..5cd92ce0 100644 --- a/packages/chronicle/src/server/App.tsx +++ b/packages/chronicle/src/server/App.tsx @@ -80,6 +80,12 @@ function PageFallback() { ); } +/** + * Site-wide head tags. The page title is deliberately not among them: every + * route renders its own `<Head>`, and a title here would be hoisted into the + * document ahead of that one. The browser reads the first `<title>` it finds, + * so the tab said "Chronicle" on every page instead of naming the page. + */ function RootHead({ config }: { config: ChronicleConfig }) { const siteJsonLd = config.url ? { @@ -91,15 +97,12 @@ function RootHead({ config }: { config: ChronicleConfig }) { } : null; + if (!siteJsonLd) return null; + return ( - <> - <title>{config.site.title} - {siteJsonLd && ( -