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>
149 lines
5 KiB
TypeScript
149 lines
5 KiB
TypeScript
import { beforeEach, describe, expect, it } from "vitest";
|
|
import { eq } from "drizzle-orm";
|
|
import { db } from "@/db";
|
|
import { fontEnum, tags, themeEnum } from "@/db/schema";
|
|
import { resolveHomeContent } from "@/lib/services/home";
|
|
import { createPage, deletePage, setPageStatus } from "@/lib/services/pages";
|
|
import { createPost } from "@/lib/services/posts";
|
|
import {
|
|
getSettings,
|
|
listPublicNav,
|
|
saveSettings,
|
|
type SettingsInput,
|
|
} from "@/lib/services/settings";
|
|
import { resetDb } from "../helpers/db";
|
|
|
|
const settingsInput = (overrides: Partial<SettingsInput> = {}): SettingsInput => ({
|
|
siteTitle: "Test Site",
|
|
siteUrl: "",
|
|
headerText: "",
|
|
footerText: "",
|
|
postsPerPage: 10,
|
|
excerptWords: 40,
|
|
homeMode: "posts",
|
|
homeTagId: null,
|
|
homePageId: null,
|
|
theme: "solarized-dark",
|
|
font: "geist",
|
|
...overrides,
|
|
});
|
|
|
|
beforeEach(resetDb);
|
|
|
|
describe("home page configuration", () => {
|
|
it("defaults to the post list", async () => {
|
|
await saveSettings(settingsInput(), []);
|
|
const home = await resolveHomeContent(await getSettings());
|
|
expect(home.kind).toBe("posts");
|
|
});
|
|
|
|
it("shows a configured tag and falls back when the tag is deleted", async () => {
|
|
await createPost({
|
|
title: "Tagged",
|
|
slug: "",
|
|
body: "b",
|
|
authorName: "T",
|
|
featuredImageUrl: null,
|
|
featuredImageAlt: null,
|
|
status: "published",
|
|
tagIds: [],
|
|
newTagNames: ["Featured"],
|
|
});
|
|
const [tag] = await db.select().from(tags).where(eq(tags.slug, "featured"));
|
|
await saveSettings(settingsInput({ homeMode: "tag", homeTagId: tag.id }), []);
|
|
|
|
const home = await resolveHomeContent(await getSettings());
|
|
expect(home).toMatchObject({ kind: "tag", tag: { slug: "featured" } });
|
|
|
|
// Deleting the tag nulls settings.home_tag_id via ON DELETE SET NULL.
|
|
await db.delete(tags).where(eq(tags.id, tag.id));
|
|
const settingsAfter = await getSettings();
|
|
expect(settingsAfter.homeTagId).toBeNull();
|
|
expect((await resolveHomeContent(settingsAfter)).kind).toBe("posts");
|
|
});
|
|
|
|
it("shows a configured page and falls back when it is unpublished or deleted", async () => {
|
|
const page = await createPage({
|
|
title: "Welcome",
|
|
slug: "",
|
|
body: "Hello!",
|
|
status: "published",
|
|
});
|
|
await saveSettings(settingsInput({ homeMode: "page", homePageId: page.id }), []);
|
|
|
|
let home = await resolveHomeContent(await getSettings());
|
|
expect(home).toMatchObject({ kind: "page", page: { slug: "welcome" } });
|
|
|
|
// Unpublished page must not leak through the home route.
|
|
await setPageStatus(page.id, "draft");
|
|
home = await resolveHomeContent(await getSettings());
|
|
expect(home.kind).toBe("posts");
|
|
|
|
await setPageStatus(page.id, "published");
|
|
await deletePage(page.id);
|
|
const settingsAfter = await getSettings();
|
|
expect(settingsAfter.homePageId).toBeNull();
|
|
expect((await resolveHomeContent(settingsAfter)).kind).toBe("posts");
|
|
});
|
|
|
|
it("ignores a stale tag reference when the mode is posts", async () => {
|
|
await saveSettings(settingsInput({ homeMode: "posts", homeTagId: null }), []);
|
|
expect((await resolveHomeContent(await getSettings())).kind).toBe("posts");
|
|
});
|
|
});
|
|
|
|
describe("appearance settings", () => {
|
|
it("defaults to solarized-dark and geist on an unseeded database", async () => {
|
|
const settings = await getSettings();
|
|
expect(settings.theme).toBe("solarized-dark");
|
|
expect(settings.font).toBe("geist");
|
|
});
|
|
|
|
it("round-trips every theme in the enum", async () => {
|
|
for (const theme of themeEnum.enumValues) {
|
|
await saveSettings(settingsInput({ theme }), []);
|
|
expect((await getSettings()).theme).toBe(theme);
|
|
}
|
|
});
|
|
|
|
it("round-trips every font in the enum", async () => {
|
|
for (const font of fontEnum.enumValues) {
|
|
await saveSettings(settingsInput({ font }), []);
|
|
expect((await getSettings()).font).toBe(font);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("navigation", () => {
|
|
it("keeps URL items, resolves page items, and hides unpublished pages", async () => {
|
|
const page = await createPage({
|
|
title: "About",
|
|
slug: "",
|
|
body: "",
|
|
status: "published",
|
|
});
|
|
await saveSettings(settingsInput(), [
|
|
{ label: "All posts", url: "/posts", pageId: null },
|
|
{ label: "About", url: null, pageId: page.id },
|
|
{ label: "Elsewhere", url: "https://example.com", pageId: null },
|
|
]);
|
|
|
|
let nav = await listPublicNav();
|
|
expect(nav).toEqual([
|
|
{ label: "All posts", href: "/posts", external: false },
|
|
{ label: "About", href: "/pages/about", external: false },
|
|
{ label: "Elsewhere", href: "https://example.com", external: true },
|
|
]);
|
|
|
|
await setPageStatus(page.id, "draft");
|
|
nav = await listPublicNav();
|
|
expect(nav.map((l) => l.label)).toEqual(["All posts", "Elsewhere"]);
|
|
|
|
// Deleting the page removes its nav item entirely (ON DELETE CASCADE).
|
|
await setPageStatus(page.id, "published");
|
|
await deletePage(page.id);
|
|
nav = await listPublicNav();
|
|
expect(nav.map((l) => l.label)).toEqual(["All posts", "Elsewhere"]);
|
|
});
|
|
});
|