Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ npm run check:python
cd site
npm ci
npm run dev # live preview at http://localhost:3100 (also `npm run dev` from the repo root)
npm run build # static build in site/out, Markdown twins, OG images, search data
npm run check:out # every nav page, internal link, anchor, redirect, image and agent file
npm run build # static build in site/out, Markdown twins, share cards, search data
npm run check:out # every nav page, share card, internal link, anchor, redirect, image and agent file
npm run check:worker # type-check the Worker
npm run preview # serve site/out through the Worker at http://localhost:8799
npm run test:mcp # exercise /mcp and /api/search against the preview
Expand All @@ -38,6 +38,7 @@ How it reads the Mintlify-format content, and where:
- Snippet imports are inlined at build time (`lib/remark-mintlify.ts`), so their text reaches the Markdown output too.
- Heading anchors follow Mintlify's slug rule (`lib/slug.mjs`), so existing deep links keep working.
- `openapi:` frontmatter renders the operation with an interactive playground (`fumadocs-openapi`).
- Every page has a share card, the pre.dev card design (`lib/share-card.mjs`, an exact copy of the card spec in `predev-app`; change the spec there first, then copy it here) filled with the page's section, title and description (`lib/og-card.ts`). The build draws each one with satori and resvg (`app/og/[...slug]/route.ts`) at `/og/<page>/<hash>.png`. The hash covers what the card shows and the design version, so a changed card gets a new URL and link previews fetch it again; `/og/<page>/image.png` serves the current card too. The section is the page's group in `docs.json`, at most two levels below its tab.
- Every page has a Markdown twin at `/<page>.md`, also served for `Accept: text/markdown` and to AI fetchers. `llms-full.txt` is generated; `llms.txt` and `skill.md` are served as written.
- The Worker (`worker/`) answers `/api/search`, the docs MCP server at `/mcp` (`search_pre_dev`, `query_docs_filesystem_pre_dev`, `submit_feedback`), and Ask AI at `/api/chat`. Ask AI answers from these docs with two models: Jev, a decision model, turns away off-topic questions and picks the sections that answer the rest, and DeepSeek V4.1 Flash writes the answer from those sections. It has a per-IP rate limit and Turnstile.

Expand Down
15 changes: 9 additions & 6 deletions site/app/(docs)/[...slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { placeOf } from '@/lib/groups';
import { lastModified } from '@/lib/last-modified';
import { PageFooter } from '@/components/page-footer';
import { ImageZoom } from '@/components/image-zoom';
import { cardImage, cardText } from '@/lib/og-card';

/** schema.org data for the page: an article (or API reference) and its breadcrumb trail. */
function structuredData(page: { title: string; description?: string; slug: string; api: boolean; modified?: string }) {
Expand Down Expand Up @@ -120,10 +121,12 @@ export async function generateMetadata(props: { params: Promise<{ slug: string[]
const page = source.getPage(slug);
if (!page) notFound();
const pathSlug = pageSlug(page);
const image = `/og/${pathSlug}/image.png`;
// the page's share card (lib/og-card.ts): its address changes whenever what it shows does
const image = cardImage(page);
const description = cardText(page);
return {
title: page.data.title,
description: page.data.description,
description,
alternates: {
canonical: `/${pathSlug}`,
types: { 'text/markdown': `/${pathSlug}.md` },
Expand All @@ -134,16 +137,16 @@ export async function generateMetadata(props: { params: Promise<{ slug: string[]
siteName: docsJson.name,
url: `/${pathSlug}`,
title: page.data.title,
description: page.data.description,
images: { url: image, width: 1200, height: 630 },
description,
images: image,
...(lastModified(page.path) ? { modifiedTime: lastModified(page.path) } : {}),
},
twitter: {
card: 'summary_large_image',
site: docsJson.seo?.metatags?.['twitter:site'],
title: page.data.title,
description: page.data.description,
images: { url: image, width: 1200, height: 630 },
description,
images: image,
},
};
}
44 changes: 44 additions & 0 deletions site/app/og/[...slug]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// Each page's share card, drawn at build time from the pre.dev card spec
// (lib/share-card.mjs) and written to out/og/<page>/<hash>.png. satori lays out
// and measures the card (fitCard steps the title down and keeps whole sentences
// of the description), resvg turns its SVG into the PNG.
import fs from 'node:fs';
import path from 'node:path';
import { notFound } from 'next/navigation';
import satori from 'satori';
import { Resvg } from '@resvg/resvg-js';
import { source } from '@/lib/source';
import { cardFile, docsCard } from '@/lib/og-card';
import { CARD_FONTS, CARD_HEIGHT, CARD_WIDTH, MARK_SVG, cardTree, checkCard, fitCard, skySvg, svgDataUrl } from '@/lib/share-card.mjs';

export const revalidate = false;
export const dynamicParams = false;

// Inter and JetBrains Mono as WOFF (satori reads no WOFF2)
const fonts = CARD_FONTS.map((f) => ({
name: f.name,
weight: f.weight,
style: 'normal' as const,
data: fs.readFileSync(path.join(process.cwd(), 'node_modules/@fontsource', f.file)),
}));
const art = { mark: svgDataUrl(MARK_SVG), sky: svgDataUrl(skySvg()) };

export async function GET(_req: Request, { params }: { params: Promise<{ slug: string[] }> }) {
const { slug } = await params;
const page = source.getPage(slug.slice(0, -1));
if (!page) notFound();
const card = docsCard(page);
// only the current card's address answers
if (slug.at(-1) !== cardFile(card)) notFound();
const fit = await fitCard(card, satori, fonts);
const tree = cardTree(card, art, fit);
const problems = checkCard(tree);
if (problems.length) throw new Error(`share card for /${slug.slice(0, -1).join('/')}: ${problems.join('; ')}`);
const svg = await satori(tree as never, { width: CARD_WIDTH, height: CARD_HEIGHT, fonts });
const png = new Resvg(svg, { fitTo: { mode: 'original' } }).render().asPng();
return new Response(new Uint8Array(png), { headers: { 'Content-Type': 'image/png' } });
}

export function generateStaticParams() {
return source.getPages().map((page) => ({ slug: [...page.slugs, cardFile(docsCard(page))] }));
}
52 changes: 0 additions & 52 deletions site/app/og/[...slug]/route.tsx

This file was deleted.

34 changes: 22 additions & 12 deletions site/lib/groups.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ export interface NavPlace {
/** First page of the tab */
tabPage: string;
group?: string;
/** Every group above the page, outermost first */
groups: string[];
}

let map: Map<string, NavPlace> | undefined;
Expand All @@ -13,27 +15,35 @@ function places(): Map<string, NavPlace> {
if (!map) {
map = new Map();
for (const tab of docsJson.navigation.tabs) {
const pages: { slug: string; group?: string }[] = [];
const walk = (items: NavGroup['pages'], group?: string) => {
const pages: { slug: string; groups: string[] }[] = [];
const walk = (items: NavGroup['pages'], groups: string[]) => {
for (const item of items) {
if (typeof item === 'string') pages.push({ slug: item, group });
else walk(item.pages, item.group);
if (typeof item === 'string') pages.push({ slug: item, groups });
else walk(item.pages, [...groups, item.group]);
}
};
if (tab.pages) walk(tab.pages);
for (const group of tab.groups ?? []) walk(group.pages, group.group);
for (const page of pages) map.set(page.slug, { tab: tab.tab, tabPage: pages[0].slug, group: page.group });
if (tab.pages) walk(tab.pages, []);
for (const group of tab.groups ?? []) walk(group.pages, [group.group]);
for (const page of pages) {
map.set(page.slug, { tab: tab.tab, tabPage: pages[0].slug, group: page.groups.at(-1), groups: page.groups });
}
}
}
return map;
}

/** The navigation group a page sits in (shown above the title, like Mintlify). */
export function groupOf(slug: string): string | undefined {
return places().get(slug)?.group;
}

/** The tab and group a page sits in. */
export function placeOf(slug: string): NavPlace | undefined {
return places().get(slug);
}

/**
* The section a page's share card names: its group, looking at most two levels
* below the tab, so the AI Gateway's endpoint pages read "AI Gateway reference"
* and the payments pages read "Payments".
*/
export function sectionOf(slug: string): string | undefined {
const place = places().get(slug);
if (!place) return undefined;
return place.groups[Math.min(place.groups.length, 2) - 1] ?? place.tab;
}
69 changes: 69 additions & 0 deletions site/lib/og-card.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// Each docs page's share card (lib/share-card.mjs is the design) and the
// address it is published under. The page's og:image and twitter:image point at
// /og/<page>/<hash>.png, where the hash covers everything the card shows plus
// CARD_DESIGN: change a title, a description, a section or the design and the
// card gets a new URL, so link scrapers that cache images forever fetch it again.
import crypto from 'node:crypto';
import { CARD_DESIGN, CARD_HEIGHT, CARD_WIDTH, cardAlt, type ShareCard } from './share-card.mjs';
import { sectionOf } from './groups';
import { pageSlug, type DocsPage } from './source';

const HOST = 'docs.pre.dev';

/** The page's description, or for a page without one, its first paragraph. */
export function cardText(page: DocsPage): string | undefined {
if (page.data.description?.trim()) return page.data.description.trim();
const first = page.data.structuredData?.contents?.find((c) => c.content.trim())?.content;
return first
?.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
.replace(/`/g, '')
.replace(/\s+/g, ' ')
.trim();
}

export function docsCard(page: DocsPage): ShareCard {
const slug = pageSlug(page);
const section = sectionOf(slug);
const card: ShareCard = {
eyebrow: ['pre.dev', 'Docs', ...(section ? [section] : [])],
title: page.data.title,
text: cardText(page),
footer: { meta: ['pre.dev documentation', `${HOST}/${slug}`] },
fit: true,
};
checkEyebrow(card.eyebrow!, slug);
return card;
}

// The eyebrow is set in JetBrains Mono capitals (0.6em advance, 0.2em apart) and is
// never fitted, so a section name too long for the line fails the build instead.
function checkEyebrow(parts: string[], slug: string) {
const ch = 28 * 0.6 + 5.6;
const words = parts.map((p) => p.split(/\s+/).filter(Boolean));
const chars = words.flat().join('').length + (parts.length - 1);
const gaps = words.reduce((n, w) => n + w.length - 1, 0) + (parts.length - 1) * 4;
const room = CARD_WIDTH - 72 * 2 - Math.round(34 * (432 / 312)) - 18;
const width = (chars + gaps) * ch;
if (width > room) throw new Error(`share card for ${slug}: eyebrow "${parts.join(' / ')}" is ${Math.round(width)}px wide, room for ${room}px`);
}

export function cardHash(card: ShareCard): string {
return crypto.createHash('sha256').update(JSON.stringify([CARD_DESIGN, card])).digest('hex').slice(0, 10);
}

/** The card's file name under /og/<page>/. */
export function cardFile(card: ShareCard): string {
return `${cardHash(card)}.png`;
}

/** What a page's metadata needs for its og:image and twitter:image. */
export function cardImage(page: DocsPage) {
const card = docsCard(page);
return {
url: `/og/${pageSlug(page)}/${cardFile(card)}`,
width: CARD_WIDTH,
height: CARD_HEIGHT,
alt: cardAlt(card),
type: 'image/png',
};
}
47 changes: 47 additions & 0 deletions site/lib/share-card.d.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// Types for share-card.mjs, the pre.dev share card spec (kept identical to the original).
export interface ShareCard {
eyebrow?: string[];
title: string;
dim?: string;
text?: string;
list?: [string, string][];
footer?: {
meta?: [string, string];
stats?: [string, string][];
chips?: string[];
bars?: unknown;
};
avatar?: string;
portrait?: boolean;
fit?: boolean;
}

export interface CardArt {
mark: string;
sky: string;
portrait?: string;
}

export interface CardFit {
titleSize: number;
text?: string;
textSize?: number;
list?: [string, string][];
footer?: ShareCard['footer'];
}

export type CardNode = { type: string; props: Record<string, unknown> };

export declare const CARD_WIDTH: 1200;
export declare const CARD_HEIGHT: 630;
export declare const CARD_DESIGN: string;
export declare const MIN_FONT: number;
export declare const CARD_FONTS: { name: string; weight: 500 | 600 | 700 | 800; file: string }[];
export declare const MARK_SVG: string;
export declare function skySvg(): string;
export declare function svgDataUrl(svg: string): string;
export declare function plainText(text: string | undefined): string;
export declare function cardTree(card: ShareCard, art: CardArt, fit?: Partial<CardFit>): CardNode;
export declare function fitCard(card: ShareCard, satori: unknown, fonts: unknown[]): Promise<CardFit>;
export declare function checkCard(tree: CardNode): string[];
export declare function cardAlt(card: ShareCard): string;
Loading
Loading