From 175208ee4983bb0383af9ac3ea472c3a68d2beb7 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Fri, 25 Sep 2026 19:46:47 -0400 Subject: [PATCH] add 404 page and sitemap --- Dockerfile | 2 + app/(docs)/[[...slug]]/page.tsx | 7 +- app/(docs)/not-found.tsx | 30 ++++++++ app/global.css | 2 +- app/layout.tsx | 1 + app/robots.ts | 21 ++++++ app/sitemap.ts | 55 ++++++++++++++ components/not-found.tsx | 71 ++++++++++++++++++ components/view-options.tsx | 129 ++++++++++++++++++++++++++++++++ 9 files changed, 315 insertions(+), 3 deletions(-) create mode 100644 app/(docs)/not-found.tsx create mode 100644 app/robots.ts create mode 100644 app/sitemap.ts create mode 100644 components/not-found.tsx create mode 100644 components/view-options.tsx diff --git a/Dockerfile b/Dockerfile index 5316e2d..8a84cfb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,6 +6,8 @@ RUN npm ci FROM node:24-alpine AS build WORKDIR /app +# sitemap lastmod comes from git history +RUN apk add --no-cache git ENV NEXT_TELEMETRY_DISABLED=1 ARG NEXT_PUBLIC_SITE_URL=https://docs.pangolin.net ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL diff --git a/app/(docs)/[[...slug]]/page.tsx b/app/(docs)/[[...slug]]/page.tsx index 8a97dc4..41c3452 100644 --- a/app/(docs)/[[...slug]]/page.tsx +++ b/app/(docs)/[[...slug]]/page.tsx @@ -5,8 +5,8 @@ import { DocsPage, DocsTitle, MarkdownCopyButton, - ViewOptionsPopover, } from 'fumadocs-ui/layouts/notebook/page'; +import { ViewOptionsPopover } from '@/components/view-options'; import { notFound } from 'next/navigation'; import { getMDXComponents } from '@/components/mdx'; import type { Metadata } from 'next'; @@ -77,7 +77,10 @@ export async function generateMetadata(props: PageProps<'/[[...slug]]'>): Promis description: page.data.description, alternates: { canonical: page.url, - types: { 'text/markdown': getPageMarkdownUrl(page).url }, + types: { + 'text/markdown': getPageMarkdownUrl(page).url, + 'application/xml': '/sitemap.xml', + }, }, openGraph: { images: '/images/home-social-graph.png', diff --git a/app/(docs)/not-found.tsx b/app/(docs)/not-found.tsx new file mode 100644 index 0000000..f7c3418 --- /dev/null +++ b/app/(docs)/not-found.tsx @@ -0,0 +1,30 @@ +import type { Metadata } from 'next'; +import type { ReactNode } from 'react'; +import type * as PageTree from 'fumadocs-core/page-tree'; +import { NotFoundPage, type NotFoundLink } from '@/components/not-found'; +import { getNavigationTree } from '@/lib/navigation'; + +export const metadata: Metadata = { + title: 'Page Not Found', +}; + +function label(name: ReactNode) { + return typeof name === 'string' ? name : null; +} + +function flatten(nodes: PageTree.Node[]): NotFoundLink[] { + const links: NotFoundLink[] = []; + for (const node of nodes) { + if (node.type === 'page') { + const title = label(node.name); + if (title && node.url) links.push({ title, url: node.url }); + continue; + } + if (node.type === 'folder') links.push(...flatten(node.children)); + } + return links; +} + +export default function NotFound() { + return ; +} diff --git a/app/global.css b/app/global.css index 67a4ab4..508b901 100644 --- a/app/global.css +++ b/app/global.css @@ -323,7 +323,7 @@ body { .pg-ask-ai-btn svg { width: 1rem; height: 1rem; - color: var(--color-pg-orange); + color: currentColor; } /* logo: fixed height, width from the image's aspect ratio (never stretched) */ diff --git a/app/layout.tsx b/app/layout.tsx index bf135f0..43dc49e 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -15,6 +15,7 @@ export const metadata: Metadata = { title: { template: `%s | ${appName}`, default: appName }, description: siteDescription, appleWebApp: { title: appName }, + alternates: { types: { 'application/xml': '/sitemap.xml' } }, openGraph: { siteName: appName, images: '/images/home-social-graph.png' }, twitter: { card: 'summary_large_image' }, }; diff --git a/app/robots.ts b/app/robots.ts new file mode 100644 index 0000000..5ca40f4 --- /dev/null +++ b/app/robots.ts @@ -0,0 +1,21 @@ +import type { MetadataRoute } from 'next'; +import { siteUrl } from '@/lib/shared'; + +/** + * Mintlify's generated robots.txt: AI crawlers may train, search, and answer + * from the docs, while Next.js build assets stay out of the index. + * https://www.mintlify.com/docs/optimize/seo + */ +export default function robots(): MetadataRoute.Robots { + return { + rules: { + userAgent: '*', + allow: '/_next/image', + disallow: ['/cdn-cgi/', '/_next/'], + other: { + 'Content-Signal': 'ai-train=yes, search=yes, ai-input=yes', + }, + }, + sitemap: `${siteUrl}/sitemap.xml`, + }; +} diff --git a/app/sitemap.ts b/app/sitemap.ts new file mode 100644 index 0000000..0364f9c --- /dev/null +++ b/app/sitemap.ts @@ -0,0 +1,55 @@ +import { execFileSync } from 'node:child_process'; +import { statSync } from 'node:fs'; +import path from 'node:path'; +import type { MetadataRoute } from 'next'; +import { source } from '@/lib/source'; +import { docsDir, siteUrl } from '@/lib/shared'; + +/** + * Same shape as a Mintlify docs sitemap (`/docs/sitemap.xml` on Infisical): + * one `` and `` per published page. + */ +export default function sitemap(): MetadataRoute.Sitemap { + const dates = lastCommitDates(); + + return source + .getPages() + .map((page) => { + const file = `${docsDir}/${page.path}`; + return { + url: `${siteUrl}${page.url}`, + lastModified: dates.get(file) ?? mtime(page.absolutePath ?? path.join(process.cwd(), file)), + }; + }) + .sort((a, b) => a.url.localeCompare(b.url)); +} + +/** Newest commit that touched each file under content/docs. Empty when git is unavailable. */ +function lastCommitDates(): Map { + const dates = new Map(); + try { + const out = execFileSync('git', ['log', '--format=COMMIT %cI', '--name-only', '--', docsDir], { + encoding: 'utf8', + }); + let current: Date | undefined; + for (const line of out.split('\n')) { + if (line.startsWith('COMMIT ')) { + const date = new Date(line.slice('COMMIT '.length)); + current = Number.isNaN(date.getTime()) ? undefined : date; + } else if (line && current && !dates.has(line)) { + dates.set(line, current); + } + } + } catch { + // Docker builds without git fall back to file mtime. + } + return dates; +} + +function mtime(file: string): Date { + try { + return statSync(file).mtime; + } catch { + return new Date(); + } +} diff --git a/components/not-found.tsx b/components/not-found.tsx new file mode 100644 index 0000000..8951825 --- /dev/null +++ b/components/not-found.tsx @@ -0,0 +1,71 @@ +'use client'; + +import Link from 'fumadocs-core/link'; +import { usePathname } from 'next/navigation'; + +export type NotFoundLink = { + title: string; + url: string; +}; + +const fallbackUrls = ['/', '/about/how-pangolin-works', '/self-host/quick-install']; + +function segments(path: string) { + return path.split('/').filter(Boolean); +} + +/** Pages that share the most of the requested path, same idea as Mintlify's suggestions. */ +function suggest(pages: NotFoundLink[], pathname: string) { + const wanted = segments(pathname); + const ranked = pages + .map((page, index) => { + const parts = segments(page.url); + let prefix = 0; + while (prefix < parts.length && prefix < wanted.length && parts[prefix] === wanted[prefix]) { + prefix += 1; + } + return { + page, + index, + score: prefix === 0 ? 0 : prefix * 100 - Math.abs(parts.length - wanted.length), + }; + }) + .filter((item) => item.score > 0) + .sort((a, b) => b.score - a.score || a.index - b.index); + + const picks = ranked.slice(0, 3).map((item) => item.page); + if (picks.length > 0) return picks; + + return fallbackUrls.flatMap((url) => { + const page = pages.find((item) => item.url === url); + return page ? [page] : []; + }); +} + +export function NotFoundPage({ pages }: { pages: NotFoundLink[] }) { + const suggestions = suggest(pages, usePathname() || '/'); + + return ( +
+

