Skip to content

Commit afcc7e4

Browse files
authored
feat(shell-docs): add sitemap, robots, and per-framework self-canonical metadata (CopilotKit#4703)
## Summary Phase 3 of the docs.copilotkit.ai cutover. Surfaces a complete sitemap, basic robots config, and per-framework self-canonical metadata so every URL variant is indexed under its own canonical instead of collapsing onto a single root. - **sitemap.ts** — emits one entry per (root URL × framework variant) pair plus reference, AG-UI, and per-framework override pages. `lastModified` resolves from MDX frontmatter \`lastmod\` first, then file mtime, then \`new Date()\`. Strips Next.js route-group \`(name)\` segments and trailing \`/index\` so URLs match what the routers actually serve. ~2,250 entries. - **robots.ts** — allow all, disallow \`/api/\`, sitemap pointer at \`\${NEXT_PUBLIC_BASE_URL}/sitemap.xml\`. - **sitemap-helpers.ts** — shared MDX walking + base-URL resolution. - **generateMetadata()** added to the four catch-all docs routes (\`[[...slug]]\`, \`[framework]/[[...slug]]\`, \`reference/[...slug]\`, \`ag-ui/[[...slug]]\`). Each sets \`alternates.canonical\` to the page's own full URL — per-framework self-canonical, not root canonical. So \`/langgraph-python/quickstart\` declares itself canonical, \`/agno/quickstart\` declares itself canonical, and the bare \`/quickstart\` declares itself canonical too. - **.env.example** — documents \`NEXT_PUBLIC_BASE_URL\` and \`NEXT_PUBLIC_SHELL_URL\`. Extended the existing comment in \`next.config.ts\` with the new consumers. ## Test plan - [x] \`npm run build\` from \`showcase/shell-docs/\` passes; route table shows \`/sitemap.xml\` and \`/robots.txt\` as static. - [x] \`curl http://localhost:3099/sitemap.xml\` returns valid XML; 2,254 \`<url>\` entries covering bare unscoped, framework-scoped, reference, and AG-UI URLs; no \`(other)\` route-group leakage; no trailing \`/index\` artifacts. - [x] \`curl http://localhost:3099/robots.txt\` returns the expected User-Agent / Allow / Disallow / Sitemap config. - [x] \`/shared-state\` HTML contains \`<link rel="canonical" href=".../shared-state">\`. - [x] \`/langgraph-python/quickstart\` HTML contains \`<link rel="canonical" href=".../langgraph-python/quickstart">\` (NOT pointing at the bare \`/quickstart\`). - [x] \`/agno/quickstart\` and \`/reference/components/CopilotChat\` and \`/ag-ui/concepts/architecture\` all self-canonical.
2 parents a6a74c5 + aa65c89 commit afcc7e4

9 files changed

Lines changed: 415 additions & 0 deletions

File tree

showcase/shell-docs/.env.example

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# shell-docs environment variables.
2+
#
3+
# Both NEXT_PUBLIC_* values below are required for `next build`
4+
# (next.config.ts throws when either is missing during a production
5+
# build). In dev, missing values fall back to localhost defaults so
6+
# you can run `next dev` without a .env.local.
7+
8+
# Canonical base URL used by sitemap.ts, robots.ts, and the per-page
9+
# canonical metadata. In production this points at the docs host so
10+
# crawlers index every framework variant at its self-canonical URL.
11+
NEXT_PUBLIC_BASE_URL=https://docs.copilotkit.ai
12+
13+
# URL of the showcase shell host, which owns /integrations and /matrix.
14+
# Used by the top-nav cross-host links and InlineDemo. Replace with your
15+
# deployment's shell host.
16+
NEXT_PUBLIC_SHELL_URL=https://www.copilotkit.ai

showcase/shell-docs/next.config.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,13 @@ import type { NextConfig } from "next";
44
// because of the NEXT_PUBLIC_ prefix. Do NOT re-declare it in an `env` block —
55
// doing so bakes the build-time value into server code and overrides runtime env.
66
//
7+
// Consumers in production: src/app/sitemap.ts, src/app/robots.ts, and the
8+
// per-page `generateMetadata()` canonical URLs in the catch-all routes
9+
// (src/app/[[...slug]]/page.tsx, src/app/[framework]/[[...slug]]/page.tsx,
10+
// src/app/reference/[...slug]/page.tsx, src/app/ag-ui/[[...slug]]/page.tsx).
11+
// All read it through `getBaseUrl()` in src/lib/sitemap-helpers.ts, which
12+
// falls back to https://docs.copilotkit.ai when unset.
13+
//
714
// Fail fast during an actual `next build` if the variable is missing, so we
815
// never ship broken absolute URLs. Other invocations that also load this
916
// config (e.g. `next lint`, `next dev`) only warn, because failing them on a

showcase/shell-docs/src/app/[[...slug]]/page.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
// the user chooses one — code without a backend context is incomplete.
88

99
import React from "react";
10+
import type { Metadata } from "next";
1011
import Link from "next/link";
1112
import { DocsLandingNext } from "@/components/docs-landing-next";
1213
import { SidebarFrameworkSelector } from "@/components/sidebar-framework-selector";
@@ -15,6 +16,25 @@ import { SidebarNav } from "@/components/sidebar-nav";
1516
import { UnscopedDocsPage } from "@/components/unscoped-docs-page";
1617
import { CONTENT_DIR, buildNavTree } from "@/lib/docs-render";
1718
import type { NavNode } from "@/lib/docs-render";
19+
import { getBaseUrl } from "@/lib/sitemap-helpers";
20+
21+
// Per-framework self-canonical: each variant of a doc page declares
22+
// itself canonical so search engines index every framework's quickstart
23+
// (etc.) at its own URL rather than collapsing them all onto the bare
24+
// /quickstart. Done at the page level so the metadata depends on params.
25+
export async function generateMetadata({
26+
params,
27+
}: {
28+
params: Promise<{ slug?: string[] }>;
29+
}): Promise<Metadata> {
30+
const { slug } = await params;
31+
const slugPath = slug && slug.length > 0 ? `/${slug.join("/")}` : "";
32+
return {
33+
alternates: {
34+
canonical: `${getBaseUrl()}${slugPath}`,
35+
},
36+
};
37+
}
1838

1939
function DocsOverview() {
2040
const navTree = buildNavTree(CONTENT_DIR);

showcase/shell-docs/src/app/[framework]/[[...slug]]/page.tsx

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
// correctly even though Next.js routes them here before [[...slug]].
1616

1717
import React from "react";
18+
import type { Metadata } from "next";
1819
import { notFound, redirect } from "next/navigation";
1920
import Link from "next/link";
2021
import { DocsLandingNext } from "@/components/docs-landing-next";
@@ -32,9 +33,31 @@ import {
3233
import type { NavNode } from "@/lib/docs-render";
3334
import { getDocsFolder, getIntegration, getIntegrations } from "@/lib/registry";
3435
import type { Integration } from "@/lib/registry";
36+
import { getBaseUrl } from "@/lib/sitemap-helpers";
3537
import { RESERVED_ROUTE_SLUGS } from "@/app/layout";
3638
import demoContent from "@/data/demo-content.json";
3739

40+
// Per-framework self-canonical: /<framework>/<slug> declares itself
41+
// canonical (NOT the bare /<slug>) so search engines index each
42+
// framework variant at its own URL. When the URL's first segment
43+
// doesn't match a registered integration, the route falls through to
44+
// UnscopedDocsPage but the canonical still points at the same URL —
45+
// the page's identity is defined by its URL, not the resolution
46+
// strategy used to render it.
47+
export async function generateMetadata({
48+
params,
49+
}: {
50+
params: Promise<{ framework: string; slug?: string[] }>;
51+
}): Promise<Metadata> {
52+
const { framework, slug } = await params;
53+
const slugTail = slug && slug.length > 0 ? `/${slug.join("/")}` : "";
54+
return {
55+
alternates: {
56+
canonical: `${getBaseUrl()}/${framework}${slugTail}`,
57+
},
58+
};
59+
}
60+
3861
export async function generateStaticParams() {
3962
// Rely on the catch-all's dynamic behaviour at runtime; returning an
4063
// empty array keeps build times short since there are ~17 frameworks

showcase/shell-docs/src/app/ag-ui/[[...slug]]/page.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import React from "react";
22
import fs from "fs";
33
import path from "path";
44
import matter from "gray-matter";
5+
import type { Metadata } from "next";
56
import { notFound } from "next/navigation";
67
import { MDXRemote } from "next-mdx-remote/rsc";
78
import remarkGfm from "remark-gfm";
@@ -11,6 +12,23 @@ import { SidebarNav } from "@/components/sidebar-nav";
1112
import { docsComponents } from "@/lib/mdx-registry";
1213
import { stripLeadingImports } from "@/lib/docs-render";
1314
import { resolveWithinDir, safeReadFileSync } from "@/lib/safe-fs";
15+
import { getBaseUrl } from "@/lib/sitemap-helpers";
16+
17+
// Self-canonical for /ag-ui[/<slug>]. AG-UI pages aren't per-framework
18+
// but get a canonical for parity with the rest of the docs surface.
19+
export async function generateMetadata({
20+
params,
21+
}: {
22+
params: Promise<{ slug?: string[] }>;
23+
}): Promise<Metadata> {
24+
const { slug } = await params;
25+
const slugTail = slug && slug.length > 0 ? `/${slug.join("/")}` : "";
26+
return {
27+
alternates: {
28+
canonical: `${getBaseUrl()}/ag-ui${slugTail}`,
29+
},
30+
};
31+
}
1432

1533
const CONTENT_DIR = path.join(process.cwd(), "src/content/ag-ui");
1634

showcase/shell-docs/src/app/reference/[...slug]/page.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { Metadata } from "next";
12
import Link from "next/link";
23
import { notFound } from "next/navigation";
34
import { MDXRemote } from "next-mdx-remote/rsc";
@@ -19,6 +20,23 @@ import {
1920
} from "@/lib/reference-items";
2021
import { stripLeadingImports } from "@/lib/docs-render";
2122
import { safeReadFileSync } from "@/lib/safe-fs";
23+
import { getBaseUrl } from "@/lib/sitemap-helpers";
24+
25+
// Self-canonical for /reference/<slug>. Reference pages are not
26+
// per-framework, but we still emit a canonical so the production URL
27+
// is unambiguous and any future host aliases can't fragment indexing.
28+
export async function generateMetadata({
29+
params,
30+
}: {
31+
params: Promise<{ slug: string[] }>;
32+
}): Promise<Metadata> {
33+
const { slug } = await params;
34+
return {
35+
alternates: {
36+
canonical: `${getBaseUrl()}/reference/${slug.join("/")}`,
37+
},
38+
};
39+
}
2240

2341
// next-mdx-remote components map
2442
const mdxComponents = {
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
// Next.js App Router robots config. Allows all user-agents across the
2+
// public docs surface, blocks the internal `/api/` routes, and points
3+
// crawlers at sitemap.xml so they can discover every framework variant.
4+
5+
import type { MetadataRoute } from "next";
6+
import { getBaseUrl } from "@/lib/sitemap-helpers";
7+
8+
export default function robots(): MetadataRoute.Robots {
9+
const baseUrl = getBaseUrl();
10+
return {
11+
rules: [
12+
{
13+
userAgent: "*",
14+
allow: "/",
15+
disallow: "/api/",
16+
},
17+
],
18+
sitemap: `${baseUrl}/sitemap.xml`,
19+
};
20+
}
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
// Next.js App Router sitemap. Emits one entry per (root URL, framework
2+
// variant) pair so search engines can crawl every framework-scoped doc
3+
// at its self-canonical URL.
4+
//
5+
// The expansion is:
6+
// - Root URL (/)
7+
// - Bare unscoped pages (/<slug>) from src/content/docs/**.mdx
8+
// - Framework-scoped pages (/<fw>/<slug>) bare slugs × every registered
9+
// integration, plus per-framework
10+
// override pages under
11+
// src/content/docs/integrations/
12+
// - Reference (/reference/<slug>) from src/content/reference/
13+
// - AG-UI (/ag-ui/<slug>) from src/content/ag-ui/
14+
//
15+
// Each entry's `lastModified` is resolved via resolveLastModified —
16+
// frontmatter `lastmod` first, then file mtime, then `new Date()`.
17+
18+
import type { MetadataRoute } from "next";
19+
import {
20+
getAgUiPages,
21+
getBareDocsPages,
22+
getBaseUrl,
23+
getFrameworkOverridePages,
24+
getReferencePages,
25+
resolveLastModified,
26+
} from "@/lib/sitemap-helpers";
27+
import { getDocsFolder, getIntegrations } from "@/lib/registry";
28+
29+
export default function sitemap(): MetadataRoute.Sitemap {
30+
const baseUrl = getBaseUrl();
31+
const now = new Date();
32+
const entries: MetadataRoute.Sitemap = [];
33+
34+
// 1. Root / overview.
35+
entries.push({
36+
url: `${baseUrl}/`,
37+
lastModified: now,
38+
});
39+
40+
// 2. Bare unscoped docs and 3. framework-scoped variants. Each bare
41+
// slug generates one bare entry plus N framework-scoped entries — one
42+
// per registered integration. Per-framework override pages are added
43+
// alongside (deduped against the bare × framework cross-product).
44+
const bareDocs = getBareDocsPages();
45+
const integrations = getIntegrations();
46+
47+
// Track every framework-scoped URL we've already emitted so the
48+
// override loop below can skip duplicates without scanning the array.
49+
const seenFrameworkUrls = new Set<string>();
50+
51+
for (const { slug, filePath } of bareDocs) {
52+
const lastModified = resolveLastModified(filePath);
53+
entries.push({
54+
url: `${baseUrl}/${slug}`,
55+
lastModified,
56+
});
57+
for (const integration of integrations) {
58+
const url = `${baseUrl}/${integration.slug}/${slug}`;
59+
seenFrameworkUrls.add(url);
60+
entries.push({ url, lastModified });
61+
}
62+
}
63+
64+
// Framework landing pages: /<framework> on its own.
65+
for (const integration of integrations) {
66+
const url = `${baseUrl}/${integration.slug}`;
67+
seenFrameworkUrls.add(url);
68+
entries.push({ url, lastModified: now });
69+
}
70+
71+
// Per-framework override pages — topics that only exist under
72+
// integrations/<folder>/. These are addressable as /<framework>/<slug>
73+
// and aren't covered by the bare × framework cross-product above.
74+
for (const integration of integrations) {
75+
const folder = getDocsFolder(integration.slug);
76+
for (const { slug, filePath } of getFrameworkOverridePages(folder)) {
77+
const url = `${baseUrl}/${integration.slug}/${slug}`;
78+
if (seenFrameworkUrls.has(url)) continue;
79+
seenFrameworkUrls.add(url);
80+
entries.push({
81+
url,
82+
lastModified: resolveLastModified(filePath),
83+
});
84+
}
85+
}
86+
87+
// 4. Reference docs.
88+
for (const { slug, filePath } of getReferencePages()) {
89+
entries.push({
90+
url: `${baseUrl}/reference/${slug}`,
91+
lastModified: resolveLastModified(filePath),
92+
});
93+
}
94+
// Reference index.
95+
entries.push({ url: `${baseUrl}/reference`, lastModified: now });
96+
97+
// 5. AG-UI.
98+
for (const { slug, filePath } of getAgUiPages()) {
99+
entries.push({
100+
url: `${baseUrl}/ag-ui/${slug}`,
101+
lastModified: resolveLastModified(filePath),
102+
});
103+
}
104+
// AG-UI overview landing.
105+
entries.push({ url: `${baseUrl}/ag-ui`, lastModified: now });
106+
107+
return entries;
108+
}

0 commit comments

Comments
 (0)