Merge feature/seo: RSS feed, sitemap, robots.txt, SEO metadata
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
commit
11a7d6d659
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,
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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} />
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
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";
|
} 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
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 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'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
|
||||||
|
|
|
||||||
|
|
@ -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"> </p>}
|
{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>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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
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";
|
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,
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
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