fs.existsSync(root));
- if (!docsRoot) {
- return '(API reference not yet generated — run npm run generate-api-docs)';
- }
+const API_DOCS: Record
= {
+ 'ag-ui': agUiApiDocs,
+ agent: agentApiDocs,
+ chat: chatApiDocs,
+ render: renderApiDocs,
+};
- const sections = fs.readdirSync(docsRoot)
- .sort()
- .map((library) => {
- const apiDocsPath = path.join(docsRoot, library, 'api', 'api-docs.json');
- if (!fs.existsSync(apiDocsPath)) return null;
- const raw = fs.readFileSync(apiDocsPath, 'utf8');
- return `### ${library}\n\n${raw}`;
- })
- .filter((section): section is string => section !== null);
+function loadApiDocs(): string {
+ const sections = Object.entries(API_DOCS)
+ .sort(([a], [b]) => a.localeCompare(b))
+ .map(([library, docs]) => `### ${library}\n\n${JSON.stringify(docs, null, 2)}`);
return sections.length > 0
? sections.join('\n\n')
@@ -29,8 +24,12 @@ function loadApiDocs(): string {
}
function loadAllPrompts(): string {
- const dir = path.join(process.cwd(), 'content', 'prompts');
- if (!fs.existsSync(dir)) return '(no prompt recipes found)';
+ const roots = [
+ path.join(process.cwd(), 'apps', 'website', 'content', 'prompts'),
+ path.join(process.cwd(), 'content', 'prompts'),
+ ];
+ const dir = roots.find((root) => fs.existsSync(root));
+ if (!dir) return '(no prompt recipes found)';
return fs.readdirSync(dir)
.filter((f) => f.endsWith('.md'))
.map((f) => {
diff --git a/apps/website/src/app/robots.ts b/apps/website/src/app/robots.ts
new file mode 100644
index 000000000..0cdfcb355
--- /dev/null
+++ b/apps/website/src/app/robots.ts
@@ -0,0 +1,12 @@
+import type { MetadataRoute } from 'next';
+import { getCanonicalUrl } from '../lib/site-metadata';
+
+export default function robots(): MetadataRoute.Robots {
+ return {
+ rules: {
+ userAgent: '*',
+ allow: '/',
+ },
+ sitemap: getCanonicalUrl('/sitemap.xml'),
+ };
+}
diff --git a/apps/website/src/app/sitemap.ts b/apps/website/src/app/sitemap.ts
new file mode 100644
index 000000000..94b3fc7bd
--- /dev/null
+++ b/apps/website/src/app/sitemap.ts
@@ -0,0 +1,13 @@
+import type { MetadataRoute } from 'next';
+import { getCanonicalUrl, getSitemapRoutes } from '../lib/site-metadata';
+
+export default function sitemap(): MetadataRoute.Sitemap {
+ const now = new Date();
+
+ return getSitemapRoutes().map((route) => ({
+ url: getCanonicalUrl(route),
+ lastModified: now,
+ changeFrequency: route.startsWith('/docs') ? 'weekly' : 'monthly',
+ priority: route === '/' ? 1 : route.startsWith('/docs') ? 0.8 : 0.7,
+ }));
+}
diff --git a/apps/website/src/components/docs/MdxRenderer.tsx b/apps/website/src/components/docs/MdxRenderer.tsx
index 4839db1aa..df306bb2f 100644
--- a/apps/website/src/components/docs/MdxRenderer.tsx
+++ b/apps/website/src/components/docs/MdxRenderer.tsx
@@ -25,6 +25,11 @@ const mdxComponents = {
ArchFlowDiagram,
FeatureChips,
pre: Pre,
+ table: ({ children, ...rest }: React.HTMLAttributes) => (
+
+ ),
h2: ({ id, children, ...rest }: React.HTMLAttributes) => (
{id ? # : null}
diff --git a/apps/website/src/lib/docs.spec.ts b/apps/website/src/lib/docs.spec.ts
index abb9811cb..6e193c165 100644
--- a/apps/website/src/lib/docs.spec.ts
+++ b/apps/website/src/lib/docs.spec.ts
@@ -1,6 +1,14 @@
import { describe, expect, it } from 'vitest';
import { getAllDocSlugs, getDocBySlug, getDocMetadata } from './docs';
import { allDocsPages } from './docs-config';
+import { getCanonicalUrl, getSitemapRoutes } from './site-metadata';
+
+const internalDocsLinkPattern = /(?:href=["']|\]\()(?\/docs\/[^"')#\s]+)/g;
+
+function findInternalDocsLinks(content: string): string[] {
+ return Array.from(content.matchAll(internalDocsLinkPattern), (match) => match.groups?.href)
+ .filter((href): href is string => Boolean(href));
+}
describe('website docs bindings', () => {
it('lists all doc slugs from config', () => {
@@ -29,11 +37,65 @@ describe('website docs bindings', () => {
});
it('resolves page metadata for configured docs', () => {
- expect(getDocMetadata('ag-ui', 'reference', 'event-mapping')).toEqual({
+ const metadata = getDocMetadata('ag-ui', 'reference', 'event-mapping');
+
+ expect(metadata).toMatchObject({
title: 'Event Mapping - AG-UI Docs - Angular Agent Framework',
- description:
- 'Adapter for any AG-UI-compatible backend (CrewAI, Mastra, Microsoft AF, AG2, Pydantic AI, AWS Strands, CopilotKit runtime)',
+ alternates: {
+ canonical: '/docs/ag-ui/reference/event-mapping',
+ },
+ openGraph: {
+ title: 'Event Mapping - AG-UI Docs - Angular Agent Framework',
+ url: '/docs/ag-ui/reference/event-mapping',
+ },
+ twitter: {
+ card: 'summary_large_image',
+ title: 'Event Mapping - AG-UI Docs - Angular Agent Framework',
+ },
});
+ expect(metadata?.description).toContain('AG-UI protocol events');
+ expect(metadata?.description).not.toBe(
+ 'Adapter for any AG-UI-compatible backend (CrewAI, Mastra, Microsoft AF, AG2, Pydantic AI, AWS Strands, CopilotKit runtime)',
+ );
+ });
+
+ it('derives mostly unique descriptions from page content', () => {
+ const descriptions = getAllDocSlugs()
+ .map(({ library, section, slug }) => getDocMetadata(library, section, slug)?.description)
+ .filter((description): description is string => Boolean(description));
+
+ const duplicateDescriptions = descriptions.filter((description, index) => descriptions.indexOf(description) !== index);
+ expect(duplicateDescriptions).toHaveLength(0);
+ });
+
+ it('includes every configured doc page in the sitemap routes', () => {
+ const sitemapRoutes = getSitemapRoutes();
+
+ for (const { library, section, slug } of getAllDocSlugs()) {
+ expect(sitemapRoutes).toContain(`/docs/${library}/${section}/${slug}`);
+ }
+ });
+
+ it('resolves canonical URLs against the production origin', () => {
+ expect(getCanonicalUrl('/docs/agent/guides/streaming')).toBe('https://cacheplane.ai/docs/agent/guides/streaming');
+ });
+
+ it('does not contain stale or broken internal docs links', () => {
+ const validDocsRoutes = new Set(['/docs', ...getSitemapRoutes().filter((route) => route.startsWith('/docs/'))]);
+ const brokenLinks: string[] = [];
+
+ for (const { library, section, slug } of getAllDocSlugs()) {
+ const doc = getDocBySlug(library, section, slug);
+ if (!doc) continue;
+
+ for (const href of findInternalDocsLinks(doc.content)) {
+ if (!validDocsRoutes.has(href)) {
+ brokenLinks.push(`${library}/${section}/${slug} -> ${href}`);
+ }
+ }
+ }
+
+ expect(brokenLinks).toEqual([]);
});
it('returns null for non-existent doc', () => {
diff --git a/apps/website/src/lib/docs.ts b/apps/website/src/lib/docs.ts
index 266b3b23f..22bfebc5c 100644
--- a/apps/website/src/lib/docs.ts
+++ b/apps/website/src/lib/docs.ts
@@ -1,6 +1,8 @@
import fs from 'fs';
import path from 'path';
+import type { Metadata } from 'next';
import { docsConfig, type DocsPage, getLibraryConfig, getLibraryPages } from './docs-config';
+import { createPageMetadata } from './site-metadata';
const resolveContentDir = (library: string): string => {
const workspacePath = path.join(process.cwd(), 'apps', 'website', 'content', 'docs', library);
@@ -14,9 +16,46 @@ export interface ResolvedDoc {
title: string;
}
-export interface ResolvedDocMetadata {
- title: string;
- description: string;
+export type ResolvedDocMetadata = Metadata;
+
+const FRONTMATTER_DESCRIPTION_PATTERN = /^---\s*\n[\s\S]*?\ndescription:\s*['"]?(?[^'"\n]+)['"]?\s*\n[\s\S]*?\n---/;
+
+function normalizeDescription(description: string): string {
+ return description
+ .replace(/`([^`]+)`/g, '$1')
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
+ .replace(/<[^>]+>/g, '')
+ .replace(/\s+/g, ' ')
+ .trim();
+}
+
+function extractFirstParagraph(content: string): string | null {
+ const withoutFrontmatter = content.replace(/^---\s*\n[\s\S]*?\n---\s*/, '');
+ const withoutImports = withoutFrontmatter.replace(/^import\s.+$/gm, '');
+ const paragraphs = withoutImports.split(/\n{2,}/);
+
+ for (const paragraph of paragraphs) {
+ const normalized = normalizeDescription(paragraph);
+ if (
+ normalized.length < 40 ||
+ normalized.startsWith('#') ||
+ normalized.startsWith('|') ||
+ normalized.startsWith('```') ||
+ normalized.startsWith('<')
+ ) {
+ continue;
+ }
+
+ return normalized.length > 180 ? `${normalized.slice(0, 177).trimEnd()}...` : normalized;
+ }
+
+ return null;
+}
+
+function getDocDescription(content: string, fallback: string): string {
+ const frontmatterDescription = content.match(FRONTMATTER_DESCRIPTION_PATTERN)?.groups?.description;
+ if (frontmatterDescription) return normalizeDescription(frontmatterDescription);
+ return extractFirstParagraph(content) ?? fallback;
}
export function getDocBySlug(library: string, section: string, slug: string): ResolvedDoc | null {
@@ -47,10 +86,11 @@ export function getDocMetadata(
const lib = getLibraryConfig(library);
const libraryTitle = lib?.title ?? 'Docs';
- return {
- title: `${doc.title} - ${libraryTitle} Docs - Angular Agent Framework`,
- description: lib?.description ?? 'Angular Agent Framework documentation',
- };
+ const title = `${doc.title} - ${libraryTitle} Docs - Angular Agent Framework`;
+ const description = getDocDescription(doc.content, lib?.description ?? 'Angular Agent Framework documentation');
+ const pathname = `/docs/${library}/${section}/${slug}`;
+
+ return createPageMetadata({ title, description, pathname });
}
export function getAllDocSlugs(): { library: string; section: string; slug: string }[] {
diff --git a/apps/website/src/lib/site-metadata.ts b/apps/website/src/lib/site-metadata.ts
new file mode 100644
index 000000000..d237a6e63
--- /dev/null
+++ b/apps/website/src/lib/site-metadata.ts
@@ -0,0 +1,64 @@
+import type { Metadata } from 'next';
+import { getAllSolutionSlugs } from './solutions-data';
+import { docsConfig } from './docs-config';
+
+export const SITE_ORIGIN = 'https://cacheplane.ai';
+export const SITE_NAME = 'Angular Agent Framework';
+export const DEFAULT_SOCIAL_IMAGE = '/opengraph-image';
+
+export function getCanonicalPath(pathname: string): string {
+ if (pathname === '/') return '/';
+ return `/${pathname.replace(/^\/+|\/+$/g, '')}`;
+}
+
+export function getCanonicalUrl(pathname: string): string {
+ return new URL(getCanonicalPath(pathname), SITE_ORIGIN).toString();
+}
+
+export function createPageMetadata({
+ title,
+ description,
+ pathname,
+ type = 'article',
+}: {
+ title: string;
+ description: string;
+ pathname: string;
+ type?: 'article' | 'website';
+}): Metadata {
+ const canonicalPath = getCanonicalPath(pathname);
+
+ return {
+ title,
+ description,
+ alternates: {
+ canonical: canonicalPath,
+ },
+ openGraph: {
+ title,
+ description,
+ url: canonicalPath,
+ siteName: SITE_NAME,
+ type,
+ images: [DEFAULT_SOCIAL_IMAGE],
+ },
+ twitter: {
+ card: 'summary_large_image',
+ title,
+ description,
+ images: [DEFAULT_SOCIAL_IMAGE],
+ },
+ };
+}
+
+export function getSitemapRoutes(): string[] {
+ const staticRoutes = ['/', '/angular', '/render', '/chat', '/pricing', '/solutions', '/pilot-to-prod', '/docs'];
+ const solutionRoutes = getAllSolutionSlugs().map((slug) => `/solutions/${slug}`);
+ const docsRoutes = docsConfig.flatMap((library) =>
+ library.sections.flatMap((section) =>
+ section.pages.map((page) => `/docs/${library.id}/${page.section}/${page.slug}`),
+ ),
+ );
+
+ return [...staticRoutes, ...solutionRoutes, ...docsRoutes];
+}