yap-blog/src/app/layout.tsx
matt b7d473e7e5 Add RSS feed, sitemap, robots.txt, and search-engine metadata
A new "Site URL" setting (validated, trailing-slash-normalized, with
SITE_URL env fallback) anchors every absolute URL. On top of it:

- /feed.xml — RSS 2.0 with the 20 newest published posts: excerpt
  description, full sanitized HTML in content:encoded (relative image
  and link URLs rewritten to absolute, since readers resolve nothing),
  categories from tags, dc:creator, and a self atom:link. Autodiscovery
  <link> on every public page and a footer link.
- /sitemap.xml — home, post list, every published post and page
  (lastmod from updatedAt), and publicly visible tags. Rendered per
  request like the rest of the site so it never goes stale; drafts
  never appear.
- /robots.txt — allow all, disallow /admin/ and /api/, sitemap pointer.
  Admin pages also carry noindex meta as a second layer.
- Page metadata: metadataBase + canonical URLs everywhere, Open Graph
  (article type with published/modified times, author, and tags on
  posts; og:image + summary_large_image card when there's a featured
  image), and BlogPosting JSON-LD on post pages.

Gotcha encoded in lib/seo.ts: Next merges metadata shallowly, so pages
setting alternates.canonical alone would wipe the layout's RSS
autodiscovery entry — pageAlternates() always sets both.

Backups gain settings.siteUrl (export v4; older files still import).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 13:59:04 -04:00

192 lines
4.7 KiB
TypeScript

import type { Metadata, Viewport } from "next";
import {
Atkinson_Hyperlegible,
EB_Garamond,
Geist,
Geist_Mono,
Inter,
JetBrains_Mono,
Lora,
Merriweather,
Open_Sans,
Playfair_Display,
Source_Serif_4,
Space_Grotesk,
Work_Sans,
} from "next/font/google";
import "./globals.css";
import type { Settings } from "@/db/schema";
import { FEED_PATH, resolveSiteUrl } from "@/lib/seo";
import { DEFAULT_SETTINGS, getSettings } from "@/lib/services/settings";
import { THEME_META } from "@/lib/themes";
// The entire site is driven by database content that admins change at any
// time, so every route renders per request (no build-time DB dependency).
// Incremental caching/revalidation is a documented future optimization.
export const dynamic = "force-dynamic";
// All selectable body fonts are self-hosted by next/font at build time.
// Only Geist (the default) is preloaded; the others declare @font-face
// rules and are fetched by the browser solely when the admin-selected
// data-font attribute makes one of them the active --font-body.
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
const inter = Inter({
variable: "--font-inter",
subsets: ["latin"],
preload: false,
});
const lora = Lora({
variable: "--font-lora",
subsets: ["latin"],
style: ["normal", "italic"],
preload: false,
});
const merriweather = Merriweather({
variable: "--font-merriweather",
subsets: ["latin"],
weight: ["300", "400", "700"],
style: ["normal", "italic"],
preload: false,
});
const jetbrainsMono = JetBrains_Mono({
variable: "--font-jetbrains-mono",
subsets: ["latin"],
style: ["normal", "italic"],
preload: false,
});
const sourceSerif = Source_Serif_4({
variable: "--font-source-serif",
subsets: ["latin"],
style: ["normal", "italic"],
preload: false,
});
const ebGaramond = EB_Garamond({
variable: "--font-eb-garamond",
subsets: ["latin"],
style: ["normal", "italic"],
preload: false,
});
const playfair = Playfair_Display({
variable: "--font-playfair",
subsets: ["latin"],
style: ["normal", "italic"],
preload: false,
});
const openSans = Open_Sans({
variable: "--font-open-sans",
subsets: ["latin"],
style: ["normal", "italic"],
preload: false,
});
const workSans = Work_Sans({
variable: "--font-work-sans",
subsets: ["latin"],
style: ["normal", "italic"],
preload: false,
});
const atkinson = Atkinson_Hyperlegible({
variable: "--font-atkinson",
subsets: ["latin"],
weight: ["400", "700"],
style: ["normal", "italic"],
preload: false,
});
const spaceGrotesk = Space_Grotesk({
variable: "--font-space-grotesk",
subsets: ["latin"],
preload: false,
});
const fontVariables = [
geistSans.variable,
geistMono.variable,
inter.variable,
lora.variable,
merriweather.variable,
jetbrainsMono.variable,
sourceSerif.variable,
ebGaramond.variable,
playfair.variable,
openSans.variable,
workSans.variable,
atkinson.variable,
spaceGrotesk.variable,
].join(" ");
/** The chrome must render even when the DB is down; fall back to defaults. */
async function settingsOrDefaults(): Promise<Settings> {
try {
return await getSettings();
} catch {
return DEFAULT_SETTINGS;
}
}
export async function generateMetadata(): Promise<Metadata> {
const settings = await settingsOrDefaults();
return {
// Resolves every relative canonical/OG URL below to the public origin.
metadataBase: new URL(resolveSiteUrl(settings)),
title: { default: settings.siteTitle, template: `%s · ${settings.siteTitle}` },
description: settings.headerText || undefined,
alternates: {
types: { "application/rss+xml": [{ url: FEED_PATH, title: settings.siteTitle }] },
},
openGraph: {
siteName: settings.siteTitle,
type: "website",
locale: "en",
},
};
}
export async function generateViewport(): Promise<Viewport> {
const { theme } = await settingsOrDefaults();
return { themeColor: THEME_META[theme].bg };
}
export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const { theme, font } = await settingsOrDefaults();
return (
<html
lang="en"
data-theme={theme}
data-font={font}
className={`${fontVariables} h-full antialiased`}
>
<body className="flex min-h-dvh flex-col font-sans">
<a
href="#main"
className="sr-only focus:not-sr-only focus:fixed focus:left-4 focus:top-4 focus:z-[100] focus:rounded-md focus:bg-link focus:px-4 focus:py-2 focus:text-ink-inverse"
>
Skip to content
</a>
{children}
</body>
</html>
);
}