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>
192 lines
4.7 KiB
TypeScript
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>
|
|
);
|
|
}
|