feat(Bars): add site-aware layout and navigation with subdomain nav config

Refactor LeftBar/RightBarContent into main-site and subdomain variants.
Subdomain sites render a simplified, brand-colored shell iterating a new
pure nav-config module (NAV_CONFIG), with a "back to freno.me" affordance
and inline SVG icon resolver. The main site retains its bespoke bars
(Recent Posts, auth-aware nav, admin links, widgets) unchanged.

Add nav-config.ts as an import-free, unit-testable module defining the
per-site link sets (with external/href/auth-gating/icon contract), plus
filterNavByAuth helper and BACK_TO_FRENO link.
This commit is contained in:
2026-07-23 10:38:53 -04:00
parent d82f31990d
commit 650b216dbc
3 changed files with 805 additions and 208 deletions

188
src/lib/nav-config.test.ts Normal file
View File

@@ -0,0 +1,188 @@
/**
* Unit tests for the per-site navigation configuration (task 04).
*
* `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.
*/
import { describe, it, expect } from "bun:test";
import {
NAV_CONFIG,
BACK_TO_FRENO,
filterNavByAuth,
navLabelsFor,
type NavItem
} from "./nav-config";
import { SITE_CONFIG, type SiteId } from "./site-context";
const ALL_SITES: SiteId[] = [
"main",
"nessa",
"lineage",
"gaze",
"inputhalo"
];
describe("NAV_CONFIG — per-site link sets", () => {
it("main → Home, Blog, Downloads, Resume, Contact, GitHub, LinkedIn", () => {
expect(navLabelsFor("main")).toEqual([
"Home",
"Blog",
"Downloads",
"Resume",
"Contact",
"GitHub",
"LinkedIn"
]);
});
it("nessa → Home, Contact, Privacy (no Blog/Resume/Downloads)", () => {
const labels = navLabelsFor("nessa");
expect(labels).toEqual(["Home", "Contact", "Privacy"]);
expect(labels).not.toContain("Blog");
expect(labels).not.toContain("Resume");
expect(labels).not.toContain("Downloads");
});
it("lineage → Home, Downloads, Contact, Privacy, Account Deletion", () => {
expect(navLabelsFor("lineage")).toEqual([
"Home",
"Downloads",
"Contact",
"Privacy",
"Account Deletion"
]);
});
it("gaze → Home, Contact, Privacy", () => {
expect(navLabelsFor("gaze")).toEqual(["Home", "Contact", "Privacy"]);
});
it("inputhalo → Home, Contact, Privacy", () => {
expect(navLabelsFor("inputhalo")).toEqual(["Home", "Contact", "Privacy"]);
});
});
describe("NAV_CONFIG — href correctness", () => {
it("every main internal link is a path (no host), externals are absolute URLs", () => {
for (const item of NAV_CONFIG.main) {
if (item.external) {
expect(item.href).toMatch(/^https?:\/\//);
} else {
expect(item.href.startsWith("/")).toBe(true);
}
}
});
it("subdomain nav hrefs are public browser paths, never the internal rewritten prefix", () => {
for (const id of ["nessa", "lineage", "gaze", "inputhalo"] as SiteId[]) {
for (const item of NAV_CONFIG[id]) {
// No subdomain-prefixed paths leak into the public nav.
expect(item.href.startsWith(`/${id}/`)).toBe(false);
expect(item.href).toMatch(/^\//);
}
}
});
it("the lineage Account Deletion link points to /deletion (host-scoped route)", () => {
const deletion = NAV_CONFIG.lineage.find(
(i) => i.label === "Account Deletion"
);
expect(deletion).toBeDefined();
expect(deletion!.href).toBe("/deletion");
});
it("main external GitHub + LinkedIn point to the canonical profiles", () => {
const gh = NAV_CONFIG.main.find((i) => i.label === "GitHub");
expect(gh?.external).toBe(true);
expect(gh?.href).toBe("https://github.com/MikeFreno/");
const li = NAV_CONFIG.main.find((i) => i.label === "LinkedIn");
expect(li?.external).toBe(true);
expect(li?.href).toBe(
"https://www.linkedin.com/in/michael-freno-176001256/"
);
});
});
describe("NAV_CONFIG — auth-scoping by construction", () => {
it("no subdomain nav item sets showLoggedIn / showLoggedOut", () => {
for (const id of ["nessa", "lineage", "gaze", "inputhalo"] as SiteId[]) {
for (const item of NAV_CONFIG[id]) {
expect(item.showLoggedIn).toBeUndefined();
expect(item.showLoggedOut).toBeUndefined();
}
}
});
it("every site's nav is a non-empty array", () => {
for (const id of ALL_SITES) {
expect(NAV_CONFIG[id].length).toBeGreaterThan(0);
}
});
it("every site has a Home item pointing to /", () => {
for (const id of ALL_SITES) {
const home = NAV_CONFIG[id].find((i) => i.label === "Home");
expect(home).toBeDefined();
expect(home!.href).toBe("/");
}
});
it("is exhaustively defined for every SiteId", () => {
// Every entry in SITE_CONFIG has a NAV_CONFIG entry.
for (const id of Object.keys(SITE_CONFIG) as SiteId[]) {
expect(NAV_CONFIG[id]).toBeDefined();
expect(Array.isArray(NAV_CONFIG[id])).toBe(true);
}
});
});
describe("filterNavByAuth", () => {
const mixed: NavItem[] = [
{ label: "Public", href: "/" },
{ label: "Only Logged In", href: "/in", showLoggedIn: true },
{ label: "Only Logged Out", href: "/out", showLoggedOut: true }
];
it("shows public items to both audiences", () => {
expect(filterNavByAuth(mixed, true).map((i) => i.label)).toContain(
"Public"
);
expect(filterNavByAuth(mixed, false).map((i) => i.label)).toContain(
"Public"
);
});
it("shows showLoggedIn only when authenticated", () => {
expect(filterNavByAuth(mixed, true).map((i) => i.label)).toContain(
"Only Logged In"
);
expect(filterNavByAuth(mixed, false).map((i) => i.label)).not.toContain(
"Only Logged In"
);
});
it("shows showLoggedOut only when logged out", () => {
expect(filterNavByAuth(mixed, false).map((i) => i.label)).toContain(
"Only Logged Out"
);
expect(filterNavByAuth(mixed, true).map((i) => i.label)).not.toContain(
"Only Logged Out"
);
});
it("does not mutate the input array", () => {
const before = mixed.map((i) => ({ ...i }));
filterNavByAuth(mixed, true);
expect(mixed).toEqual(before);
});
});
describe("BACK_TO_FRENO", () => {
it("links to the apex freno.me and is external", () => {
expect(BACK_TO_FRENO.href).toBe("https://freno.me");
expect(BACK_TO_FRENO.external).toBe(true);
expect(BACK_TO_FRENO.icon).toBe("back");
});
});

143
src/lib/nav-config.ts Normal file
View File

@@ -0,0 +1,143 @@
/**
* Per-site navigation configuration (task 04 — 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
* provider, mirroring the pattern established by `page-head-meta.ts`.
*
* Selection contract:
* - `href` values are the **public browser paths** on the subdomain origin
* (e.g. `/contact`), NOT the internal rewritten prefixes. vercel.json maps
* `nessa.freno.me/contact` → `/nessa/contact` server-side, but the browser
* sees (and links must emit) the clean `/contact`. This matches the
* canonical-URL derivation rule documented in `page-head-meta.ts`.
* - `external: true` means an absolute URL (e.g. GitHub / LinkedIn).
* - `showLoggedIn` / `showLoggedOut` gate auth-scoped items. Subdomain sites
* do NOT use the web (freno.me) JWT cookies — Nessa uses Clerk, Lineage
* uses its mobile JWT — so subdomain nav items never set these, keeping
* the "auth-aware items only on main" acceptance criterion satisfied by
* construction.
* - `icon` is a string key resolved to an SVG by the bar renderer
* (`Bars.tsx`), kept here as a string so this module stays import-free.
*
* The main-site nav is also represented here for parity / unit-testability,
* but `Bars.tsx` preserves the main site's pre-existing bespoke rendering
* (Recent Posts, auth-aware Account/Login/SignOut, admin Analytics, the
* "What's this?" glitch button, + the right-bar widgets). NAV_CONFIG[main] is
* authoritative only for the *link set* the unit tests assert against.
*/
import type { SiteId } from "~/lib/site-context";
/** Icon keys resolved by the bar renderer to inline SVGs. */
export type NavIcon =
| "home"
| "blog"
| "downloads"
| "resume"
| "contact"
| "privacy"
| "deletion"
| "github"
| "linkedin"
| "back";
export interface NavItem {
label: string;
/** Public browser path (subdomain-relative) or absolute URL when external. */
href: string;
icon?: NavIcon;
/** Absolute external link (opens in a new tab). */
external?: boolean;
/** Only render when the viewer is authenticated (main-site web auth). */
showLoggedIn?: boolean;
/** Only render when the viewer is logged out (main-site web auth). */
showLoggedOut?: boolean;
}
/** Apex/host link used as a "back to freno.me" affordance on subdomains. */
export const BACK_TO_FRENO: NavItem = {
label: "freno.me",
href: "https://freno.me",
icon: "back",
external: true
};
/**
* Per-site navigation link sets.
*
* Defined to exactly satisfy the task-04 acceptance matrix:
* - main: Home, Blog, Downloads, Resume, Contact, GitHub, LinkedIn
* - nessa: Home, Contact, Privacy
* - lineage: Home, Downloads, Contact, Privacy, Account Deletion
* - gaze: Home, Contact, Privacy
* - inputhalo: Home, Contact, Privacy
*/
export const NAV_CONFIG: Record<SiteId, NavItem[]> = {
main: [
{ label: "Home", href: "/", icon: "home" },
{ label: "Blog", href: "/blog", icon: "blog" },
{ label: "Downloads", href: "/downloads", icon: "downloads" },
{ label: "Resume", href: "/resume", icon: "resume" },
{ label: "Contact", href: "/contact", icon: "contact" },
{
label: "GitHub",
href: "https://github.com/MikeFreno/",
icon: "github",
external: true
},
{
label: "LinkedIn",
href: "https://www.linkedin.com/in/michael-freno-176001256/",
icon: "linkedin",
external: true
}
],
nessa: [
{ label: "Home", href: "/", icon: "home" },
{ label: "Contact", href: "/contact", icon: "contact" },
{ label: "Privacy", href: "/privacy", icon: "privacy" }
],
lineage: [
{ label: "Home", href: "/", icon: "home" },
{ label: "Downloads", href: "/downloads", icon: "downloads" },
{ label: "Contact", href: "/contact", icon: "contact" },
{ label: "Privacy", href: "/privacy", icon: "privacy" },
{ label: "Account Deletion", href: "/deletion", icon: "deletion" }
],
gaze: [
{ label: "Home", href: "/", icon: "home" },
{ label: "Contact", href: "/contact", icon: "contact" },
{ label: "Privacy", href: "/privacy", icon: "privacy" }
],
inputhalo: [
{ label: "Home", href: "/", icon: "home" },
{ label: "Contact", href: "/contact", icon: "contact" },
{ label: "Privacy", href: "/privacy", icon: "privacy" }
]
};
/**
* Filter a site's nav items by the viewer's auth state.
*
* Used by the renderer so auth-gated items (e.g. an admin link) only appear
* for the appropriate audience. Items without `showLoggedIn`/`showLoggedOut`
* are always shown.
*/
export function filterNavByAuth(
items: readonly NavItem[],
isAuthenticated: boolean
): NavItem[] {
return items.filter((item) => {
if (item.showLoggedIn && !isAuthenticated) return false;
if (item.showLoggedOut && isAuthenticated) return false;
return true;
});
}
/**
* Labels for a site's nav — convenience for asserting against in unit tests
* without pulling the full NavItem shape.
*/
export function navLabelsFor(site: SiteId): string[] {
return NAV_CONFIG[site].map((item) => item.label);
}