Skip to content

Commit 764fd90

Browse files
committed
feat: docs share pages — wire og-docs.png into /docs crawler metadata
/docs and /docs/:slug now rewrite to api/docs-page.ts, which swaps the marker-delimited social-meta block in the built shell for per-section title/description and the og-docs.png card (same mechanism as repo-page; helpers deliberately duplicated so the prod-verified repo-page stays untouched). Raw /docs/*.md is unaffected — the filesystem is served before rewrites. DocsPage mirrors the titles client-side during SPA navigation. Unknown slugs get hub metadata without echoing the input.
1 parent 35ffdfa commit 764fd90

4 files changed

Lines changed: 304 additions & 0 deletions

File tree

‎api/_tests/docs-page.test.ts‎

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2+
import { GET } from '../docs-page.js';
3+
4+
const INDEX_HTML = `<!doctype html>
5+
<html>
6+
<head>
7+
<!-- gitlawb:social-meta:start -->
8+
<title>old metadata</title>
9+
<!-- gitlawb:social-meta:end -->
10+
<link rel="stylesheet" href="/assets/app.css" />
11+
</head>
12+
<body>
13+
<div id="root"></div>
14+
<script type="module" src="/assets/app.js"></script>
15+
</body>
16+
</html>`;
17+
18+
beforeEach(() => {
19+
vi.stubEnv('PUBLIC_SITE_URL', '');
20+
});
21+
22+
function stubIndexHtml(): ReturnType<typeof vi.fn> {
23+
const fetchMock = vi.fn().mockResolvedValue(
24+
new Response(INDEX_HTML, { status: 200, headers: { 'Content-Type': 'text/html' } }),
25+
);
26+
vi.stubGlobal('fetch', fetchMock);
27+
return fetchMock;
28+
}
29+
30+
function docsRequest(slug?: string): Request {
31+
const search = slug === undefined ? '' : `?${new URLSearchParams({ slug })}`;
32+
return new Request(`https://preview.example/api/docs-page${search}`);
33+
}
34+
35+
afterEach(() => {
36+
vi.clearAllMocks();
37+
vi.unstubAllGlobals();
38+
vi.unstubAllEnvs();
39+
});
40+
41+
describe('docs page HTML shell', () => {
42+
it('injects per-section metadata with the docs OG card', async () => {
43+
const indexFetch = stubIndexHtml();
44+
45+
const response = await GET(docsRequest('agents'));
46+
const html = await response.text();
47+
48+
expect(response.status).toBe(200);
49+
expect(response.headers.get('cache-control')).toBe(
50+
'public, max-age=0, s-maxage=3600, stale-while-revalidate=86400',
51+
);
52+
expect(indexFetch).toHaveBeenCalledTimes(1);
53+
expect(html).toContain('<title>for AI agents · docs · gitlawb explorer</title>');
54+
expect(html).toContain('content="https://preview.example/og-docs.png"');
55+
expect(html).toContain('href="https://preview.example/docs/agents"');
56+
expect(html).not.toContain('old metadata');
57+
expect(html).toContain('<div id="root"></div>');
58+
expect(html).toContain('<script type="module" src="/assets/app.js"></script>');
59+
});
60+
61+
it('serves hub metadata for the bare /docs route', async () => {
62+
stubIndexHtml();
63+
64+
const response = await GET(docsRequest());
65+
const html = await response.text();
66+
67+
expect(response.status).toBe(200);
68+
expect(html).toContain('<title>docs · gitlawb explorer</title>');
69+
expect(html).toContain('href="https://preview.example/docs"');
70+
expect(html).toContain('content="https://preview.example/og-docs.png"');
71+
});
72+
73+
it('falls back to hub metadata for an unknown slug without echoing it', async () => {
74+
stubIndexHtml();
75+
76+
const response = await GET(docsRequest('"><script>alert(1)</script>'));
77+
const html = await response.text();
78+
79+
expect(response.status).toBe(200);
80+
expect(html).toContain('<title>docs · gitlawb explorer</title>');
81+
expect(html).toContain('href="https://preview.example/docs"');
82+
expect(html).not.toContain('<script>alert(1)</script>');
83+
});
84+
85+
it('honors PUBLIC_SITE_URL for canonical and image URLs', async () => {
86+
vi.stubEnv('PUBLIC_SITE_URL', 'https://explorer.gitlawb.com');
87+
stubIndexHtml();
88+
89+
const response = await GET(docsRequest('protocol'));
90+
const html = await response.text();
91+
92+
expect(html).toContain('href="https://explorer.gitlawb.com/docs/protocol"');
93+
expect(html).toContain('content="https://explorer.gitlawb.com/og-docs.png"');
94+
});
95+
96+
it('returns a plain 502 when the shell cannot be fetched', async () => {
97+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('nope', { status: 500 })));
98+
99+
const response = await GET(docsRequest('agents'));
100+
101+
expect(response.status).toBe(502);
102+
expect(response.headers.get('cache-control')).toBe('private, no-store, max-age=0');
103+
expect(await response.text()).toContain('temporarily unavailable');
104+
});
105+
});

‎api/docs-page.ts‎

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
import { escapeHtml } from './_lib/repoSocial.js';
2+
3+
// Serves /docs and /docs/:slug to crawlers with docs-specific social metadata
4+
// (og-docs.png card, per-section title/description) by swapping the
5+
// marker-delimited block in the built index.html — same mechanism as
6+
// api/repo-page.ts. The shell-fetch/inject helpers are intentionally
7+
// duplicated from repo-page rather than shared: repo-page is verified in
8+
// production and stays untouched.
9+
//
10+
// Raw markdown is unaffected: the filesystem (public/docs/*.md) is served
11+
// before rewrites, so only extensionless page routes reach this function.
12+
13+
const SITE_NAME = 'gitlawb explorer';
14+
const IMAGE_ALT = 'gitlawb docs — guides for agents, humans, and node operators';
15+
16+
const SOCIAL_META_PATTERN =
17+
/<!-- gitlawb:social-meta:start -->[\s\S]*?<!-- gitlawb:social-meta:end -->/;
18+
const INDEX_FETCH_TIMEOUT_MS = 3_500;
19+
20+
// Docs content changes only on deploy; let the CDN hold pages for an hour and
21+
// serve stale for a day while revalidating.
22+
const DOCS_CACHE = 'public, max-age=0, s-maxage=3600, stale-while-revalidate=86400';
23+
const NO_CACHE = 'private, no-store, max-age=0';
24+
25+
interface DocMeta {
26+
title: string;
27+
description: string;
28+
}
29+
30+
/** Mirrors the DOCS list in src/pages/DocsPage.tsx — keep the slugs in sync. */
31+
const DOCS: Record<string, DocMeta> = {
32+
quickstart: {
33+
title: `quickstart · docs · ${SITE_NAME}`,
34+
description: 'Install gl and make your first signed push on the gitlawb network in about two minutes.',
35+
},
36+
agents: {
37+
title: `for AI agents · docs · ${SITE_NAME}`,
38+
description:
39+
'End-to-end instructions for agents operating on gitlawb — install, identity, signed pushes, PRs, bounties, MCP server, and failure modes.',
40+
},
41+
protocol: {
42+
title: `protocol · docs · ${SITE_NAME}`,
43+
description:
44+
'How gitlawb works: DIDs, RFC 9421 signed pushes, UCAN delegation, iCaptcha, three-tier storage, libp2p networking, and ref consensus without a blockchain.',
45+
},
46+
node: {
47+
title: `run a node · docs · ${SITE_NAME}`,
48+
description: 'Stake, register on-chain, and operate a gitlawb node — deployment, environment reference, and rewards.',
49+
},
50+
};
51+
52+
const HUB: DocMeta = {
53+
title: `docs · ${SITE_NAME}`,
54+
description:
55+
'End-to-end guides for agents and humans — quickstart, agent instructions, protocol internals, and running a node.',
56+
};
57+
58+
function renderMetadata(meta: DocMeta, canonicalUrl: string, imageUrl: string): string {
59+
const title = escapeHtml(meta.title);
60+
const description = escapeHtml(meta.description);
61+
const canonical = escapeHtml(canonicalUrl);
62+
const image = escapeHtml(imageUrl);
63+
const imageAlt = escapeHtml(IMAGE_ALT);
64+
65+
return `<!-- gitlawb:social-meta:start -->
66+
<title>${title}</title>
67+
<meta name="description" content="${description}" />
68+
<link rel="canonical" href="${canonical}" />
69+
<meta property="og:type" content="website" />
70+
<meta property="og:site_name" content="${escapeHtml(SITE_NAME)}" />
71+
<meta property="og:title" content="${title}" />
72+
<meta property="og:description" content="${description}" />
73+
<meta property="og:url" content="${canonical}" />
74+
<meta property="og:image" content="${image}" />
75+
<meta property="og:image:width" content="1200" />
76+
<meta property="og:image:height" content="630" />
77+
<meta property="og:image:alt" content="${imageAlt}" />
78+
<meta name="twitter:card" content="summary_large_image" />
79+
<meta name="twitter:title" content="${title}" />
80+
<meta name="twitter:description" content="${description}" />
81+
<meta name="twitter:image" content="${image}" />
82+
<meta name="twitter:image:alt" content="${imageAlt}" />
83+
<!-- gitlawb:social-meta:end -->`;
84+
}
85+
86+
function siteOrigin(request: Request): string {
87+
const requestOrigin = new URL(request.url).origin;
88+
const configuredOrigin = process.env.PUBLIC_SITE_URL?.trim();
89+
if (!configuredOrigin) return requestOrigin;
90+
91+
try {
92+
return new URL(configuredOrigin).origin;
93+
} catch {
94+
return requestOrigin;
95+
}
96+
}
97+
98+
async function fetchIndexHtml(request: Request): Promise<string | null> {
99+
const controller = new AbortController();
100+
const abortFromRequest = () => controller.abort(request.signal.reason);
101+
102+
if (request.signal.aborted) abortFromRequest();
103+
else request.signal.addEventListener('abort', abortFromRequest, { once: true });
104+
105+
const timeout = setTimeout(() => controller.abort(new Error('Index request timed out')), INDEX_FETCH_TIMEOUT_MS);
106+
const headers = new Headers({ Accept: 'text/html' });
107+
const cookie = request.headers.get('cookie');
108+
if (cookie) headers.set('cookie', cookie);
109+
110+
try {
111+
const response = await fetch(new URL('/index.html', request.url), {
112+
method: 'GET',
113+
headers,
114+
cache: 'force-cache',
115+
redirect: 'follow',
116+
signal: controller.signal,
117+
});
118+
if (!response.ok) return null;
119+
120+
const html = await response.text();
121+
return SOCIAL_META_PATTERN.test(html) ? html : null;
122+
} catch {
123+
return null;
124+
} finally {
125+
clearTimeout(timeout);
126+
request.signal.removeEventListener('abort', abortFromRequest);
127+
}
128+
}
129+
130+
function htmlResponse(html: string, init: { status?: number; cacheControl: string }): Response {
131+
return new Response(html, {
132+
status: init.status ?? 200,
133+
headers: {
134+
'Cache-Control': init.cacheControl,
135+
'Content-Type': 'text/html; charset=utf-8',
136+
'X-Content-Type-Options': 'nosniff',
137+
},
138+
});
139+
}
140+
141+
function shellUnavailable(): Response {
142+
return new Response('Docs page is temporarily unavailable.', {
143+
status: 502,
144+
headers: {
145+
'Cache-Control': NO_CACHE,
146+
'Content-Type': 'text/plain; charset=utf-8',
147+
'X-Content-Type-Options': 'nosniff',
148+
},
149+
});
150+
}
151+
152+
function docSlug(request: Request): string | null {
153+
const url = new URL(request.url);
154+
const fromQuery = url.searchParams.get('slug');
155+
if (fromQuery !== null) return fromQuery;
156+
157+
// Fallback for exercising the handler outside Vercel's rewrite.
158+
const match = url.pathname.match(/^\/docs(?:\/([^/]+))?\/?$/u);
159+
if (!match) return null;
160+
return match[1] ?? '';
161+
}
162+
163+
export async function GET(request: Request): Promise<Response> {
164+
const slug = docSlug(request);
165+
const origin = siteOrigin(request);
166+
167+
const indexHtml = await fetchIndexHtml(request);
168+
if (!indexHtml) return shellUnavailable();
169+
170+
// Unknown slugs get the hub metadata; the SPA redirects them client-side.
171+
const known = slug !== null && slug !== '' && Object.hasOwn(DOCS, slug);
172+
const meta = known ? DOCS[slug] : HUB;
173+
const canonicalPath = known ? `/docs/${slug}` : '/docs';
174+
175+
const html = indexHtml.replace(
176+
SOCIAL_META_PATTERN,
177+
renderMetadata(
178+
meta,
179+
new URL(canonicalPath, origin).toString(),
180+
new URL('/og-docs.png', origin).toString(),
181+
),
182+
);
183+
184+
return htmlResponse(html, { cacheControl: DOCS_CACHE });
185+
}