404

+

Page Not Found

+

+ We couldn't find the page. Maybe you were looking for one of these pages below? +

+ {suggestions.length > 0 && ( +
    + {suggestions.map((page) => ( +
  • + + {page.title} + +
  • + ))} +
+ )} +
+ ); +} diff --git a/components/view-options.tsx b/components/view-options.tsx new file mode 100644 index 0000000..66e42af --- /dev/null +++ b/components/view-options.tsx @@ -0,0 +1,129 @@ +'use client'; + +import { buttonVariants } from 'fumadocs-ui/components/ui/button'; +import { Popover, PopoverContent, PopoverTrigger } from 'fumadocs-ui/components/ui/popover'; +import { ChevronDown, ExternalLink, Text } from 'lucide-react'; +import { useMemo, type ComponentProps, type ReactNode } from 'react'; +import { cn } from '@/lib/cn'; +import { GitHubIcon } from './icons'; + +type ViewOptionsPopoverProps = ComponentProps & { + markdownUrl?: string; + githubUrl?: string; + /** Canonical page URL the AI prompts ask to read. */ + pageUrl: string; +}; + +type Option = { + title: string; + href: string; + icon: ReactNode; +}; + +export function ViewOptionsPopover({ + markdownUrl, + githubUrl, + pageUrl, + children, + className, + ...props +}: ViewOptionsPopoverProps) { + const items = useMemo(() => { + const prompt = `Read ${pageUrl}, I want to ask questions about it.`; + const options: Option[] = []; + + if (githubUrl) { + options.push({ + title: 'Open in GitHub', + href: githubUrl, + icon: , + }); + } + + if (markdownUrl) { + options.push({ + title: 'View as Markdown', + href: markdownUrl, + icon: , + }); + } + + options.push( + { + title: 'Open in ChatGPT', + href: `https://chatgpt.com/?${new URLSearchParams({ prompt, hints: 'search' })}`, + icon: , + }, + { + title: 'Open in Claude', + href: `https://claude.ai/new?${new URLSearchParams({ q: prompt })}`, + icon: , + }, + { + title: 'Open in Gemini', + // gemini.google.com ignores prompt query params; AI Mode is Gemini and reads `q` + href: `https://www.google.com/search?${new URLSearchParams({ udm: '50', q: prompt })}`, + icon: , + }, + ); + + return options; + }, [githubUrl, markdownUrl, pageUrl]); + + return ( + + + cn( + buttonVariants({ variant: 'secondary', size: 'sm' }), + 'gap-2 data-[popup-open]:bg-fd-accent data-[popup-open]:text-fd-accent-foreground', + typeof className === 'function' ? className(state) : className, + ) + } + > + {children ?? 'Open'} + + + + {items.map((item) => ( + + {item.icon} + {item.title} + + + ))} + + + ); +} + +function OpenAIIcon() { + return ( + + + + ); +} + +function ClaudeIcon() { + return ( + + + + ); +} + +function GeminiIcon() { + return ( + + + + ); +}