yap-blog/tests/unit/feed.test.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

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 ]]&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"');
});
});