‎src/pages/DocsPage.tsx‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,18 @@ export default function DocsPage() {
5151
};
5252
}, [slug]);
5353

54+
// Crawlers get per-section titles from api/docs-page.ts; this keeps the
55+
// document title in sync during client-side navigation.
56+
useEffect(() => {
57+
const doc = DOCS.find(d => d.slug === slug);
58+
if (!doc) return;
59+
const previous = document.title;
60+
document.title = `${doc.label} · docs · gitlawb explorer`;
61+
return () => {
62+
document.title = previous;
63+
};
64+
}, [slug]);
65+
5466
const state: Omit<DocState, 'slug'> =
5567
loaded.slug === slug ? loaded : { html: null, error: null };
5668

‎vercel.json‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
"rewrites": [
33
{ "source": "/repos/:owner/:name", "destination": "/api/repo-page?owner=:owner&name=:name" },
44
{ "source": "/og/repos/:owner/:name/:version", "destination": "/api/og?owner=:owner&name=:name&v=:version" },
5+
{ "source": "/docs", "destination": "/api/docs-page" },
6+
{ "source": "/docs/:slug", "destination": "/api/docs-page?slug=:slug" },
57
{ "source": "/api/v1/:path*", "destination": "https://node.gitlawb.com/api/v1/:path*" },
68
{ "source": "/node-info", "destination": "https://node.gitlawb.com/" },
79
{ "source": "/nodes/node2/node-info", "destination": "https://node2.gitlawb.com/" },

0 commit comments

Comments
 (0)