feat: add subdomain-based site context with SSR resolution

Resolve the active site from the request Host header on the server and
expose it through a SiteContext provider so the app can vary rendering
per subdomain. Serialize the resolved site into the document shell
(data-site attribute + window.__SITE__) so client hydration matches the
server render. Add host-based Vercel rewrites mapping each subdomain to
its site root, and include unit tests for the site resolution logic.
This commit is contained in:
2026-07-23 09:18:55 -04:00
parent 0385090f32
commit 92890ce259
7 changed files with 552 additions and 35 deletions

View File

@@ -0,0 +1,99 @@
/**
* Unit tests for the shared site-context resolver (task 01).
*
* `resolveSiteFromHost` is pure — no env / no I/O — so the cases below are
* straightforward synchronous assertions mirroring the acceptance matrix in
* the task spec.
*/
import { describe, it, expect } from "bun:test";
import {
resolveSiteFromHost,
SITE_CONFIG,
type SiteId
} from "./site-context";
function expectSite(host: string, id: SiteId) {
expect(resolveSiteFromHost(host).id).toBe(id);
}
describe("resolveSiteFromHost", () => {
it("maps each product subdomain to its own site config", () => {
expectSite("nessa.freno.me", "nessa");
expectSite("lineage.freno.me", "lineage");
expectSite("gaze.freno.me", "gaze");
expectSite("inputhalo.freno.me", "inputhalo");
});
it("maps the apex and www hosts to main", () => {
expectSite("freno.me", "main");
expectSite("www.freno.me", "main");
});
it("falls back to main for unknown subdomains", () => {
expectSite("unknown.freno.me", "main");
expectSite("blog.freno.me", "main");
});
it("handles ports", () => {
expectSite("freno.me:3000", "main");
expectSite("nessa.freno.me:8787", "nessa");
expectSite("www.freno.me:443", "main");
});
it("handles localhost dev hosts", () => {
expectSite("localhost", "main");
expectSite("localhost:3000", "main");
expectSite("nessa.localhost:3000", "nessa");
expectSite("nessa.localhost", "nessa");
expectSite("gaze.localhost", "gaze");
expectSite("lineage.localhost", "lineage");
expectSite("inputhalo.localhost", "inputhalo");
});
it("treats unknown *.localhost as main", () => {
expectSite("wat.localhost", "main");
});
it("handles empty / null / undefined hosts by returning main", () => {
expect(resolveSiteFromHost("").id).toBe("main");
expect(resolveSiteFromHost(null).id).toBe("main");
expect(resolveSiteFromHost(undefined).id).toBe("main");
expect(resolveSiteFromHost(" ").id).toBe("main");
});
it("case-insensitively normalizes hosts", () => {
expectSite("NeSsA.Freno.Me", "nessa");
expectSite("WWW.Freno.Me", "main");
expectSite("Gaze.LOCALHOST:3000", "gaze");
});
it("preserves exact dot-match semantics (no prefix bleed)", () => {
// `x-nessa.freno.me` must NOT match `nessa.freno.me`.
expectSite("x-nessa.freno.me", "main");
expectSite("notgaze.freno.me", "main");
});
it("returns the matching SITE_CONFIG entry (full object, not just id)", () => {
expect(resolveSiteFromHost("nessa.freno.me")).toEqual(SITE_CONFIG.nessa);
expect(resolveSiteFromHost("gaze.freno.me")).toEqual(SITE_CONFIG.gaze);
expect(resolveSiteFromHost("freno.me")).toEqual(SITE_CONFIG.main);
});
it("every SITE_CONFIG entry has a non-empty baseRoutePrefix for subdomains", () => {
for (const id of [
"nessa",
"lineage",
"gaze",
"inputhalo"
] as SiteId[]) {
expect(SITE_CONFIG[id].baseRoutePrefix).toBe(`/${id}`);
expect(SITE_CONFIG[id].subdomain).toBe(id);
expect(SITE_CONFIG[id].titleSuffix).toBe(` | ${SITE_CONFIG[id].displayName}`);
}
});
it("main has empty subdomain and empty baseRoutePrefix", () => {
expect(SITE_CONFIG.main.subdomain).toBe("");
expect(SITE_CONFIG.main.baseRoutePrefix).toBe("");
});
});

171
src/lib/site-context.ts Normal file
View File

