Merge branch 'per-subdomain-sitemaps'
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 { 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"
|
||||||
|
|||||||
Reference in New Issue
Block a user