Files
Pranay Prakash 8a872529fe docs: make /worlds the canonical home for World docs (#2934)
* docs: make /worlds the canonical home for World docs

The world pages (Local/Postgres/Vercel) and Building a World were
duplicated inside the v4 and v5 docs trees while /worlds/[id] rendered
the v4 copy — hiding v5-only content like multi-region and leaving two
diverging sources of truth.

- Move world docs to an unversioned docs/content/worlds/ collection
  (based on the v5 copies, with inline 4.x callouts for factory naming
  and 5.x-only env vars), rendered at /worlds/*
- Add /worlds/building-a-world; flatten the docs Deploying section to a
  single intro page and drop its Rocket icon
- Point every link, frontmatter ref, and worlds-manifest docs field at
  /worlds/*; add redirects for the removed v5 and building-a-world URLs
- Keep world docs on agent-facing surfaces: search, llms.txt,
  sitemap.md/.xml, and .md exports now serve the worlds collection
- Extend the docs link linter to validate worlds pages (with heading
  anchors) and their outgoing links

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Pranay Prakash <pranay.gp@gmail.com>

* docs: version the world docs like the docs trees (v4/v5 switcher)

Instead of a single unversioned copy, world docs now follow the same
versioning strategy as the docs pages: content/worlds/v4 is served at
/worlds/* (current) and content/worlds/v5 at /v5/worlds/*, restoring the
original per-version content. Each world detail page (and Building a
World) renders the docs version switcher — the worlds listing page has
no natural home for it, so it lives on the world pages themselves.

- Render-time href rewriting on v5 pages now covers /worlds/... links
  (shared rewriteHrefForVersion helper, also used by the v5 docs and
  cookbook routes), and the markdown-export rewrite does the same
- v5 world pages are noindexed with a canonical to /worlds/<id>;
  community worlds stay unversioned (/v5/worlds/<id> redirects)
- /v5/docs/deploying/world/* redirects now land on /v5/worlds/*;
  /v5/worlds and /v5/worlds/compare redirect to the unversioned pages
- Link linter models the versioned worlds URL spaces (v5 pages resolve
  /worlds hrefs against the v5 collection); sitemap.md and the .md
  export routes cover /v5/worlds/*

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Pranay Prakash <pranay.gp@gmail.com>

* docs: fix v4 multi-region anchor and tighten version-prefix matching

Address PR review:
- The v4 Deploying page linked /worlds/vercel#multi-region, but the
  Multi-region section only exists on the v5 world page; use the
  explicit cross-version /v5/worlds/vercel#multi-region link (this was
  the Docs Links CI failure)
- rewriteHrefForVersion now uses the boundary-checked hasPathPrefix
  (shared leaf module lib/geistdocs/path-prefix.ts, also used by
  source.ts) instead of bare startsWith
- buildVersionUrl's shared-route fast path is segment-based rather than
  substring includes()

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Pranay Prakash <pranay.gp@gmail.com>

---------

Signed-off-by: Pranay Prakash <pranay.gp@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 17:15:07 +07:00

336 lines
10 KiB
TypeScript

import {
createSource,
createVersionedSources,
type GeistdocsSourceBundle,
} from '@vercel/geistdocs/source';
import type { Node, Root } from 'fumadocs-core/page-tree';
import { v4docs, v5docs, worldsV4Docs, worldsV5Docs } from '@/.source/server';
import { config } from './config';
import { hasPathPrefix, replacePathPrefix } from './path-prefix';
import { resolveSectionChildren } from './section-children';
type Source = GeistdocsSourceBundle['source'];
type Page = NonNullable<ReturnType<Source['getPage']>>;
const COOKBOOK_DOCS_PREFIX = '/docs/cookbook';
const DOCS_PREFIX = '/docs';
const WORLDS_PREFIX = '/worlds';
const LOCAL_DOCS_LINK_TARGET_RE =
/(\]\(|\[[^\]\n]+\]:\s*|(?:href|src)=["'])(\/(?:docs|worlds)(?:[^\s)"']*)?)/g;
const rewriteLocalDocsUrlForVersion = (url: string, versionPrefix: string) => {
if (hasPathPrefix(url, COOKBOOK_DOCS_PREFIX)) {
return replacePathPrefix(
url,
COOKBOOK_DOCS_PREFIX,
`${versionPrefix}/cookbook`
);
}
if (versionPrefix && hasPathPrefix(url, DOCS_PREFIX)) {
return replacePathPrefix(url, DOCS_PREFIX, `${versionPrefix}/docs`);
}
// World docs are versioned like the docs trees (/worlds vs /v5/worlds), so
// links authored against the raw /worlds/... space get the same treatment.
if (versionPrefix && hasPathPrefix(url, WORLDS_PREFIX)) {
return replacePathPrefix(url, WORLDS_PREFIX, `${versionPrefix}/worlds`);
}
return url;
};
const rewriteCookbookUrlForVersion = (url: string, versionPrefix: string) =>
rewriteLocalDocsUrlForVersion(url, versionPrefix);
const rewriteDocsUrlsForVersion = (text: string, versionPrefix: string) =>
text.replace(LOCAL_DOCS_LINK_TARGET_RE, (_match, prefix, url) => {
return `${prefix}${rewriteLocalDocsUrlForVersion(url, versionPrefix)}`;
});
const isCookbookPage = (page: Pick<Page, 'url'>) =>
page.url === '/docs/cookbook' || page.url.startsWith('/docs/cookbook/');
const withUrl = (page: Page, url: string): Page => ({ ...page, url });
// Matches the `<AutoCards />` placeholder (self-closing or paired) so the
// markdown export can substitute the rendered card list. The component itself
// only renders in the React tree, so without this the agent-facing markdown
// (llms.txt, .md routes, copy-page) would lose every child link.
const AUTO_CARDS_RE = /<AutoCards\b[^>]*?(?:\/>|>[\s\S]*?<\/AutoCards>)/g;
const asText = (value: unknown): string =>
typeof value === 'string' ? value : '';
const isCookbookFolder = (node: Node): boolean =>
node.type === 'folder' &&
(node.index?.url?.startsWith(COOKBOOK_DOCS_PREFIX) ?? false);
/**
* Page tree for a version's markdown export: the version's own source tree
* (raw `/docs/...` URL space, matching the pre-transform markdown) with
* cookbook nodes stripped. Resolved lazily so the transform closures can
* reference `versionedSources` after it is initialized.
*/
const getMarkdownTree = (versionId: 'v4' | 'v5'): Root => {
const lang = config.defaultLanguage ?? 'en';
const fullTree = versionedSources.byId[versionId].source.pageTree[lang];
return {
...fullTree,
children: fullTree.children.filter((node) => !isCookbookFolder(node)),
};
};
/**
* Render the `<Cards>`/`<Card>` JSX a section landing page would have
* contained by hand, derived from the page tree. Matching the existing
* serialized format keeps the markdown export consistent across converted and
* unconverted pages. Runs before the version URL rewrite so the inserted
* `href`s get mapped into the version's public URL space with everything else.
*/
const expandAutoCards = (
markdown: string,
versionId: 'v4' | 'v5',
sectionUrl: string
): string =>
// `.replace` is a no-op when there's no placeholder, and the replacer only
// walks the tree on an actual match.
markdown.replace(AUTO_CARDS_RE, () => {
const cards = resolveSectionChildren(getMarkdownTree(versionId), sectionUrl)
.map((child) => {
const title = asText(child.title);
const description = asText(child.description);
const open = `<Card href="${child.url}" title="${title}">`;
return description ? `${open}${description}</Card>` : `${open}</Card>`;
})
.join('\n');
return `<Cards>\n${cards}\n</Cards>`;
});
const versionedSources = createVersionedSources({
config,
current: 'v4',
versions: [
{
id: 'v4',
label: 'v4 (Latest)',
docs: v4docs,
baseUrl: '/docs',
markdown: {
transform: (markdown, { page }) =>
rewriteDocsUrlsForVersion(
expandAutoCards(markdown, 'v4', page.url),
''
),
},
},
{
id: 'v5',
label: 'v5 (Pre-release)',
docs: v5docs,
baseUrl: '/docs',
routePrefix: '/v5',
markdown: {
transform: (markdown, { page }) =>
rewriteDocsUrlsForVersion(
expandAutoCards(markdown, 'v5', page.url),
'/v5'
),
},
},
],
});
const createDocsRouteSource = (
bundle: GeistdocsSourceBundle,
options: { id: string; label: string; versionPrefix?: string }
): GeistdocsSourceBundle => {
const { id, label, versionPrefix = '' } = options;
const baseSource = bundle.source;
const mapPage = (page: Page) =>
versionPrefix ? withUrl(page, `${versionPrefix}${page.url}`) : page;
return {
...bundle,
id,
label,
baseUrl: `${versionPrefix}/docs`,
source: {
...baseSource,
getPage: ((slug?: string[], lang?: string) => {
if (slug?.[0] === 'cookbook') {
return undefined;
}
return baseSource.getPage(slug, lang);
}) as Source['getPage'],
getPages: ((lang?: string) =>
baseSource
.getPages(lang)
.filter((page) => !isCookbookPage(page))
.map(mapPage)) as Source['getPages'],
generateParams: ((...args: Parameters<Source['generateParams']>) =>
baseSource
.generateParams(...args)
.filter(
(params) =>
!(Array.isArray(params.slug) && params.slug[0] === 'cookbook')
)) as unknown as Source['generateParams'],
},
};
};
const resolveCookbookSlug = (slug?: string[]) => {
if (!slug?.length) {
return ['cookbook'];
}
return slug[0] === 'cookbook' ? slug : ['cookbook', ...slug];
};
const createCookbookRouteSource = (
bundle: GeistdocsSourceBundle,
options: { id: string; label: string; versionPrefix?: string }
): GeistdocsSourceBundle => {
const { id, label, versionPrefix = '' } = options;
const baseSource = bundle.source;
const mapPage = (page: Page) =>
withUrl(page, rewriteCookbookUrlForVersion(page.url, versionPrefix));
return {
...bundle,
id,
label,
baseUrl: `${versionPrefix}/cookbook`,
source: {
...baseSource,
getPage: ((slug?: string[], lang?: string) => {
const page = baseSource.getPage(resolveCookbookSlug(slug), lang);
return page && isCookbookPage(page) ? mapPage(page) : undefined;
}) as Source['getPage'],
getPages: ((lang?: string) =>
baseSource
.getPages(lang)
.filter(isCookbookPage)
.map(mapPage)) as Source['getPages'],
generateParams: ((...args: Parameters<Source['generateParams']>) =>
baseSource
.generateParams(...args)
.filter(
(params) =>
Array.isArray(params.slug) && params.slug[0] === 'cookbook'
)
.map((params) => ({
...params,
slug: Array.isArray(params.slug)
? params.slug.slice(1)
: params.slug,
}))) as unknown as Source['generateParams'],
},
};
};
export const versions = versionedSources;
export const geistdocsSource = createDocsRouteSource(versionedSources.current, {
id: 'docs',
label: 'Docs',
});
export const cookbookSource = createCookbookRouteSource(
versionedSources.current,
{
id: 'cookbook',
label: 'Cookbook',
}
);
export const v5GeistdocsSource = createDocsRouteSource(
versionedSources.byId.v5,
{
id: 'v5-docs',
label: 'v5 Docs',
versionPrefix: '/v5',
}
);
export const v5CookbookSource = createCookbookRouteSource(
versionedSources.byId.v5,
{
id: 'v5-cookbook',
label: 'v5 Cookbook',
versionPrefix: '/v5',
}
);
// Canonical World docs, versioned like the docs trees: v4 (current) is served
// at /worlds/*, v5 at /v5/worlds/*. These pages are rendered by the worlds app
// routes (not the docs layout), but the bundles are included in the source
// lists so they stay covered by search, llms.txt, sitemap(.md), and the
// markdown export routes.
export const worldsSourceBundle = createSource({
config,
docs: worldsV4Docs,
baseUrl: '/worlds',
id: 'worlds',
label: 'Worlds',
});
const v5WorldsBundleRaw = createSource({
config,
docs: worldsV5Docs,
baseUrl: '/worlds',
id: 'v5-worlds',
label: 'v5 Worlds',
markdown: {
// Match the v5 docs markdown export: links authored against the raw
// /docs/... and /worlds/... spaces are rewritten into the /v5 view.
transform: (markdown) => rewriteDocsUrlsForVersion(markdown, '/v5'),
},
});
// Route/list surfaces see the v5 worlds pages in their public /v5/worlds/...
// URL space (the raw loader keeps /worlds/... URLs, mirroring how the v5 docs
// source is wrapped by createDocsRouteSource).
export const v5WorldsSourceBundle: GeistdocsSourceBundle = {
...v5WorldsBundleRaw,
baseUrl: '/v5/worlds',
source: {
...v5WorldsBundleRaw.source,
getPage: ((slug?: string[], lang?: string) => {
const page = v5WorldsBundleRaw.source.getPage(slug, lang);
return page ? withUrl(page, `/v5${page.url}`) : undefined;
}) as Source['getPage'],
getPages: ((lang?: string) =>
v5WorldsBundleRaw.source
.getPages(lang)
.map((page) => withUrl(page, `/v5${page.url}`))) as Source['getPages'],
},
};
export const worldsSource = worldsSourceBundle.source;
export const v5WorldsSource = v5WorldsBundleRaw.source;
export const currentSources = [
geistdocsSource,
cookbookSource,
worldsSourceBundle,
];
export const allSources = [
geistdocsSource,
cookbookSource,
v5GeistdocsSource,
v5CookbookSource,
worldsSourceBundle,
v5WorldsSourceBundle,
];
export const source = versionedSources.current.source;
export const v5Source = versionedSources.byId.v5.source;
export const getPageImage = versionedSources.current.getPageImage;
export const getLLMText = versionedSources.current.getPageMarkdown;