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:
parent
f552be3dac
commit
b7d473e7e5
1
drizzle/0007_site-url.sql
Normal file
1
drizzle/0007_site-url.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE "settings" ADD COLUMN "site_url" text DEFAULT '' NOT NULL;
|
||||
1049
drizzle/meta/0007_snapshot.json
Normal file
1049
drizzle/meta/0007_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -50,6 +50,13 @@
|
|||
"when": 1783266633118,
|
||||
"tag": "0006_author-permissions",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "7",
|
||||
"when": 1783271305140,
|
||||
"tag": "0007_site-url",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -19,6 +19,7 @@ export async function updateSettingsAction(
|
|||
|
||||
const parsed = settingsFormSchema.safeParse({
|
||||
siteTitle: formData.get("siteTitle"),
|
||||
siteUrl: formData.get("siteUrl") ?? "",
|
||||
headerText: formData.get("headerText"),
|
||||
footerText: formData.get("footerText"),
|
||||
postsPerPage: formData.get("postsPerPage"),
|
||||
|
|
@ -83,6 +84,7 @@ export async function updateSettingsAction(
|
|||
await saveSettings(
|
||||
{
|
||||
siteTitle: data.siteTitle,
|
||||
siteUrl: data.siteUrl,
|
||||
headerText: data.headerText,
|
||||
footerText: data.footerText,
|
||||
postsPerPage: data.postsPerPage,
|
||||
|
|
|
|||
|
|
@ -1,9 +1,13 @@
|
|||
import type { Metadata } from "next";
|
||||
import { PageArticle } from "@/components/public/PageArticle";
|
||||
import { PostListSection } from "@/components/public/PostListSection";
|
||||
import { parsePage } from "@/lib/pagination";
|
||||
import { pageAlternates } from "@/lib/seo";
|
||||
import { resolveHomeContent } from "@/lib/services/home";
|
||||
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
|
||||
* static page — decided by site settings, with a safe fallback to the
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { PageArticle } from "@/components/public/PageArticle";
|
||||
import { pageAlternates } from "@/lib/seo";
|
||||
import { getPublishedPageBySlug } from "@/lib/services/pages";
|
||||
|
||||
type Props = { params: Promise<{ slug: string }> };
|
||||
|
|
@ -9,7 +10,11 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|||
const { slug } = await params;
|
||||
const page = await getPublishedPageBySlug(slug);
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,13 @@
|
|||
import type { Metadata } from "next";
|
||||
import { PostListSection } from "@/components/public/PostListSection";
|
||||
import { parsePage } from "@/lib/pagination";
|
||||
import { pageAlternates } from "@/lib/seo";
|
||||
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({
|
||||
searchParams,
|
||||
|
|
|
|||
|
|
@ -4,8 +4,10 @@ import { submitCommentAction } from "@/actions/comments";
|
|||
import { CommentsSection } from "@/components/public/CommentsSection";
|
||||
import { PostArticle } from "@/components/public/PostArticle";
|
||||
import { generateExcerpt } from "@/lib/excerpt";
|
||||
import { absoluteUrl, pageAlternates, resolveSiteUrl } from "@/lib/seo";
|
||||
import { listApprovedComments } from "@/lib/services/comments";
|
||||
import { getPublishedPostBySlug } from "@/lib/services/posts";
|
||||
import { getSettings } from "@/lib/services/settings";
|
||||
|
||||
type Props = { params: Promise<{ slug: string }> };
|
||||
|
||||
|
|
@ -13,7 +15,30 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|||
const { slug } = await params;
|
||||
const post = await getPublishedPostBySlug(slug);
|
||||
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) {
|
||||
|
|
@ -21,9 +46,41 @@ export default async function PostPage({ params }: Props) {
|
|||
// Draft posts are filtered inside the query — they 404 like unknown slugs.
|
||||
const post = await getPublishedPostBySlug(slug);
|
||||
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 (
|
||||
<>
|
||||
<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} />
|
||||
<CommentsSection postId={post.id} comments={comments} action={submitCommentAction} />
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import type { Metadata } from "next";
|
|||
import { notFound } from "next/navigation";
|
||||
import { PostListSection } from "@/components/public/PostListSection";
|
||||
import { parsePage } from "@/lib/pagination";
|
||||
import { pageAlternates } from "@/lib/seo";
|
||||
import { getSettings } from "@/lib/services/settings";
|
||||
import { getPublicTagBySlug } from "@/lib/services/tags";
|
||||
|
||||
|
|
@ -14,7 +15,10 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|||
const { slug } = await params;
|
||||
const tag = await getPublicTagBySlug(slug);
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,10 @@ import { requireUser } from "@/lib/auth/dal";
|
|||
import { countCommentsByStatus } from "@/lib/services/comments";
|
||||
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 =
|
||||
"rounded-md px-2.5 py-1.5 text-sm font-medium text-ink transition-colors hover:bg-background hover:text-ink-strong";
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,10 @@ import { redirect } from "next/navigation";
|
|||
import { LoginForm } from "@/components/admin/LoginForm";
|
||||
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() {
|
||||
const user = await getSessionUser();
|
||||
|
|
|
|||
20
src/app/feed.xml/route.ts
Normal file
20
src/app/feed.xml/route.ts
Normal 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" },
|
||||
});
|
||||
}
|
||||
|
|
@ -16,6 +16,7 @@ import {
|
|||
} 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";
|
||||
|
||||
|
|
@ -143,8 +144,18 @@ async function settingsOrDefaults(): Promise<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",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
18
src/app/robots.ts
Normal file
18
src/app/robots.ts
Normal 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
54
src/app/sitemap.ts
Normal 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,
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
|
@ -31,6 +31,7 @@ export function SettingsForm({ settings, navItems, allTags, publishedPages, acti
|
|||
const ids = useId();
|
||||
|
||||
const [siteTitle, setSiteTitle] = useState(settings.siteTitle);
|
||||
const [siteUrl, setSiteUrl] = useState(settings.siteUrl);
|
||||
const [headerText, setHeaderText] = useState(settings.headerText);
|
||||
const [footerText, setFooterText] = useState(settings.footerText);
|
||||
const [postsPerPage, setPostsPerPage] = useState(String(settings.postsPerPage));
|
||||
|
|
@ -95,6 +96,23 @@ export function SettingsForm({ settings, navItems, allTags, publishedPages, acti
|
|||
/>
|
||||
<ErrorText>{err("siteTitle")}</ErrorText>
|
||||
</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's public address — used for the RSS feed, sitemap, and
|
||||
search-engine metadata.
|
||||
</HelpText>
|
||||
<ErrorText>{err("siteUrl")}</ErrorText>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor={`${ids}-header-text`}>Header text</Label>
|
||||
<Input
|
||||
|
|
|
|||
|
|
@ -3,6 +3,11 @@ export function SiteFooter({ text }: { text: string }) {
|
|||
<footer className="mt-16 border-t border-edge bg-surface">
|
||||
<div className="container-site py-8 text-center text-sm text-ink-muted">
|
||||
{text ? <p>{text}</p> : <p aria-hidden="true"> </p>}
|
||||
<p className="mt-2">
|
||||
<a href="/feed.xml" className="transition-colors hover:text-link">
|
||||
RSS feed
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -177,6 +177,10 @@ export const settings = pgTable(
|
|||
// Single-row table; the row always has id = 1 (enforced below).
|
||||
id: integer("id").primaryKey(),
|
||||
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(""),
|
||||
footerText: text("footer_text").notNull().default(""),
|
||||
postsPerPage: integer("posts_per_page").notNull().default(10),
|
||||
|
|
|
|||
71
src/lib/feed.ts
Normal file
71
src/lib/feed.ts
Normal 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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
/** 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
43
src/lib/seo.ts
Normal 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}"`,
|
||||
);
|
||||
}
|
||||
|
|
@ -24,9 +24,10 @@ import { getSettings } from "./settings";
|
|||
*/
|
||||
export const SITE_EXPORT_FORMAT = "yap-blog-export";
|
||||
// v1: posts/pages/tags/nav/settings. v2 adds comments. v3 adds
|
||||
// posts.authorUsername so post ownership survives a restore. Older
|
||||
// files still import (missing fields default to empty/unowned).
|
||||
export const SITE_EXPORT_VERSION = 3;
|
||||
// posts.authorUsername so post ownership survives a restore. v4 adds
|
||||
// settings.siteUrl. Older files still import (missing fields default
|
||||
// to empty/unowned).
|
||||
export const SITE_EXPORT_VERSION = 4;
|
||||
|
||||
const slugValue = z.string().trim().min(1).max(120);
|
||||
const statusValue = z.enum(["draft", "published"]);
|
||||
|
|
@ -36,10 +37,11 @@ const timestampValue = z.coerce.date();
|
|||
export const siteExportSchema = z
|
||||
.object({
|
||||
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,
|
||||
settings: z.object({
|
||||
siteTitle: z.string().trim().min(1).max(120),
|
||||
siteUrl: z.string().trim().max(200).default(""),
|
||||
headerText: z.string().trim().max(300),
|
||||
footerText: z.string().trim().max(500),
|
||||
postsPerPage: z.number().int().min(1).max(50),
|
||||
|
|
@ -302,6 +304,7 @@ export async function buildSiteExport(): Promise<SiteExport> {
|
|||
exportedAt: new Date(),
|
||||
settings: {
|
||||
siteTitle: settingsRow.siteTitle,
|
||||
siteUrl: settingsRow.siteUrl,
|
||||
headerText: settingsRow.headerText,
|
||||
footerText: settingsRow.footerText,
|
||||
postsPerPage: settingsRow.postsPerPage,
|
||||
|
|
@ -511,6 +514,7 @@ export async function importSiteExport(data: SiteExport): Promise<void> {
|
|||
const s = data.settings;
|
||||
const settingsValues = {
|
||||
siteTitle: s.siteTitle,
|
||||
siteUrl: s.siteUrl,
|
||||
headerText: s.headerText,
|
||||
footerText: s.footerText,
|
||||
postsPerPage: s.postsPerPage,
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
const [post] = await db
|
||||
.select()
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { type NavItem, navItems, pages, type Settings, settings } from "@/db/sch
|
|||
export const DEFAULT_SETTINGS: Settings = {
|
||||
id: 1,
|
||||
siteTitle: "My Blog",
|
||||
siteUrl: "",
|
||||
headerText: "",
|
||||
footerText: "",
|
||||
postsPerPage: 10,
|
||||
|
|
|
|||
|
|
@ -159,6 +159,25 @@ const optionalId = z.preprocess(
|
|||
|
||||
export const settingsFormSchema = z.object({
|
||||
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(""),
|
||||
footerText: z.string().trim().max(500, "Footer text is too long.").default(""),
|
||||
postsPerPage: z.coerce
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import { resetDb } from "../helpers/db";
|
|||
|
||||
const settingsInput = (overrides: Partial<SettingsInput> = {}): SettingsInput => ({
|
||||
siteTitle: "Test Site",
|
||||
siteUrl: "",
|
||||
headerText: "",
|
||||
footerText: "",
|
||||
postsPerPage: 10,
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ describe("export/import round trip", () => {
|
|||
await saveSettings(
|
||||
{
|
||||
siteTitle: "Round Trip",
|
||||
siteUrl: "https://roundtrip.example",
|
||||
headerText: "hdr",
|
||||
footerText: "ftr",
|
||||
postsPerPage: 7,
|
||||
|
|
|
|||
85
tests/unit/feed.test.ts
Normal file
85
tests/unit/feed.test.ts
Normal 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 ]]> literal</p><p>and ]]> raw</p>',
|
||||
}),
|
||||
],
|
||||
});
|
||||
expect(xml).toContain("<title>Tom & "Jerry" <Show></title>");
|
||||
expect(xml).toContain("<title>A & B <C></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"');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue