}
- actions={
}
+ actions={
}
mobileMenuContent={
}
hideMobileDocsLink
/>
diff --git a/web/app/docs/loop/[slug]/og.png/route.tsx b/web/app/docs/loop/[slug]/og.png/route.tsx
new file mode 100644
index 0000000..a4557da
--- /dev/null
+++ b/web/app/docs/loop/[slug]/og.png/route.tsx
@@ -0,0 +1,48 @@
+import { ImageResponse } from 'next/og';
+import { notFound } from 'next/navigation';
+
+import { DefaultVariant, loadBrandFonts, OG_SIZE } from '../../../../../lib/og/template';
+import { getProductDoc, getProductDocSlugs } from '../../../../../lib/product-docs';
+import { loopSection } from '../../../../../lib/product-docs-nav';
+
+export const runtime = 'nodejs';
+// Prerender one card per Relayloop doc at build time.
+export const dynamic = 'force-static';
+
+type RouteContext = {
+ params: Promise<{ slug: string }>;
+};
+
+export function generateStaticParams() {
+ return getProductDocSlugs(loopSection).map((slug) => ({ slug }));
+}
+
+/**
+ * Per-doc Open Graph card for Relayloop: the default variant with a "Relayloop"
+ * eyebrow plus the doc title and description — so these pages get a product card
+ * instead of the site-wide Agent Relay default.
+ */
+export async function GET(_request: Request, { params }: RouteContext) {
+ const { slug } = await params;
+ const doc = getProductDoc(loopSection.id, slug);
+
+ if (!doc) {
+ notFound();
+ }
+
+ const { fonts, headingFamily, bodyFamily } = await loadBrandFonts();
+
+ return new ImageResponse(
+
,
+ {
+ ...OG_SIZE,
+ ...(fonts.length > 0 ? { fonts } : {}),
+ }
+ );
+}
diff --git a/web/app/docs/loop/[slug]/page.tsx b/web/app/docs/loop/[slug]/page.tsx
index 224e9cf..5b9bba9 100644
--- a/web/app/docs/loop/[slug]/page.tsx
+++ b/web/app/docs/loop/[slug]/page.tsx
@@ -1,6 +1,7 @@
import type { Metadata } from 'next';
import { ProductDocPage } from '../../../../components/docs/ProductDocPage';
+import { ogImage } from '../../../../lib/og-meta';
import {
getProductDoc,
getProductDocMarkdownUrl,
@@ -27,6 +28,8 @@ export async function generateMetadata({ params }: PageProps): Promise
return { title: 'Not Found' };
}
+ const ogPath = `/docs/${SECTION_ID}/${slug}/og.png`;
+
return {
title: `${doc.frontmatter.title} — Relayloop`,
description: doc.frontmatter.description,
@@ -34,6 +37,19 @@ export async function generateMetadata({ params }: PageProps): Promise
canonical: absoluteUrl(`/docs/${SECTION_ID}/${slug}`),
types: { 'text/markdown': getProductDocMarkdownUrl(SECTION_ID, slug) },
},
+ openGraph: {
+ title: `${doc.frontmatter.title} — Relayloop`,
+ description: doc.frontmatter.description,
+ url: absoluteUrl(`/docs/${SECTION_ID}/${slug}`),
+ type: 'article',
+ images: [ogImage(ogPath, `${doc.frontmatter.title} — Relayloop docs`)],
+ },
+ twitter: {
+ card: 'summary_large_image',
+ title: `${doc.frontmatter.title} — Relayloop`,
+ description: doc.frontmatter.description,
+ images: [absoluteUrl(ogPath)],
+ },
};
}
diff --git a/web/components/DocsGitHubStarsBadge.tsx b/web/components/DocsGitHubStarsBadge.tsx
new file mode 100644
index 0000000..7c5153e
--- /dev/null
+++ b/web/components/DocsGitHubStarsBadge.tsx
@@ -0,0 +1,59 @@
+'use client';
+
+import { usePathname } from 'next/navigation';
+
+import { getProductSectionForPath } from '../lib/product-docs-nav';
+import s from './github-stars.module.css';
+
+export type DocsStarRepo = {
+ /** Product section id (`file`, `loop`) or `null` for the default Agent Relay repo. */
+ id: string | null;
+ href: string;
+ label: string;
+ count: string | null;
+};
+
+function GithubIcon() {
+ return (
+
+ );
+}
+
+/**
+ * GitHub stars badge that targets the repo for the docs section currently being
+ * viewed: Relayfile under `/docs/file`, Relayloop under `/docs/loop`, and Agent
+ * Relay everywhere else. Star counts are fetched on the server and handed in;
+ * this component just picks the right one for the active path.
+ */
+export function DocsGitHubStarsBadge({ repos }: { repos: DocsStarRepo[] }) {
+ const pathname = usePathname() ?? '/docs';
+ const section = getProductSectionForPath(pathname);
+ const entry =
+ repos.find((r) => r.id === (section?.id ?? null)) ?? repos.find((r) => r.id === null) ?? repos[0];
+
+ if (!entry) return null;
+
+ return (
+
+
+ {entry.count ? (
+
+
+ {entry.count}
+
+ ) : null}
+
+ );
+}
diff --git a/web/components/GitHubStars.tsx b/web/components/GitHubStars.tsx
index 4409f07..cb734ee 100644
--- a/web/components/GitHubStars.tsx
+++ b/web/components/GitHubStars.tsx
@@ -1,9 +1,13 @@
+import { productSections } from '../lib/product-docs-nav';
+import { DocsGitHubStarsBadge, type DocsStarRepo } from './DocsGitHubStarsBadge';
import s from './github-stars.module.css';
type GitHubRepoResponse = {
stargazers_count?: number;
};
+const DEFAULT_REPO = 'agentworkforce/relay';
+
function GithubIcon() {
return (
diff --git a/web/components/docs/TableOfContents.tsx b/web/components/docs/TableOfContents.tsx
index a9b1e2f..b52d38c 100644
--- a/web/components/docs/TableOfContents.tsx
+++ b/web/components/docs/TableOfContents.tsx
@@ -1,8 +1,10 @@
'use client';
import { useEffect, useState } from 'react';
+import { usePathname } from 'next/navigation';
import type { TocItem } from '../../lib/docs';
+import { getProductSectionForPath } from '../../lib/product-docs-nav';
import { useDocsLanguage } from './DocsLanguageContext';
import { DocsVersionSelect } from './DocsVersionSelect';
import styles from './docs.module.css';
@@ -10,6 +12,8 @@ import styles from './docs.module.css';
export function TableOfContents({ items }: { items: TocItem[] }) {
const [activeId, setActiveId] = useState('');
const { language, setLanguage } = useDocsLanguage();
+ const pathname = usePathname() ?? '/docs';
+ const productSection = getProductSectionForPath(pathname);
useEffect(() => {
const headings = items.map((item) => document.getElementById(item.id)).filter(Boolean) as HTMLElement[];
@@ -47,7 +51,13 @@ export function TableOfContents({ items }: { items: TocItem[] }) {
-