From e9aa987b948d95f571dcd0eafdf38fdd8ddae226 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Thu, 23 Jul 2026 09:46:28 -0400 Subject: [PATCH] feat: generate per-subdomain sitemaps based on Host header Refactor sitemap.xml route to read the Host header, determine the active site, and produce a sitemap scoped to that site's routes with canonical URLs. Extracts route definitions and XML generation into shared modules. --- src/lib/sitemap-generate.ts | 44 ++++++++++ src/lib/sitemap-routes.test.ts | 155 +++++++++++++++++++++++++++++++++ src/lib/sitemap-routes.ts | 74 ++++++++++++++++ src/routes/sitemap.xml.ts | 42 +++------ 4 files changed, 287 insertions(+), 28 deletions(-) create mode 100644 src/lib/sitemap-generate.ts create mode 100644 src/lib/sitemap-routes.test.ts create mode 100644 src/lib/sitemap-routes.ts diff --git a/src/lib/sitemap-generate.ts b/src/lib/sitemap-generate.ts new file mode 100644 index 0000000..38d65da --- /dev/null +++ b/src/lib/sitemap-generate.ts @@ -0,0 +1,44 @@ +/** + * Pure utilities for generating sitemap XML. + * + * Extracted from the route handler so the logic can be unit-tested without + * spinning up an HTTP server. + */ +import type { Site } from "./site-context"; +import type { SitemapEntry } from "./sitemap-routes"; + +/** + * Escape a string for safe XML attribute / text content embedding. + */ +function xmlEscape(s: string): string { + return s + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +/** + * Generate a single `` element for a given entry on a site. + */ +export function urlElement(site: Site, entry: SitemapEntry): string { + const loc = `https://${site.domain}${entry.path}`; + return ` + ${xmlEscape(loc)} + ${new Date().toISOString()} + ${xmlEscape(entry.changefreq)} + ${entry.priority.toFixed(1)} + `; +} + +/** + * Generate the full sitemap XML for a given site and its route entries. + */ +export function generateSitemap(site: Site, entries: SitemapEntry[]): string { + const urls = entries.map((e) => urlElement(site, e)).join("\n"); + return ` + +${urls} +`; +} diff --git a/src/lib/sitemap-routes.test.ts b/src/lib/sitemap-routes.test.ts new file mode 100644 index 0000000..850ed9d --- /dev/null +++ b/src/lib/sitemap-routes.test.ts @@ -0,0 +1,155 @@ +/** + * Unit tests for the per-subdomain sitemap generation (task 03). + * + * Covers: + * - `generateSitemap(site, entries)` returns correct XML for each site + * - All `` URLs use the correct subdomain domain + * - Main site sitemap includes all existing routes (no regression) + * - XML is valid (parseable by standard XML parsers) + * - No cross-site URL leakage + */ +import { describe, it, expect } from "bun:test"; +import { SITE_CONFIG, type SiteId } from "./site-context"; +import { SITEMAP_ROUTES } from "./sitemap-routes"; +import { generateSitemap } from "./sitemap-generate"; + +// Helper: parse XML string and return matching values +function extractLocs(xml: string): string[] { + const matches: string[] = []; + const re = /([^<]+)<\/loc>/g; + let m: RegExpExecArray | null; + while ((m = re.exec(xml)) !== null) { + matches.push(m[1]); + } + return matches; +} + +describe("generateSitemap", () => { + it("generates valid XML for main site with all expected routes", () => { + const xml = generateSitemap(SITE_CONFIG.main, SITEMAP_ROUTES.main); + + // Basic structure + expect(xml).toContain(''); + expect(xml).toContain(''); + + // All main site paths present with freno.me domain + const locs = extractLocs(xml); + expect(locs).toContain("https://freno.me/"); + expect(locs).toContain("https://freno.me/blog"); + expect(locs).toContain("https://freno.me/contact"); + expect(locs).toContain("https://freno.me/login"); + expect(locs).toContain("https://freno.me/resume"); + expect(locs).toContain("https://freno.me/downloads"); + + // Exactly 6 entries + expect(locs.length).toBe(6); + + // Verify well-formedness by checking balanced tags + expect(xml).toContain(""); + const urlOpens = (xml.match(//g) || []).length; + const urlCloses = (xml.match(/<\/url>/g) || []).length; + expect(urlOpens).toBe(urlCloses); + expect(urlOpens).toBe(6); + }); + + it("generates valid parseable XML for lineage site", () => { + const xml = generateSitemap(SITE_CONFIG.lineage, SITEMAP_ROUTES.lineage); + // Verify balanced tags + expect(xml).toContain(""); + const urlOpens = (xml.match(//g) || []).length; + const urlCloses = (xml.match(/<\/url>/g) || []).length; + expect(urlOpens).toBe(urlCloses); + expect(urlOpens).toBe(5); + }); + + it("generates correct URLs for essa site", () => { + const xml = generateSitemap(SITE_CONFIG.nessa, SITEMAP_ROUTES.nessa); + const locs = extractLocs(xml); + + expect(locs).toContain("https://nessa.freno.me/"); + expect(locs).toContain("https://nessa.freno.me/contact"); + expect(locs).toContain("https://nessa.freno.me/privacy"); + expect(locs.length).toBe(3); + + // No leakage from main site + for (const loc of locs) { + expect(loc).not.toContain("://freno.me/"); + expect(loc).not.toContain("://freno.me/blog"); + } + }); + + it("generates correct URLs for lineage site", () => { + const xml = generateSitemap(SITE_CONFIG.lineage, SITEMAP_ROUTES.lineage); + const locs = extractLocs(xml); + + expect(locs).toContain("https://lineage.freno.me/"); + expect(locs).toContain("https://lineage.freno.me/contact"); + expect(locs).toContain("https://lineage.freno.me/privacy"); + expect(locs).toContain("https://lineage.freno.me/downloads"); + expect(locs).toContain("https://lineage.freno.me/deletion"); + expect(locs.length).toBe(5); + }); + + it("generates correct URLs for gaze site", () => { + const xml = generateSitemap(SITE_CONFIG.gaze, SITEMAP_ROUTES.gaze); + const locs = extractLocs(xml); + + expect(locs).toContain("https://gaze.freno.me/"); + expect(locs).toContain("https://gaze.freno.me/contact"); + expect(locs).toContain("https://gaze.freno.me/privacy"); + expect(locs.length).toBe(3); + }); + + it("generates correct URLs for inputhalo site", () => { + const xml = generateSitemap(SITE_CONFIG.inputhalo, SITEMAP_ROUTES.inputhalo); + const locs = extractLocs(xml); + + expect(locs).toContain("https://inputhalo.freno.me/"); + expect(locs).toContain("https://inputhalo.freno.me/contact"); + expect(locs).toContain("https://inputhalo.freno.me/privacy"); + expect(locs.length).toBe(3); + }); + + it("escapes special XML characters in URLs", () => { + const xml = generateSitemap(SITE_CONFIG.main, [ + { path: "/test?a=1&b=2", changefreq: "weekly", priority: 0.5 } + ]); + expect(xml).toContain("a=1&b=2"); + }); +}); + +describe("SITEMAP_ROUTES validation", () => { + it("all entries have paths starting with /", () => { + for (const [siteId, entries] of Object.entries(SITEMAP_ROUTES)) { + for (const entry of entries) { + expect(entry.path).toMatch(/^\//); + } + } + }); + + it("all entries have priority between 0 and 1", () => { + for (const [siteId, entries] of Object.entries(SITEMAP_ROUTES)) { + for (const entry of entries) { + expect(entry.priority).toBeGreaterThanOrEqual(0); + expect(entry.priority).toBeLessThanOrEqual(1); + } + } + }); + + it("main site has the original 4 entries plus resume and downloads", () => { + const mainPaths = SITEMAP_ROUTES.main.map((e) => e.path); + expect(mainPaths).toContain("/"); + expect(mainPaths).toContain("/blog"); + expect(mainPaths).toContain("/contact"); + expect(mainPaths).toContain("/login"); + expect(mainPaths).toContain("/resume"); + expect(mainPaths).toContain("/downloads"); + }); + + it("each site has at least the home page entry", () => { + const siteIds: SiteId[] = ["main", "nessa", "lineage", "gaze", "inputhalo"]; + for (const id of siteIds) { + expect(SITEMAP_ROUTES[id].some((e) => e.path === "/")).toBe(true); + } + }); +}); diff --git a/src/lib/sitemap-routes.ts b/src/lib/sitemap-routes.ts new file mode 100644 index 0000000..02324ac --- /dev/null +++ b/src/lib/sitemap-routes.ts @@ -0,0 +1,74 @@ +/** + * Centralized sitemap route registry per site. + * + * Each entry describes a URL path that should appear in the corresponding + * site's `sitemap.xml`, along with SEO metadata (change frequency and + * relative priority). + * + * This module is pure — no imports of server-side code — so it can be + * used in unit tests and from both server and client contexts. + */ +import type { SiteId } from "./site-context"; + +export interface SitemapEntry { + /** + * Absolute path on the site's domain (must start with `/`). + */ + path: string; + + /** + * Expected change frequency. + */ + changefreq: "always" | "hourly" | "daily" | "weekly" | "monthly" | "yearly" | "never"; + + /** + * Relative priority (0.0–1.0). + */ + priority: number; +} + +/** + * Per-site sitemap route definitions. + * + * Entries for subdomain pages (contact, privacy, downloads, etc.) are + * populated as those pages are built in tasks 05–11. + */ +export const SITEMAP_ROUTES: Record = { + main: [ + { path: "/", changefreq: "weekly", priority: 1.0 }, + { path: "/blog", changefreq: "daily", priority: 0.9 }, + { path: "/contact", changefreq: "monthly", priority: 0.7 }, + { path: "/login", changefreq: "monthly", priority: 0.5 }, + { path: "/resume", changefreq: "yearly", priority: 0.6 }, + { path: "/downloads", changefreq: "weekly", priority: 0.8 } + ], + + // ── Subdomain sites ────────────────────────────────────────────────── + // Populated as pages land in tasks 05–11. + + nessa: [ + { path: "/", changefreq: "weekly", priority: 1.0 }, + { path: "/contact", changefreq: "monthly", priority: 0.6 }, + { path: "/privacy", changefreq: "yearly", priority: 0.4 } + ], + + lineage: [ + { path: "/", changefreq: "weekly", priority: 1.0 }, + { path: "/contact", changefreq: "monthly", priority: 0.6 }, + { path: "/privacy", changefreq: "yearly", priority: 0.4 }, + { path: "/downloads", changefreq: "weekly", priority: 0.8 }, + { path: "/deletion", changefreq: "yearly", priority: 0.3 } + ], + + gaze: [ + { path: "/", changefreq: "weekly", priority: 1.0 }, + { path: "/contact", changefreq: "monthly", priority: 0.6 }, + { path: "/privacy", changefreq: "yearly", priority: 0.4 } + ], + + inputhalo: [ + { path: "/", changefreq: "weekly", priority: 1.0 }, + { path: "/contact", changefreq: "monthly", priority: 0.6 }, + { path: "/privacy", changefreq: "yearly", priority: 0.4 } + ] +}; diff --git a/src/routes/sitemap.xml.ts b/src/routes/sitemap.xml.ts index 55d4b3b..b9e516d 100644 --- a/src/routes/sitemap.xml.ts +++ b/src/routes/sitemap.xml.ts @@ -1,35 +1,21 @@ +/** + * Host-aware sitemap.xml route handler (task 03). + * + * Reads the `Host` header to determine the active site, then generates a + * sitemap scoped to that site's routes with canonical URLs from the + * corresponding domain. + */ import { APIEvent } from "@solidjs/start/server"; +import { getSiteFromEvent } from "~/server/site-context-server"; +import { SITEMAP_ROUTES } from "~/lib/sitemap-routes"; +import { generateSitemap } from "~/lib/sitemap-generate"; export async function GET(event: APIEvent) { - const sitemap = ` - - - https://www.freno.me - ${new Date().toISOString()} - weekly - 1.0 - - - https://www.freno.me/blog - ${new Date().toISOString()} - daily - 0.9 - - - https://www.freno.me/contact - ${new Date().toISOString()} - monthly - 0.7 - - - https://www.freno.me/login - ${new Date().toISOString()} - monthly - 0.5 - -`; + const site = getSiteFromEvent(event); + const entries = SITEMAP_ROUTES[site.id] ?? []; + const xml = generateSitemap(site, entries); - return new Response(sitemap, { + return new Response(xml, { headers: { "Content-Type": "application/xml", "Cache-Control": "public, max-age=3600"