forked from Mike/freno-dev
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:
188
src/lib/nav-config.test.ts
Normal file
188
src/lib/nav-config.test.ts
Normal 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
143
src/lib/nav-config.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user