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.
This commit is contained in:
44
src/lib/sitemap-generate.ts
Normal file
44
src/lib/sitemap-generate.ts
Normal 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, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
/**
|
||||
* 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>`;
|
||||
}
|
||||
155
src/lib/sitemap-routes.test.ts
Normal file
155
src/lib/sitemap-routes.test.ts
Normal 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&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
74
src/lib/sitemap-routes.ts
Normal 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.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<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 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 }
|
||||
]
|
||||
};
|
||||
@@ -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 = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<url>
|
||||
<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>`;
|
||||
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"
|
||||
|
||||
Reference in New Issue
Block a user