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>
This commit is contained in:
matt 2026-07-05 13:59:04 -04:00
parent f552be3dac
commit b7d473e7e5
27 changed files with 1516 additions and 10 deletions

View file

@ -0,0 +1 @@
ALTER TABLE "settings" ADD COLUMN "site_url" text DEFAULT '' NOT NULL;

File diff suppressed because it is too large Load diff

View file

@ -50,6 +50,13 @@
"when": 1783266633118, "when": 1783266633118,
"tag": "0006_author-permissions", "tag": "0006_author-permissions",
"breakpoints": true "breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1783271305140,
"tag": "0007_site-url",
"breakpoints": true
} }
] ]
} }

View file

@ -19,6 +19,7 @@ export async function updateSettingsAction(
const parsed = settingsFormSchema.safeParse({ const parsed = settingsFormSchema.safeParse({
siteTitle: formData.get("siteTitle"), siteTitle: formData.get("siteTitle"),
siteUrl: formData.get("siteUrl") ?? "",
headerText: formData.get("headerText"), headerText: formData.get("headerText"),
footerText: formData.get("footerText"), footerText: formData.get("footerText"),
postsPerPage: formData.get("postsPerPage"), postsPerPage: formData.get("postsPerPage"),
@ -83,6 +84,7 @@ export async function updateSettingsAction(
await saveSettings( await saveSettings(
{ {
siteTitle: data.siteTitle, siteTitle: data.siteTitle,
siteUrl: data.siteUrl,
headerText: data.headerText, headerText: data.headerText,
footerText: data.footerText, footerText: data.footerText,
postsPerPage: data.postsPerPage, postsPerPage: data.postsPerPage,

View file

@ -1,9 +1,13 @@
import type { Metadata } from "next";
import { PageArticle } from "@/components/public/PageArticle"; import { PageArticle } from "@/components/public/PageArticle";
import { PostListSection } from "@/components/public/PostListSection"; import { PostListSection } from "@/components/public/PostListSection";
import { parsePage } from "@/lib/pagination"; import { parsePage } from "@/lib/pagination";
import { pageAlternates } from "@/lib/seo";
import { resolveHomeContent } from "@/lib/services/home"; import { resolveHomeContent } from "@/lib/services/home";
import { getSettings } from "@/lib/services/settings"; import { getSettings } from "@/lib/services/settings";
export const metadata: Metadata = { alternates: pageAlternates("/") };
/** /**
* Configured home page: the full post list, a tag's post list, or a * Configured home page: the full post list, a tag's post list, or a
* static page decided by site settings, with a safe fallback to the * static page decided by site settings, with a safe fallback to the

View file

@ -1,6 +1,7 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { PageArticle } from "@/components/public/PageArticle"; import { PageArticle } from "@/components/public/PageArticle";
import { pageAlternates } from "@/lib/seo";
import { getPublishedPageBySlug } from "@/lib/services/pages"; import { getPublishedPageBySlug } from "@/lib/services/pages";
type Props = { params: Promise<{ slug: string }> }; type Props = { params: Promise<{ slug: string }> };
@ -9,7 +10,11 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params; const { slug } = await params;
const page = await getPublishedPageBySlug(slug); const page = await getPublishedPageBySlug(slug);
if (!page) return {}; if (!page) return {};
return { title: page.title }; return {
title: page.title,
alternates: pageAlternates(`/pages/${page.slug}`),
openGraph: { title: page.title, url: `/pages/${page.slug}` },
};
} }
export default async function StaticPage({ params }: Props) { export default async function StaticPage({ params }: Props) {

View file

@ -1,9 +1,13 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { PostListSection } from "@/components/public/PostListSection"; import { PostListSection } from "@/components/public/PostListSection";
import { parsePage } from "@/lib/pagination"; import { parsePage } from "@/lib/pagination";
import { pageAlternates } from "@/lib/seo";
import { getSettings } from "@/lib/services/settings"; import { getSettings } from "@/lib/services/settings";
export const metadata: Metadata = { title: "All posts" }; export const metadata: Metadata = {
title: "All posts",
alternates: pageAlternates("/posts"),
};
export default async function PostsPage({ export default async function PostsPage({
searchParams, searchParams,

View file

@ -4,8 +4,10 @@ import { submitCommentAction } from "@/actions/comments";
import { CommentsSection } from "@/components/public/CommentsSection"; import { CommentsSection } from "@/components/public/CommentsSection";
import { PostArticle } from "@/components/public/PostArticle"; import { PostArticle } from "@/components/public/PostArticle";
import { generateExcerpt } from "@/lib/excerpt"; import { generateExcerpt } from "@/lib/excerpt";
import { absoluteUrl, pageAlternates, resolveSiteUrl } from "@/lib/seo";
import { listApprovedComments } from "@/lib/services/comments"; import { listApprovedComments } from "@/lib/services/comments";
import { getPublishedPostBySlug } from "@/lib/services/posts"; import { getPublishedPostBySlug } from "@/lib/services/posts";
import { getSettings } from "@/lib/services/settings";
type Props = { params: Promise<{ slug: string }> }; type Props = { params: Promise<{ slug: string }> };
@ -13,7 +15,30 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params; const { slug } = await params;
const post = await getPublishedPostBySlug(slug); const post = await getPublishedPostBySlug(slug);
if (!post) return {}; if (!post) return {};
return { title: post.title, description: generateExcerpt(post.body, 30) || undefined }; const description = generateExcerpt(post.body, 30) || undefined;
return {
title: post.title,
description,
alternates: pageAlternates(`/posts/${post.slug}`),
openGraph: {
type: "article",
title: post.title,
description,
url: `/posts/${post.slug}`,
publishedTime: post.publishedAt?.toISOString(),
modifiedTime: post.updatedAt.toISOString(),
authors: [post.authorName],
tags: post.tags.map((t) => t.name),
images: post.featuredImageUrl
? [{ url: post.featuredImageUrl, alt: post.featuredImageAlt ?? undefined }]
: undefined,
},
twitter: {
card: post.featuredImageUrl ? "summary_large_image" : "summary",
title: post.title,
description,
},
};
} }
export default async function PostPage({ params }: Props) { export default async function PostPage({ params }: Props) {
@ -21,9 +46,41 @@ export default async function PostPage({ params }: Props) {
// Draft posts are filtered inside the query — they 404 like unknown slugs. // Draft posts are filtered inside the query — they 404 like unknown slugs.
const post = await getPublishedPostBySlug(slug); const post = await getPublishedPostBySlug(slug);
if (!post) notFound(); if (!post) notFound();
const comments = await listApprovedComments(post.id); const [comments, settings] = await Promise.all([
listApprovedComments(post.id),
getSettings(),
]);
const siteUrl = resolveSiteUrl(settings);
const jsonLd = {
"@context": "https://schema.org",
"@type": "BlogPosting",
headline: post.title,
url: absoluteUrl(siteUrl, `/posts/${post.slug}`),
datePublished: post.publishedAt?.toISOString(),
dateModified: post.updatedAt.toISOString(),
author: { "@type": "Person", name: post.authorName },
keywords: post.tags.map((t) => t.name).join(", ") || undefined,
description: generateExcerpt(post.body, 30) || undefined,
...(post.featuredImageUrl
? {
image: post.featuredImageUrl.startsWith("/")
? absoluteUrl(siteUrl, post.featuredImageUrl)
: post.featuredImageUrl,
}
: {}),
};
return ( return (
<> <>
<script
type="application/ld+json"
// Structured data for search engines; content is JSON built from
// trusted fields (escaped "<" defends against </script> breakout).
dangerouslySetInnerHTML={{
__html: JSON.stringify(jsonLd).replaceAll("<", "\\u003c"),
}}
/>
<PostArticle post={post} /> <PostArticle post={post} />
<CommentsSection postId={post.id} comments={comments} action={submitCommentAction} /> <CommentsSection postId={post.id} comments={comments} action={submitCommentAction} />
</> </>

View file

@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { PostListSection } from "@/components/public/PostListSection"; import { PostListSection } from "@/components/public/PostListSection";
import { parsePage } from "@/lib/pagination"; import { parsePage } from "@/lib/pagination";
import { pageAlternates } from "@/lib/seo";
import { getSettings } from "@/lib/services/settings"; import { getSettings } from "@/lib/services/settings";
import { getPublicTagBySlug } from "@/lib/services/tags"; import { getPublicTagBySlug } from "@/lib/services/tags";
@ -14,7 +15,10 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params; const { slug } = await params;
const tag = await getPublicTagBySlug(slug); const tag = await getPublicTagBySlug(slug);
if (!tag) return {}; if (!tag) return {};
return { title: `Posts tagged “${tag.name}` }; return {
title: `Posts tagged “${tag.name}`,
alternates: pageAlternates(`/tags/${tag.slug}`),
};
} }
export default async function TagPage({ params, searchParams }: Props) { export default async function TagPage({ params, searchParams }: Props) {

View file

@ -4,6 +4,10 @@ import { requireUser } from "@/lib/auth/dal";
import { countCommentsByStatus } from "@/lib/services/comments"; import { countCommentsByStatus } from "@/lib/services/comments";
import { getSettings } from "@/lib/services/settings"; import { getSettings } from "@/lib/services/settings";
// Belt to robots.txt's suspenders: even a stray crawler that reaches an
// admin URL is told not to index it.
export const metadata = { robots: { index: false, follow: false } };
const navLinkClasses = const navLinkClasses =
"rounded-md px-2.5 py-1.5 text-sm font-medium text-ink transition-colors hover:bg-background hover:text-ink-strong"; "rounded-md px-2.5 py-1.5 text-sm font-medium text-ink transition-colors hover:bg-background hover:text-ink-strong";

View file

@ -4,7 +4,10 @@ import { redirect } from "next/navigation";
import { LoginForm } from "@/components/admin/LoginForm"; import { LoginForm } from "@/components/admin/LoginForm";
import { getSessionUser } from "@/lib/auth/dal"; import { getSessionUser } from "@/lib/auth/dal";
export const metadata: Metadata = { title: "Sign in" }; export const metadata: Metadata = {
title: "Sign in",
robots: { index: false, follow: false },
};
export default async function LoginPage() { export default async function LoginPage() {
const user = await getSessionUser(); const user = await getSessionUser();

20
src/app/feed.xml/route.ts Normal file
View file

@ -0,0 +1,20 @@
import { buildRssXml } from "@/lib/feed";
import { resolveSiteUrl } from "@/lib/seo";
import { listPublishedPosts } from "@/lib/services/posts";
import { getSettings } from "@/lib/services/settings";
/** RSS 2.0 feed of the 20 newest published posts. */
export async function GET(): Promise<Response> {
const [settings, page] = await Promise.all([
getSettings(),
listPublishedPosts({ page: 1, perPage: 20 }),
]);
const xml = buildRssXml({
settings,
siteUrl: resolveSiteUrl(settings),
posts: page.items,
});
return new Response(xml, {
headers: { "Content-Type": "application/rss+xml; charset=utf-8" },
});
}

View file

@ -16,6 +16,7 @@ import {
} from "next/font/google"; } from "next/font/google";
import "./globals.css"; import "./globals.css";
import type { Settings } from "@/db/schema"; import type { Settings } from "@/db/schema";
import { FEED_PATH, resolveSiteUrl } from "@/lib/seo";
import { DEFAULT_SETTINGS, getSettings } from "@/lib/services/settings"; import { DEFAULT_SETTINGS, getSettings } from "@/lib/services/settings";
import { THEME_META } from "@/lib/themes"; import { THEME_META } from "@/lib/themes";
@ -143,8 +144,18 @@ async function settingsOrDefaults(): Promise<Settings> {
export async function generateMetadata(): Promise<Metadata> { export async function generateMetadata(): Promise<Metadata> {
const settings = await settingsOrDefaults(); const settings = await settingsOrDefaults();
return { 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}` }, title: { default: settings.siteTitle, template: `%s · ${settings.siteTitle}` },
description: settings.headerText || undefined, description: settings.headerText || undefined,
alternates: {
types: { "application/rss+xml": [{ url: FEED_PATH, title: settings.siteTitle }] },
},
openGraph: {
siteName: settings.siteTitle,
type: "website",
locale: "en",
},
}; };
} }

18
src/app/robots.ts Normal file
View file

@ -0,0 +1,18 @@
import type { MetadataRoute } from "next";
import { resolveSiteUrl } from "@/lib/seo";
import { getSettings } from "@/lib/services/settings";
// The sitemap URL depends on the admin-configured site URL setting.
export const dynamic = "force-dynamic";
export default async function robots(): Promise<MetadataRoute.Robots> {
const base = resolveSiteUrl(await getSettings());
return {
rules: {
userAgent: "*",
allow: "/",
disallow: ["/admin/", "/api/"],
},
sitemap: `${base}/sitemap.xml`,
};
}

54
src/app/sitemap.ts Normal file
View file

@ -0,0 +1,54 @@
import type { MetadataRoute } from "next";
import { resolveSiteUrl } from "@/lib/seo";
// DB-driven like every other route: render per request, or the sitemap
// would freeze at whatever was published when the build ran.
export const dynamic = "force-dynamic";
import { listPublishedPages } from "@/lib/services/pages";
import { listPublishedPostSummaries } from "@/lib/services/posts";
import { getSettings } from "@/lib/services/settings";
import { listPublicTags } from "@/lib/services/tags";
/**
* Everything a crawler should index: the home page, the post list,
* every published post and page, and every publicly visible tag.
* Drafts never appear (the queries filter them), matching the 404s
* their URLs return.
*/
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const [settings, posts, pages, tags] = await Promise.all([
getSettings(),
listPublishedPostSummaries(),
listPublishedPages(),
listPublicTags(),
]);
const base = resolveSiteUrl(settings);
const newestPost = posts[0]?.updatedAt;
return [
{ url: base, lastModified: newestPost, changeFrequency: "daily", priority: 1 },
{
url: `${base}/posts`,
lastModified: newestPost,
changeFrequency: "daily",
priority: 0.9,
},
...posts.map((post) => ({
url: `${base}/posts/${post.slug}`,
lastModified: post.updatedAt,
changeFrequency: "monthly" as const,
priority: 0.8,
})),
...pages.map((page) => ({
url: `${base}/pages/${page.slug}`,
lastModified: page.updatedAt,
changeFrequency: "monthly" as const,
priority: 0.6,
})),
...tags.map((tag) => ({
url: `${base}/tags/${tag.slug}`,
changeFrequency: "weekly" as const,
priority: 0.4,
})),
];
}

View file

@ -31,6 +31,7 @@ export function SettingsForm({ settings, navItems, allTags, publishedPages, acti
const ids = useId(); const ids = useId();
const [siteTitle, setSiteTitle] = useState(settings.siteTitle); const [siteTitle, setSiteTitle] = useState(settings.siteTitle);
const [siteUrl, setSiteUrl] = useState(settings.siteUrl);
const [headerText, setHeaderText] = useState(settings.headerText); const [headerText, setHeaderText] = useState(settings.headerText);
const [footerText, setFooterText] = useState(settings.footerText); const [footerText, setFooterText] = useState(settings.footerText);
const [postsPerPage, setPostsPerPage] = useState(String(settings.postsPerPage)); const [postsPerPage, setPostsPerPage] = useState(String(settings.postsPerPage));
@ -95,6 +96,23 @@ export function SettingsForm({ settings, navItems, allTags, publishedPages, acti
/> />
<ErrorText>{err("siteTitle")}</ErrorText> <ErrorText>{err("siteTitle")}</ErrorText>
</div> </div>
<div>
<Label htmlFor={`${ids}-site-url`}>Site URL</Label>
<Input
id={`${ids}-site-url`}
name="siteUrl"
type="url"
placeholder="https://example.com"
value={siteUrl}
onChange={(e) => setSiteUrl(e.target.value)}
aria-invalid={err("siteUrl") ? true : undefined}
/>
<HelpText>
The site&apos;s public address used for the RSS feed, sitemap, and
search-engine metadata.
</HelpText>
<ErrorText>{err("siteUrl")}</ErrorText>
</div>
<div> <div>
<Label htmlFor={`${ids}-header-text`}>Header text</Label> <Label htmlFor={`${ids}-header-text`}>Header text</Label>
<Input <Input

View file

@ -3,6 +3,11 @@ export function SiteFooter({ text }: { text: string }) {
<footer className="mt-16 border-t border-edge bg-surface"> <footer className="mt-16 border-t border-edge bg-surface">
<div className="container-site py-8 text-center text-sm text-ink-muted"> <div className="container-site py-8 text-center text-sm text-ink-muted">
{text ? <p>{text}</p> : <p aria-hidden="true">&nbsp;</p>} {text ? <p>{text}</p> : <p aria-hidden="true">&nbsp;</p>}
<p className="mt-2">
<a href="/feed.xml" className="transition-colors hover:text-link">
RSS feed
</a>
</p>
</div> </div>
</footer> </footer>
); );

View file

@ -177,6 +177,10 @@ export const settings = pgTable(
// Single-row table; the row always has id = 1 (enforced below). // Single-row table; the row always has id = 1 (enforced below).
id: integer("id").primaryKey(), id: integer("id").primaryKey(),
siteTitle: text("site_title").notNull().default("My Blog"), siteTitle: text("site_title").notNull().default("My Blog"),
// Public origin (https://example.com, no trailing slash) used for
// canonical URLs, the RSS feed, and the sitemap. Empty = not configured;
// SEO surfaces then fall back to the SITE_URL env var or localhost.
siteUrl: text("site_url").notNull().default(""),
headerText: text("header_text").notNull().default(""), headerText: text("header_text").notNull().default(""),
footerText: text("footer_text").notNull().default(""), footerText: text("footer_text").notNull().default(""),
postsPerPage: integer("posts_per_page").notNull().default(10), postsPerPage: integer("posts_per_page").notNull().default(10),

71
src/lib/feed.ts Normal file
View file

@ -0,0 +1,71 @@
import type { Settings } from "@/db/schema";
import { generateExcerpt } from "@/lib/excerpt";
import { FEED_PATH, absoluteUrl, absolutifyHtml } from "@/lib/seo";
import type { PostWithTags } from "@/lib/services/posts";
export { FEED_PATH };
function escapeXml(value: string): string {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&apos;");
}
/** CDATA can't contain "]]>"; split the sequence across sections. */
function cdata(value: string): string {
return `<![CDATA[${value.replaceAll("]]>", "]]]]><![CDATA[>")}]]>`;
}
/**
* RSS 2.0 feed for the newest published posts. Pure string building
* no DB access so it's directly unit-testable. Relative URLs inside
* post bodies are rewritten to absolute ones (readers resolve nothing).
*/
export function buildRssXml(options: {
settings: Pick<Settings, "siteTitle" | "headerText">;
siteUrl: string;
posts: PostWithTags[];
}): string {
const { settings, siteUrl, posts } = options;
const items = posts.map((post) => {
const url = absoluteUrl(siteUrl, `/posts/${post.slug}`);
const excerpt = generateExcerpt(post.body, 60);
const categories = post.tags
.map((tag) => ` <category>${escapeXml(tag.name)}</category>`)
.join("\n");
return [
" <item>",
` <title>${escapeXml(post.title)}</title>`,
` <link>${escapeXml(url)}</link>`,
` <guid isPermaLink="true">${escapeXml(url)}</guid>`,
` <pubDate>${(post.publishedAt ?? post.createdAt).toUTCString()}</pubDate>`,
` <dc:creator>${escapeXml(post.authorName)}</dc:creator>`,
excerpt ? ` <description>${escapeXml(excerpt)}</description>` : null,
` <content:encoded>${cdata(absolutifyHtml(post.body, siteUrl))}</content:encoded>`,
categories || null,
" </item>",
]
.filter((line) => line !== null)
.join("\n");
});
return [
'<?xml version="1.0" encoding="UTF-8"?>',
'<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/">',
" <channel>",
` <title>${escapeXml(settings.siteTitle)}</title>`,
` <link>${escapeXml(siteUrl)}</link>`,
` <description>${escapeXml(settings.headerText || settings.siteTitle)}</description>`,
" <language>en</language>",
` <atom:link href="${escapeXml(absoluteUrl(siteUrl, FEED_PATH))}" rel="self" type="application/rss+xml"/>`,
` <lastBuildDate>${new Date().toUTCString()}</lastBuildDate>`,
...items,
" </channel>",
"</rss>",
"",
].join("\n");
}

43
src/lib/seo.ts Normal file
View file

@ -0,0 +1,43 @@
import type { Settings } from "@/db/schema";
export const FEED_PATH = "/feed.xml";
/**
* Canonical + RSS-autodiscovery alternates for a public page. Next.js
* merges metadata SHALLOWLY per key, so a page that sets only
* `alternates.canonical` would wipe the RSS link inherited from the
* root layout always set both through this helper.
*/
export function pageAlternates(canonical: string) {
return {
canonical,
types: { "application/rss+xml": FEED_PATH },
};
}
/**
* The site's public origin for canonical URLs, feeds, and the sitemap.
* Preference order: the admin-configured setting, the SITE_URL env var,
* then localhost so development still produces valid (if wrong) URLs.
* Always returned without a trailing slash.
*/
export function resolveSiteUrl(settings: Pick<Settings, "siteUrl">): string {
const configured = settings.siteUrl || process.env.SITE_URL || "http://localhost:3000";
return configured.replace(/\/+$/, "");
}
export function absoluteUrl(siteUrl: string, path: string): string {
return `${siteUrl}${path.startsWith("/") ? path : `/${path}`}`;
}
/**
* Rewrites site-relative src/href attributes ("/uploads/…", "/posts/…")
* to absolute URLs. Feed readers resolve nothing relative to the feed,
* so stored HTML must be absolutified before it goes into RSS.
*/
export function absolutifyHtml(html: string, siteUrl: string): string {
return html.replace(
/(src|href)="(\/(?!\/)[^"]*)"/g,
(_m, attr: string, path: string) => `${attr}="${siteUrl}${path}"`,
);
}

View file

@ -24,9 +24,10 @@ import { getSettings } from "./settings";
*/ */
export const SITE_EXPORT_FORMAT = "yap-blog-export"; export const SITE_EXPORT_FORMAT = "yap-blog-export";
// v1: posts/pages/tags/nav/settings. v2 adds comments. v3 adds // v1: posts/pages/tags/nav/settings. v2 adds comments. v3 adds
// posts.authorUsername so post ownership survives a restore. Older // posts.authorUsername so post ownership survives a restore. v4 adds
// files still import (missing fields default to empty/unowned). // settings.siteUrl. Older files still import (missing fields default
export const SITE_EXPORT_VERSION = 3; // to empty/unowned).
export const SITE_EXPORT_VERSION = 4;
const slugValue = z.string().trim().min(1).max(120); const slugValue = z.string().trim().min(1).max(120);
const statusValue = z.enum(["draft", "published"]); const statusValue = z.enum(["draft", "published"]);
@ -36,10 +37,11 @@ const timestampValue = z.coerce.date();
export const siteExportSchema = z export const siteExportSchema = z
.object({ .object({
format: z.literal(SITE_EXPORT_FORMAT), format: z.literal(SITE_EXPORT_FORMAT),
version: z.union([z.literal(1), z.literal(2), z.literal(3)]), version: z.union([z.literal(1), z.literal(2), z.literal(3), z.literal(4)]),
exportedAt: timestampValue, exportedAt: timestampValue,
settings: z.object({ settings: z.object({
siteTitle: z.string().trim().min(1).max(120), siteTitle: z.string().trim().min(1).max(120),
siteUrl: z.string().trim().max(200).default(""),
headerText: z.string().trim().max(300), headerText: z.string().trim().max(300),
footerText: z.string().trim().max(500), footerText: z.string().trim().max(500),
postsPerPage: z.number().int().min(1).max(50), postsPerPage: z.number().int().min(1).max(50),
@ -302,6 +304,7 @@ export async function buildSiteExport(): Promise<SiteExport> {
exportedAt: new Date(), exportedAt: new Date(),
settings: { settings: {
siteTitle: settingsRow.siteTitle, siteTitle: settingsRow.siteTitle,
siteUrl: settingsRow.siteUrl,
headerText: settingsRow.headerText, headerText: settingsRow.headerText,
footerText: settingsRow.footerText, footerText: settingsRow.footerText,
postsPerPage: settingsRow.postsPerPage, postsPerPage: settingsRow.postsPerPage,
@ -511,6 +514,7 @@ export async function importSiteExport(data: SiteExport): Promise<void> {
const s = data.settings; const s = data.settings;
const settingsValues = { const settingsValues = {
siteTitle: s.siteTitle, siteTitle: s.siteTitle,
siteUrl: s.siteUrl,
headerText: s.headerText, headerText: s.headerText,
footerText: s.footerText, footerText: s.footerText,
postsPerPage: s.postsPerPage, postsPerPage: s.postsPerPage,

View file

@ -251,6 +251,17 @@ export async function listPublishedPosts(options: {
}; };
} }
/** Slim listing for the sitemap: every published post, newest first. */
export async function listPublishedPostSummaries(): Promise<
Array<{ slug: string; updatedAt: Date }>
> {
return db
.select({ slug: posts.slug, updatedAt: posts.updatedAt })
.from(posts)
.where(eq(posts.status, "published"))
.orderBy(desc(posts.publishedAt), desc(posts.id));
}
export async function getPublishedPostBySlug(slug: string): Promise<PostWithTags | null> { export async function getPublishedPostBySlug(slug: string): Promise<PostWithTags | null> {
const [post] = await db const [post] = await db
.select() .select()

View file

@ -10,6 +10,7 @@ import { type NavItem, navItems, pages, type Settings, settings } from "@/db/sch
export const DEFAULT_SETTINGS: Settings = { export const DEFAULT_SETTINGS: Settings = {
id: 1, id: 1,
siteTitle: "My Blog", siteTitle: "My Blog",
siteUrl: "",
headerText: "", headerText: "",
footerText: "", footerText: "",
postsPerPage: 10, postsPerPage: 10,

View file

@ -159,6 +159,25 @@ const optionalId = z.preprocess(
export const settingsFormSchema = z.object({ export const settingsFormSchema = z.object({
siteTitle: z.string().trim().min(1, "Site title is required.").max(120), siteTitle: z.string().trim().min(1, "Site title is required.").max(120),
siteUrl: z
.string()
.trim()
.max(200, "Site URL is too long.")
.default("")
.refine(
(v) => {
if (v === "") return true;
try {
const url = new URL(v);
return (url.protocol === "http:" || url.protocol === "https:") && !url.search && !url.hash;
} catch {
return false;
}
},
{ message: "Enter the site's public address, like https://example.com." },
)
// Canonical form: no trailing slash.
.transform((v) => v.replace(/\/+$/, "")),
headerText: z.string().trim().max(300, "Header text is too long.").default(""), headerText: z.string().trim().max(300, "Header text is too long.").default(""),
footerText: z.string().trim().max(500, "Footer text is too long.").default(""), footerText: z.string().trim().max(500, "Footer text is too long.").default(""),
postsPerPage: z.coerce postsPerPage: z.coerce

View file

@ -15,6 +15,7 @@ import { resetDb } from "../helpers/db";
const settingsInput = (overrides: Partial<SettingsInput> = {}): SettingsInput => ({ const settingsInput = (overrides: Partial<SettingsInput> = {}): SettingsInput => ({
siteTitle: "Test Site", siteTitle: "Test Site",
siteUrl: "",
headerText: "", headerText: "",
footerText: "", footerText: "",
postsPerPage: 10, postsPerPage: 10,

View file

@ -53,6 +53,7 @@ describe("export/import round trip", () => {
await saveSettings( await saveSettings(
{ {
siteTitle: "Round Trip", siteTitle: "Round Trip",
siteUrl: "https://roundtrip.example",
headerText: "hdr", headerText: "hdr",
footerText: "ftr", footerText: "ftr",
postsPerPage: 7, postsPerPage: 7,

85
tests/unit/feed.test.ts Normal file
View file

@ -0,0 +1,85 @@
import { describe, expect, it } from "vitest";
import type { PostWithTags } from "@/lib/services/posts";
import { buildRssXml } from "@/lib/feed";
import { absolutifyHtml, resolveSiteUrl } from "@/lib/seo";
const post = (overrides: Partial<PostWithTags> = {}): PostWithTags => ({
id: 1,
title: "Hello",
slug: "hello",
body: "<p>Hi there</p>",
authorName: "Matt",
authorId: null,
featuredImageUrl: null,
featuredImageAlt: null,
status: "published",
createdAt: new Date("2026-01-01T00:00:00Z"),
updatedAt: new Date("2026-01-02T00:00:00Z"),
publishedAt: new Date("2026-01-03T12:00:00Z"),
tags: [],
...overrides,
});
const settings = { siteTitle: "My Blog", headerText: "A blog" };
describe("resolveSiteUrl", () => {
it("prefers the setting and strips trailing slashes", () => {
expect(resolveSiteUrl({ siteUrl: "https://example.com/" })).toBe("https://example.com");
expect(resolveSiteUrl({ siteUrl: "" })).toMatch(/^http/);
});
});
describe("absolutifyHtml", () => {
it("rewrites relative src and href, leaves absolute and protocol-relative alone", () => {
const html =
'<img src="/uploads/a.png"><a href="/posts/x">x</a>' +
'<a href="https://other.example/y">y</a><img src="//cdn.example/z.png">';
const out = absolutifyHtml(html, "https://example.com");
expect(out).toContain('src="https://example.com/uploads/a.png"');
expect(out).toContain('href="https://example.com/posts/x"');
expect(out).toContain('href="https://other.example/y"');
expect(out).toContain('src="//cdn.example/z.png"');
});
});
describe("buildRssXml", () => {
it("produces a channel with items, absolute links, and pubDate", () => {
const xml = buildRssXml({
settings,
siteUrl: "https://example.com",
posts: [post({ tags: [{ id: 1, name: "Design", slug: "design" }] })],
});
expect(xml).toContain("<title>My Blog</title>");
expect(xml).toContain("<link>https://example.com/posts/hello</link>");
expect(xml).toContain("<pubDate>Sat, 03 Jan 2026 12:00:00 GMT</pubDate>");
expect(xml).toContain("<category>Design</category>");
expect(xml).toContain("<dc:creator>Matt</dc:creator>");
expect(xml).toContain('href="https://example.com/feed.xml" rel="self"');
});
it("escapes XML-hostile titles and splits CDATA breakouts", () => {
const xml = buildRssXml({
settings: { siteTitle: 'Tom & "Jerry" <Show>', headerText: "" },
siteUrl: "https://example.com",
posts: [
post({
title: "A & B <C>",
body: '<p>body with ]]&gt; literal</p><p>and ]]> raw</p>',
}),
],
});
expect(xml).toContain("<title>Tom &amp; &quot;Jerry&quot; &lt;Show&gt;</title>");
expect(xml).toContain("<title>A &amp; B &lt;C&gt;</title>");
// The raw "]]>" inside the body must not terminate the CDATA section.
expect(xml).toContain("]]]]><![CDATA[>");
});
it("rewrites relative image URLs inside content:encoded", () => {
const xml = buildRssXml({
settings,
siteUrl: "https://example.com",
posts: [post({ body: '<p><img src="/uploads/pic.png"></p>' })],
});
expect(xml).toContain('src="https://example.com/uploads/pic.png"');
});
});