yap-blog/src/lib/seo.ts
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

44 lines
1.5 KiB
TypeScript

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}"`,
);
}