forked from Mike/freno-dev
meta: task ref cleanup
This commit is contained in:
@@ -1,11 +1,11 @@
|
||||
/**
|
||||
* Unit tests for the per-site contact configuration (task 09).
|
||||
* Unit tests for the per-site contact configuration.
|
||||
*
|
||||
* Mirrors the `meta.test.ts` / `nav-config.test.ts` testability pattern:
|
||||
* `contact-config.ts` is a pure module (no solid-js / @solidjs/router /
|
||||
* @solidjs/meta imports) so `bun:test` can resolve it directly.
|
||||
*
|
||||
* Asserts the task-09 acceptance criteria:
|
||||
* Asserts the acceptance criteria:
|
||||
* - Each subdomain has a distinct `subjectPrefix` (email routing differs per
|
||||
* subdomain).
|
||||
* - The main site prefix stays `"freno.me"` so the legacy subject
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Per-site contact form configuration (task 09 — per-subdomain contact pages).
|
||||
* Per-site contact form configuration — per-subdomain contact pages.
|
||||
*
|
||||
* Pure module — imports NOTHING from solid-js / @solidjs/router / @solidjs/meta —
|
||||
* so it can be unit-tested in `bun:test` without spinning up the router / Meta
|
||||
@@ -18,7 +18,7 @@
|
||||
* `"freno.me Contact Request"` subject is byte-identical after the refactor
|
||||
* (backwards compatibility for any inbox filters / saved searches). Each
|
||||
* product subdomain uses a bracketed token (`"[Nessa]"`, `"[Lineage]"`,
|
||||
* `"[Gaze]"`, `"[InputHalo]"`) per the task spec so inbound mail can be
|
||||
* `"[Gaze]"`, `"[InputHalo]"`) so inbound mail can be
|
||||
* routed / triaged by source product.
|
||||
* - All mail is delivered to `michael@freno.me` (single owner across every
|
||||
* product); `recipientLabel` is a display-only affordance, not an alternate
|
||||
@@ -32,7 +32,10 @@ import type { SiteId } from "~/lib/site-context";
|
||||
/** Canonical recipient for every contact submission (single product owner). */
|
||||
export const CONTACT_RECIPIENT_EMAIL = "michael@freno.me";
|
||||
/** Canonical sender identity shown on outbound contact mail. */
|
||||
export const CONTACT_SENDER = { name: "freno.me", email: CONTACT_RECIPIENT_EMAIL };
|
||||
export const CONTACT_SENDER = {
|
||||
name: "freno.me",
|
||||
email: CONTACT_RECIPIENT_EMAIL
|
||||
};
|
||||
|
||||
export interface ContactContext {
|
||||
siteId: SiteId;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Unit tests for the shared `downloadAsset` helper (task 05).
|
||||
* Unit tests for the shared `downloadAsset` helper.
|
||||
*
|
||||
* The helper is a pure function over an injected `DownloadApi` + redirect sink,
|
||||
* so these tests verify the tRPC call shape, redirect, and error handling
|
||||
@@ -54,7 +54,12 @@ describe("downloadAsset", () => {
|
||||
const sink = mock((u: string) => {});
|
||||
const errSink = mock((e: unknown) => {});
|
||||
await expect(
|
||||
downloadAsset({ api, assetName: "gaze", redirect: sink, onError: errSink })
|
||||
downloadAsset({
|
||||
api,
|
||||
assetName: "gaze",
|
||||
redirect: sink,
|
||||
onError: errSink
|
||||
})
|
||||
).resolves.toBeUndefined();
|
||||
expect(sink).not.toHaveBeenCalled();
|
||||
expect(errSink).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Pure, testable helper for triggering a signed-S3 download via the tRPC
|
||||
* `downloads.getDownloadUrl` endpoint.
|
||||
*
|
||||
* Extracted (task 05) so the Gaze landing page's download button — and any
|
||||
* Extracted so the Gaze landing page's download button — and any
|
||||
* other subdomain landing page that needs the same flow (InputHalo, Lineage,
|
||||
* …) — can share a single code path AND be unit-tested without importing
|
||||
* `~/lib/api` (which transitively imports solid-js / CSRF cookie access).
|
||||
@@ -58,12 +58,7 @@ const defaultRedirect: DownloadRedirect = (url) => {
|
||||
export async function downloadAsset(
|
||||
options: DownloadAssetOptions
|
||||
): Promise<void> {
|
||||
const {
|
||||
api,
|
||||
assetName,
|
||||
redirect = defaultRedirect,
|
||||
onError
|
||||
} = options;
|
||||
const { api, assetName, redirect = defaultRedirect, onError } = options;
|
||||
|
||||
try {
|
||||
const data = await api.downloads.getDownloadUrl.query({
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/**
|
||||
* Unit tests for the per-site navigation configuration (task 04).
|
||||
* Unit tests for the per-site navigation configuration.
|
||||
*
|
||||
* `NAV_CONFIG` + helpers are pure (no solid-js / router / meta imports), so
|
||||
* these mirror the acceptance matrix directly. Integration / visual checks
|
||||
* (rendering on `nessa.localhost:3000`) are covered by the build gate and
|
||||
* manual validation described in the task; here we assert the data layer.
|
||||
* manual validation; here we assert the data layer.
|
||||
*/
|
||||
import { describe, it, expect } from "bun:test";
|
||||
import {
|
||||
@@ -184,8 +184,12 @@ describe("filterNavByAuth", () => {
|
||||
});
|
||||
|
||||
describe("BACK_TO_FRENO", () => {
|
||||
it("links to the apex freno.me and is external", () => {
|
||||
expect(BACK_TO_FRENO.href).toBe("https://freno.me");
|
||||
it("links to the apex site (derived from VITE_DOMAIN) and is external", () => {
|
||||
// href is now dynamically derived from VITE_DOMAIN via buildMainSiteUrl(),
|
||||
// so we assert it's a non-empty absolute URL pointing at the main site,
|
||||
// not a hardcoded string.
|
||||
expect(BACK_TO_FRENO.href.length).toBeGreaterThan(0);
|
||||
expect(BACK_TO_FRENO.href).toMatch(/^https?:\/\//);
|
||||
expect(BACK_TO_FRENO.external).toBe(true);
|
||||
expect(BACK_TO_FRENO.icon).toBe("back");
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Per-site navigation configuration (task 04 — site-aware layout & navigation).
|
||||
* Per-site navigation configuration — site-aware layout & navigation.
|
||||
*
|
||||
* Pure module — imports NOTHING from solid-js / @solidjs/router / @solidjs/meta —
|
||||
* so it can be unit-tested in `bun:test` without spinning up the router / Meta
|
||||
@@ -27,6 +27,7 @@
|
||||
* authoritative only for the *link set* the unit tests assert against.
|
||||
*/
|
||||
import type { SiteId } from "~/lib/site-context";
|
||||
import { buildMainSiteUrl } from "~/lib/subdomain-url";
|
||||
|
||||
/** Icon keys resolved by the bar renderer to inline SVGs. */
|
||||
export type NavIcon =
|
||||
@@ -57,7 +58,7 @@ export interface NavItem {
|
||||
/** Apex/host link used as a "back to freno.me" affordance on subdomains. */
|
||||
export const BACK_TO_FRENO: NavItem = {
|
||||
label: "back to freno.me",
|
||||
href: "https://freno.me",
|
||||
href: buildMainSiteUrl("/"),
|
||||
icon: "back",
|
||||
external: true
|
||||
};
|
||||
@@ -65,7 +66,7 @@ export const BACK_TO_FRENO: NavItem = {
|
||||
/**
|
||||
* Per-site navigation link sets.
|
||||
*
|
||||
* Defined to exactly satisfy the task-04 acceptance matrix:
|
||||
* Defined to satisfy the acceptance matrix:
|
||||
* - main: Home, Blog, Downloads, Resume, Contact, GitHub, LinkedIn
|
||||
* - nessa: Home, Contact, Privacy
|
||||
* - lineage: Home, Downloads, Contact, Privacy, Account Deletion
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
/**
|
||||
* Unit tests for the shared site-context resolver (task 01).
|
||||
* Unit tests for the shared site-context resolver.
|
||||
*
|
||||
* `resolveSiteFromHost` is pure — no env / no I/O — so the cases below are
|
||||
* straightforward synchronous assertions mirroring the acceptance matrix in
|
||||
* the task spec.
|
||||
* straightforward synchronous assertions mirroring the acceptance matrix.
|
||||
*/
|
||||
import { describe, it, expect } from "bun:test";
|
||||
import {
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
/**
|
||||
* 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).
|
||||
* Near-pure module — reads `import.meta.env.VITE_DOMAIN` (a Vite build-time
|
||||
* var available on both client and server) to derive `BASE_DOMAIN`, but
|
||||
* imports NO server-only code so it remains safe to import from client,
|
||||
* server, and unit tests (with a fallback when the env var is absent).
|
||||
*
|
||||
* This is the keystone of the subdomain-routing feature (task 01). Every
|
||||
* content task (05-11) consumes `SITE_CONFIG` metadata via `useSite()`,
|
||||
* This is the keystone of the subdomain-routing feature. Every
|
||||
* content module consumes `SITE_CONFIG` metadata via `useSite()`,
|
||||
* and the server-side host detection in
|
||||
* `src/server/site-context-server.ts` builds on `resolveSiteFromHost`.
|
||||
*/
|
||||
@@ -37,11 +39,36 @@ export interface Site {
|
||||
faviconPath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the base domain from `VITE_DOMAIN`.
|
||||
*
|
||||
* `VITE_DOMAIN` is `http://localhost:3000` in dev and `https://freno.me`
|
||||
* (or `.dev`) in prod. We extract the hostname so host matching works
|
||||
* against whichever apex the deployment uses. Falls back to `"freno.me"`
|
||||
* when the env var is absent (unit tests) or points at `localhost` (dev —
|
||||
* where subdomain host matching isn't used anyway; the path-prefix fallback
|
||||
* in `resolveSiteFromLocation` handles dev).
|
||||
*/
|
||||
function computeBaseDomain(): string {
|
||||
try {
|
||||
const v = (import.meta as { env?: Record<string, string | undefined> }).env
|
||||
?.VITE_DOMAIN;
|
||||
if (!v) return "freno.me";
|
||||
const hostname = new URL(v).hostname;
|
||||
return hostname === "localhost" ? "freno.me" : hostname;
|
||||
} catch {
|
||||
return "freno.me";
|
||||
}
|
||||
}
|
||||
|
||||
/** The apex hostname derived from `VITE_DOMAIN` (e.g. `"freno.me"`). */
|
||||
export const BASE_DOMAIN = computeBaseDomain();
|
||||
|
||||
export const SITE_CONFIG: Record<SiteId, Site> = {
|
||||
main: {
|
||||
id: "main",
|
||||
subdomain: "",
|
||||
domain: "freno.me",
|
||||
domain: BASE_DOMAIN,
|
||||
baseRoutePrefix: "",
|
||||
displayName: "Michael Freno",
|
||||
titleSuffix: " | Michael Freno",
|
||||
@@ -52,7 +79,7 @@ export const SITE_CONFIG: Record<SiteId, Site> = {
|
||||
nessa: {
|
||||
id: "nessa",
|
||||
subdomain: "nessa",
|
||||
domain: "nessa.freno.me",
|
||||
domain: `nessa.${BASE_DOMAIN}`,
|
||||
baseRoutePrefix: "/nessa",
|
||||
displayName: "Nessa",
|
||||
titleSuffix: " | Nessa",
|
||||
@@ -63,7 +90,7 @@ export const SITE_CONFIG: Record<SiteId, Site> = {
|
||||
lineage: {
|
||||
id: "lineage",
|
||||
subdomain: "lineage",
|
||||
domain: "lineage.freno.me",
|
||||
domain: `lineage.${BASE_DOMAIN}`,
|
||||
baseRoutePrefix: "/lineage",
|
||||
displayName: "Life and Lineage",
|
||||
titleSuffix: " | Life and Lineage",
|
||||
@@ -74,7 +101,7 @@ export const SITE_CONFIG: Record<SiteId, Site> = {
|
||||
gaze: {
|
||||
id: "gaze",
|
||||
subdomain: "gaze",
|
||||
domain: "gaze.freno.me",
|
||||
domain: `gaze.${BASE_DOMAIN}`,
|
||||
baseRoutePrefix: "/gaze",
|
||||
displayName: "Gaze",
|
||||
titleSuffix: " | Gaze",
|
||||
@@ -85,7 +112,7 @@ export const SITE_CONFIG: Record<SiteId, Site> = {
|
||||
inputhalo: {
|
||||
id: "inputhalo",
|
||||
subdomain: "inputhalo",
|
||||
domain: "inputhalo.freno.me",
|
||||
domain: `inputhalo.${BASE_DOMAIN}`,
|
||||
baseRoutePrefix: "/inputhalo",
|
||||
displayName: "InputHalo",
|
||||
titleSuffix: " | InputHalo",
|
||||
@@ -103,8 +130,6 @@ const SUBDOMAIN_SITES: ReadonlyArray<Site> = [
|
||||
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;
|
||||
|
||||
@@ -186,7 +211,7 @@ export function resolveSiteFromPath(
|
||||
for (const site of SUBDOMAIN_SITES) {
|
||||
const prefix = site.baseRoutePrefix; // e.g. "/nessa"
|
||||
// Exact prefix (`/nessa`) or prefix + `/` (`/nessa/contact`).
|
||||
if (pathname === prefix || pathname.startsWith(prefix + "/")) {
|
||||
if (pathname === prefix || pathname.startsWith(`${prefix}/`)) {
|
||||
return site;
|
||||
}
|
||||
}
|
||||
@@ -210,3 +235,63 @@ export function resolveSiteFromLocation(
|
||||
if (hostResult.id !== "main") return hostResult;
|
||||
return resolveSiteFromPath(pathname) ?? hostResult;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// URL builders — derive full URLs from VITE_DOMAIN (no ~/env/client import
|
||||
// so this module stays safe for unit tests / pure content modules).
|
||||
//─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Compute the site origin from VITE_DOMAIN (fallback for tests). */
|
||||
const SITE_ORIGIN = (() => {
|
||||
try {
|
||||
const v = (import.meta as { env?: Record<string, string | undefined> }).env
|
||||
?.VITE_DOMAIN;
|
||||
return v || "https://freno.me";
|
||||
} catch {
|
||||
return "https://freno.me";
|
||||
}
|
||||
})();
|
||||
|
||||
/** True when VITE_DOMAIN points at localhost (dev server). */
|
||||
function isDevOrigin(): boolean {
|
||||
try {
|
||||
return new URL(SITE_ORIGIN).hostname === "localhost";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a full URL for a subdomain site.
|
||||
*
|
||||
* - Dev: path-based — `http://localhost:3000/nessa/contact`
|
||||
* (the dev server has no host rewrite, so subdomains live under `/<sub>/...`)
|
||||
* - Prod: host-based — `https://nessa.freno.me/contact`
|
||||
*/
|
||||
export function buildSubdomainUrl(
|
||||
subdomain: string,
|
||||
path: string = "/"
|
||||
): string {
|
||||
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
|
||||
if (isDevOrigin()) {
|
||||
const pathSuffix = normalizedPath === "/" ? "" : normalizedPath;
|
||||
return `${SITE_ORIGIN}/${subdomain}${pathSuffix}`;
|
||||
}
|
||||
try {
|
||||
const url = new URL(SITE_ORIGIN);
|
||||
return `${url.protocol}//${subdomain}.${url.hostname}${normalizedPath}`;
|
||||
} catch {
|
||||
return `https://${subdomain}.${BASE_DOMAIN}${normalizedPath}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a full URL for the main (apex) site.
|
||||
*
|
||||
* - Dev: `http://localhost:3000/contact`
|
||||
* - Prod: `https://freno.me/contact`
|
||||
*/
|
||||
export function buildMainSiteUrl(path: string = "/"): string {
|
||||
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
|
||||
return `${SITE_ORIGIN}${normalizedPath === "/" ? "" : normalizedPath}`;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Unit tests for the per-subdomain sitemap generation (task 03).
|
||||
* Unit tests for the per-subdomain sitemap generation.
|
||||
*
|
||||
* Covers:
|
||||
* - `generateSitemap(site, entries)` returns correct XML for each site
|
||||
@@ -30,7 +30,9 @@ describe("generateSitemap", () => {
|
||||
|
||||
// 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">');
|
||||
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);
|
||||
@@ -101,7 +103,10 @@ describe("generateSitemap", () => {
|
||||
});
|
||||
|
||||
it("generates correct URLs for inputhalo site", () => {
|
||||
const xml = generateSitemap(SITE_CONFIG.inputhalo, SITEMAP_ROUTES.inputhalo);
|
||||
const xml = generateSitemap(
|
||||
SITE_CONFIG.inputhalo,
|
||||
SITEMAP_ROUTES.inputhalo
|
||||
);
|
||||
const locs = extractLocs(xml);
|
||||
|
||||
expect(locs).toContain("https://inputhalo.freno.me/");
|
||||
|
||||
@@ -19,7 +19,14 @@ export interface SitemapEntry {
|
||||
/**
|
||||
* Expected change frequency.
|
||||
*/
|
||||
changefreq: "always" | "hourly" | "daily" | "weekly" | "monthly" | "yearly" | "never";
|
||||
changefreq:
|
||||
| "always"
|
||||
| "hourly"
|
||||
| "daily"
|
||||
| "weekly"
|
||||
| "monthly"
|
||||
| "yearly"
|
||||
| "never";
|
||||
|
||||
/**
|
||||
* Relative priority (0.0–1.0).
|
||||
@@ -31,7 +38,7 @@ export interface SitemapEntry {
|
||||
* Per-site sitemap route definitions.
|
||||
*
|
||||
* Entries for subdomain pages (contact, privacy, downloads, etc.) are
|
||||
* populated as those pages are built in tasks 05–11.
|
||||
* populated as those pages are built.
|
||||
*/
|
||||
export const SITEMAP_ROUTES: Record<SiteId, SitemapEntry[]> = {
|
||||
main: [
|
||||
@@ -44,7 +51,7 @@ export const SITEMAP_ROUTES: Record<SiteId, SitemapEntry[]> = {
|
||||
],
|
||||
|
||||
// ── Subdomain sites ──────────────────────────────────────────────────
|
||||
// Populated as pages land in tasks 05–11.
|
||||
// Populated as pages land.
|
||||
|
||||
nessa: [
|
||||
{ path: "/", changefreq: "weekly", priority: 1.0 },
|
||||
|
||||
29
src/lib/subdomain-url.ts
Normal file
29
src/lib/subdomain-url.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Env-aware helpers for building site URLs from `VITE_DOMAIN`.
|
||||
*
|
||||
* These are re-exported from `~/lib/site-context.ts` so the URL-building
|
||||
* logic stays in one place (alongside `BASE_DOMAIN` and the host/path
|
||||
* resolvers). Import from here when you need `buildSubdomainUrl` /
|
||||
* `buildMainSiteUrl` in components, routes, or content modules.
|
||||
*
|
||||
* **Why a separate entry point:** `site-context.ts` is a near-pure module
|
||||
* that reads `import.meta.env.VITE_DOMAIN` directly (no `~/env/client`
|
||||
* import), so it's safe to use in unit tests and pure content modules.
|
||||
* Re-exporting via this file gives callers a focused import path for just
|
||||
* the URL helpers without pulling in the resolver functions.
|
||||
*
|
||||
* **Dev vs prod behavior:**
|
||||
* - Dev (`VITE_DOMAIN=http://localhost:3000`): path-based —
|
||||
* `http://localhost:3000/nessa/contact` (the dev server has no host rewrite)
|
||||
* - Prod (`VITE_DOMAIN=https://freno.me`): host-based —
|
||||
* `https://nessa.freno.me/contact`
|
||||
*
|
||||
* Use these instead of hardcoding `freno.me` anywhere a URL is emitted.
|
||||
* Email addresses (`michael@freno.me`) and email display names are
|
||||
* brand-level constants and should NOT use this module.
|
||||
*/
|
||||
export {
|
||||
buildSubdomainUrl,
|
||||
buildMainSiteUrl,
|
||||
BASE_DOMAIN as getBaseDomain
|
||||
} from "./site-context";
|
||||
Reference in New Issue
Block a user