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>
86 lines
3.1 KiB
TypeScript
86 lines
3.1 KiB
TypeScript
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"');
|
|
});
|
|
});
|