Merge branch 'per-subdomain-sitemaps'

This commit is contained in:
2026-07-23 09:52:53 -04:00
4 changed files with 287 additions and 28 deletions

View File

@@ -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, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}
/**
* Generate a single `<url>` element for a given entry on a site.
*/
export function urlElement(site: Site, entry: SitemapEntry): string {
const loc = `https://${site.domain}${entry.path}`;
return ` <url>
<loc>${xmlEscape(loc)}</loc>
<lastmod>${new Date().toISOString()}</lastmod>
<changefreq>${xmlEscape(entry.changefreq)}</changefreq>
<priority>${entry.priority.toFixed(1)}</priority>
</url>`;
}
/**
* 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 `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${urls}
</urlset>`;
}

View File

@@ -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 `<loc>` 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 <loc> values
function extractLocs(xml: string): string[] {
const matches: string[] = [];
const re = /<loc>([^<]+)<\/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('<?xml version="1.0" encoding="UTF-8"?>');
expect(xml).toContain('<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">');
// 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("</urlset>");
const urlOpens = (xml.match(/<url>/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("</urlset>");
const urlOpens = (xml.match(/<url>/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&amp;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);
}
});
});

74
src/lib/sitemap-routes.ts Normal file
View File

@@ -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.01.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 0511.
*/
export const SITEMAP_ROUTES: Record<SiteId, SitemapEntry[]> = {
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 0511.
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 }
]
};

View File

@@ -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 { 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) { export async function GET(event: APIEvent) {
const sitemap = `<?xml version="1.0" encoding="UTF-8"?> const site = getSiteFromEvent(event);
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> const entries = SITEMAP_ROUTES[site.id] ?? [];
<url> const xml = generateSitemap(site, entries);
<loc>https://www.freno.me</loc>
<lastmod>${new Date().toISOString()}</lastmod>
<changefreq>weekly</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://www.freno.me/blog</loc>
<lastmod>${new Date().toISOString()}</lastmod>
<changefreq>daily</changefreq>
<priority>0.9</priority>
</url>
<url>
<loc>https://www.freno.me/contact</loc>
<lastmod>${new Date().toISOString()}</lastmod>
<changefreq>monthly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>https://www.freno.me/login</loc>
<lastmod>${new Date().toISOString()}</lastmod>
<changefreq>monthly</changefreq>
<priority>0.5</priority>
</url>
</urlset>`;
return new Response(sitemap, { return new Response(xml, {
headers: { headers: {
"Content-Type": "application/xml", "Content-Type": "application/xml",
"Cache-Control": "public, max-age=3600" "Cache-Control": "public, max-age=3600"