Files
Ben Sabic 927d0dbd7d docs: add cross-link card sections and page-level SEO metadata (#804)
Many docs pages are orphaned: nothing links to them apart from the
sidebar, so readers and crawlers rarely find them. This PR gives every
docs page a Read more section with four cards at the bottom of the
article, above the prev/next footer.

Cards are picked deterministically in lib/read-more.ts: the page's
related frontmatter first, then prerequisites, then siblings from the
same sidebar section, then the rest of the page tree, so every page
always fills all four slots. Card titles and descriptions come from the
target page's own frontmatter, nothing is duplicated. The section is
injected through the MDX wrapper slot in the docs route, so it applies
to all pages without touching content.

To make the links topical rather than positional, 26 pages get related
frontmatter additions. The 20 pages that no other page referenced (all
ten api/ pages among them) now each have at least one inbound link,
generally pairing guides with their API reference and back. The bundled
copy of create-chat-sdk.mdx is synced to keep the byte-match test green.

Official adapter pages get the same treatment with a More adapters
section: same-type adapters first (platform or state, using the catalog
order), topped up from the other official group. Vendor-official and
community adapters are never shown, and their pages don't render the
section. It reuses AdapterCard, so logos and package names match the
listing page.

Two small SEO fixes ride along. JSON-LD was allowlisted to three docs
pages; the allowlist is gone, so all 45 now emit HowTo or TechArticle
plus a BreadcrumbList. Docs and adapter detail pages also emit canonical
URLs now, resolved against the existing metadataBase.

Verified against the production build: all 45 docs pages and all 19
official adapter pages render exactly four cards, no page is left
unreferenced, canonicals and JSON-LD are present everywhere, and pnpm
validate passes. Docs-only, so no changeset.

---------

Signed-off-by: Ben Sabic <bensabic@users.noreply.github.com>
Co-authored-by: Ben Sabic <bensabic@users.noreply.github.com>
2026-08-11 08:12:05 +10:00

119 lines
3.5 KiB
TypeScript

import type {
GeistdocsSourceBundle,
GeistdocsSourcePageData,
} from "@vercel/geistdocs/source";
import { flattenTree, type Node, type Root } from "fumadocs-core/page-tree";
export type DocsSource = GeistdocsSourceBundle["source"];
export type LoaderPage = NonNullable<ReturnType<DocsSource["getPage"]>>;
/**
* Loader pages with the geistdocs frontmatter fields (`related`,
* `prerequisites`, `internal`, ...) that the generic fumadocs loader type
* erases.
*/
export type DocsPage = Omit<LoaderPage, "data"> & {
data: LoaderPage["data"] & GeistdocsSourcePageData;
};
const CARD_COUNT = 4;
/**
* Splits the page tree into "runs": contiguous groups of pages bounded by
* meta.json separators (e.g. `---Usage---`) or folder boundaries. Pages in
* the same run are treated as section siblings.
*/
export const collectRuns = (root: Root): string[][] => {
const runs: string[][] = [];
const walk = (children: Node[], indexUrl?: string) => {
let current: string[] = indexUrl === undefined ? [] : [indexUrl];
const closeRun = () => {
if (current.length > 0) {
runs.push(current);
}
current = [];
};
for (const node of children) {
if (node.type === "page") {
current.push(node.url);
} else if (node.type === "separator") {
closeRun();
} else {
walk(node.children, node.index?.url);
}
}
closeRun();
};
walk(root.children);
return runs;
};
/** Returns `urls` reordered to start after `currentUrl`, wrapping around. */
export const rotateAfter = (urls: string[], currentUrl: string): string[] => {
const index = urls.indexOf(currentUrl);
if (index === -1) {
return urls;
}
return [...urls.slice(index + 1), ...urls.slice(0, index)];
};
/**
* Picks the pages to feature in a page's "Read more" section, in priority
* order: `related` frontmatter, then `prerequisites`, then section siblings
* (same meta.json run), then the rest of the page tree. Deterministic, and
* always fills all slots as long as enough pages exist.
*/
export const selectReadMore = (
source: DocsSource,
page: LoaderPage,
count = CARD_COUNT
): DocsPage[] => {
const lang = page.locale;
const byUrl = new Map<string, DocsPage>();
for (const candidate of source.getPages(lang) as DocsPage[]) {
if (!candidate.data.internal) {
byUrl.set(candidate.url, candidate);
}
}
const selected: DocsPage[] = [];
const seen = new Set<string>([page.url]);
const add = (url: string) => {
const candidate = byUrl.get(url);
if (!candidate || seen.has(url) || selected.length >= count) {
return;
}
seen.add(url);
selected.push(candidate);
};
const data = page.data as DocsPage["data"];
const curated = [...(data.related ?? []), ...(data.prerequisites ?? [])];
for (const url of curated) {
if (
!byUrl.has(url) &&
url.startsWith("/docs") &&
process.env.NODE_ENV === "development"
) {
// biome-ignore lint/suspicious/noConsole: dev-only authoring hint
console.warn(`read-more: ${page.url} references unknown page ${url}`);
}
add(url);
}
if (selected.length < count) {
const tree = source.getPageTree(lang);
const siblings =
collectRuns(tree).find((run) => run.includes(page.url)) ?? [];
for (const url of rotateAfter(siblings, page.url)) {
add(url);
}
const allUrls = flattenTree(tree.children).map((item) => item.url);
for (const url of rotateAfter(allUrls, page.url)) {
add(url);
}
}
return selected;
};