@@ -0,0 +1,171 @@
/**
* Shared site definitions and host-to-site resolver.
*
* Pure module — intentionally imports NO env / server-only code — so it is
* safe to import from both server and client (and from unit tests).
*
* This is the keystone of the subdomain-routing feature (task 01). Every
* content task (05-11) consumes `SITE_CONFIG` metadata via `useSite()`,
* and the server-side host detection in
* `src/server/site-context-server.ts` builds on `resolveSiteFromHost`.
*/
export type SiteId = "main" | "nessa" | "lineage" | "gaze" | "inputhalo";
export interface Site {
/** Canonical id, also serialized into `<html data-site>` and `window.__SITE__`. */
id: SiteId;
/** Subdomain label, e.g. `"nessa"`. Empty string for the main site. */
subdomain: string;
/** Fully-qualified domain, e.g. `"nessa.freno.me"`. `"freno.me"` for main. */
domain: string;
/**
* Internal route prefix the vercel.json host rewrite targets. SolidStart
* file-routing places subdomain pages under `src/routes/<prefix>/*`.
* Empty string for main.
*/
baseRoutePrefix: string;
/** Human-friendly brand / product name. */
displayName: string;
/** Appended to page titles, e.g. `" | Nessa"`. */
titleSuffix: string;
/** Hex brand color used for theming accents / OG image backgrounds. */
brandColor: string;
/** Default OpenGraph image path (resolved against the site root). */
ogDefaultImage: string;
/** Favicon path for this site. */
faviconPath: string;
}
export const SITE_CONFIG: Record<SiteId, Site> = {
main: {
id: "main",
subdomain: "",
domain: "freno.me",
baseRoutePrefix: "",
displayName: "Michael Freno",
titleSuffix: " | Michael Freno",
brandColor: "#89b4fa",
ogDefaultImage: "/blueprint.jpg",
faviconPath: "/favicon.ico"
},
nessa: {
id: "nessa",
subdomain: "nessa",
domain: "nessa.freno.me",
baseRoutePrefix: "/nessa",
displayName: "Nessa",
titleSuffix: " | Nessa",
brandColor: "#cba6f7",
ogDefaultImage: "/nessa/og-default.png",
faviconPath: "/nessa/favicon.ico"
},
lineage: {
id: "lineage",
subdomain: "lineage",
domain: "lineage.freno.me",
baseRoutePrefix: "/lineage",
displayName: "Life and Lineage",
titleSuffix: " | Life and Lineage",
brandColor: "#a6e3a1",
ogDefaultImage: "/lineage/og-default.png",
faviconPath: "/lineage/favicon.ico"
},
gaze: {
id: "gaze",
subdomain: "gaze",
domain: "gaze.freno.me",
baseRoutePrefix: "/gaze",
displayName: "Gaze",
titleSuffix: " | Gaze",
brandColor: "#f9e2af",
ogDefaultImage: "/gaze/og-default.png",
faviconPath: "/gaze/favicon.ico"
},
inputhalo: {
id: "inputhalo",
subdomain: "inputhalo",
domain: "inputhalo.freno.me",
baseRoutePrefix: "/inputhalo",
displayName: "InputHalo",
titleSuffix: " | InputHalo",
brandColor: "#f38ba8",
ogDefaultImage: "/inputhalo/og-default.png",
faviconPath: "/inputhalo/favicon.ico"
}
};
/** Ordered subdomain sites used for host matching. */
const SUBDOMAIN_SITES: ReadonlyArray<Site> = [
SITE_CONFIG.nessa,
SITE_CONFIG.lineage,
SITE_CONFIG.gaze,
SITE_CONFIG.inputhalo
];
const BASE_DOMAIN = "freno.me";
/** Matches `<sub>.localhost` and `<sub>.localhost:<port>` (dev only). */
const DEV_HOST_RE = /^([a-z0-9-]+)\.localhost$/i;
export const MAIN_SITE: Site = SITE_CONFIG.main;
/**
* Resolve a `Site` from a raw `Host` header value (or hostname).
*
* Handles:
* - exact product subdomains (`nessa.freno.me` → nessa)
* - `www.` prefix (`www.freno.me` → main)
* - the bare apex (`freno.me` → main)
* - port suffixes (`freno.me:3000` → main)
* - localhost dev (`localhost` / `localhost:3000` → main)
* - subdomain dev (`nessa.localhost` / `nessa.localhost:3000` → nessa)
* - unknown hosts / unknown subdomains → main (fail-safe default)
*
* Pure & synchronous — no I/O, no env access.
*/
export function resolveSiteFromHost(host: string | null | undefined): Site {
if (!host) return MAIN_SITE;
// Normalize: trim, lowercase, strip optional `:port` suffix.
const normalized = host.trim().toLowerCase().replace(/:\d+$/, "");
if (!normalized) return MAIN_SITE;
// Strip a leading `www.` so `www.freno.me` behaves like `freno.me`.
const withoutWww = normalized.replace(/^www\./, "");
if (withoutWww === BASE_DOMAIN) return MAIN_SITE;
// Exact subdomain.<base> match.
for (const site of SUBDOMAIN_SITES) {
if (withoutWww === `${site.subdomain}.${BASE_DOMAIN}`) return site;
}
// Dev pattern: <sub>.localhost[:port] (browsers resolve `*.localhost`).
const devMatch = normalized.match(DEV_HOST_RE);
if (devMatch) {
const sub = devMatch[1]!.toLowerCase();
for (const site of SUBDOMAIN_SITES) {
if (sub === site.subdomain) return site;
}
// `localhost` alone or unknown `<x>.localhost` → main.
return MAIN_SITE;
}
// Unknown `*.freno.me` (e.g. a future subdomain not yet configured) → main.
if (withoutWww.endsWith(`.${BASE_DOMAIN}`)) return MAIN_SITE;
// Anything else entirely (IPs, foreign hosts) → main as a safe default.
return MAIN_SITE;
}
/**
* Resolve the active site from a client `window.location`, used by the
* SolidJS `SiteContext` provider during hydration. Server codepaths should
* use `getSiteFromEvent` / `getSiteFromRequest` instead.
*/
export function resolveSiteFromLocation(
hostname: string | null | undefined
): Site {
return resolveSiteFromHost(hostname);
}