// Shared notion of "the site I'm working on" for the editor's site-scoped views // (Dashboard, Channels) and the sidebar's "Active site" picker. This module has // NO server-only imports so the client picker and provider can share it. // // THE STORE IS A COOKIE (release 15 slice SS). The root layout reads it once per // request (`readActiveSite()`, app/lib/activeSiteServer.ts) and hands it to // `SiteScopeProvider`; Dashboard and Channels read it through the same helper. // So the server renders the picker, and the scoped pages, with the stored // selection on the first paint — nothing is reconciled after it. It is written // only by `setActiveSiteAction` (app/lib/activeSiteActions.ts). // // A `?site=` LINK still works: a valid param governs that one request (the // server pages read it from `searchParams`, the picker from the URL), and is not // written to the cookie. The editor's own navigation no longer appends it. // // ON A SITE'S OWN PAGES the site is the PATH, not the cookie or the param: // /sites// names it, the tab reads `params.siteId`, and the picker // reads the same path through `siteIdFromPathname` so the two never disagree. // Visiting one records that site in the cookie, so Dashboard and Channels follow. // Sentinel value meaning "all sites" (full channel pool). A bare string so it // can never collide with a real siteId (which matches SITE_ID_RE). export const ALL_SITES = "__all__"; // The localStorage key the picker used before the cookie. Read ONCE, by // SiteScopeProvider's migration, when a visitor has it and no cookie; then // removed. Nothing else reads it. export const ACTIVE_SITE_KEY = "activeSite"; // The cookie's base name. The name a request uses carries the port it was made // to (`activeSiteCookieName`), because a cookie is shared by every port on a // host while localStorage was per origin: two editors on one machine (the live // one and a worktree's) keep a selection each, as they did. export const ACTIVE_SITE_COOKIE = "archilyzer-active-site"; // The BroadcastChannel a successful write is announced on, so the editor's // other tabs refresh (SiteScopeProvider). A channel is per origin, so each port // has its own, like the cookie's name. export const ACTIVE_SITE_CHANNEL = "archilyzer-active-site"; // One year, in seconds (the cookie's `maxAge`). export const ACTIVE_SITE_COOKIE_MAX_AGE = 60 * 60 * 24 * 365; // "localhost:3001" → "archilyzer-active-site-3001"; a host with no port (the // default port, behind a proxy) or no host at all → the base name. export function activeSiteCookieName(host: string | null | undefined): string { const port = host ? /:(\d+)$/.exec(host)?.[1] : undefined; return port ? `${ACTIVE_SITE_COOKIE}-${port}` : ACTIVE_SITE_COOKIE; } // SITE_ID_RE re-spelled (common/lib/siteSchema.ts imports zod and is // server-only), with a length cap because the value goes into a header. const SITE_ID_SHAPE = /^[a-z0-9][a-z0-9-]*$/; const STORED_MAX_LENGTH = 128; // What may be stored: ALL_SITES or anything shaped like a site id. Whether the // id names a site is decided when it is read (`resolveActiveSite`), so a site // deleted since resolves as a missing selection does. export function isStorableActiveSite(value: unknown): value is string { return ( typeof value === "string" && (value === ALL_SITES || (value.length <= STORED_MAX_LENGTH && SITE_ID_SHAPE.test(value))) ); } export type ResolvedActiveSite = { // All configured site ids (passed in by the caller). siteIds: string[]; // The canonical value for the resolved selection: a siteId or ALL_SITES. value: string; // True when the selection spans every site (full pool). isAll: boolean; // The single active siteId, or null under "all sites". siteId: string | null; }; // Resolve one raw value (a param, a cookie) against the configured site ids. // - a valid siteId -> that site // - ALL_SITES -> all sites (full pool) // - missing/invalid (default) -> the lone site if exactly one, else all sites export function resolveActiveSite( paramValue: string | undefined | null, siteIds: string[], ): ResolvedActiveSite { const all: ResolvedActiveSite = { siteIds, value: ALL_SITES, isAll: true, siteId: null, }; if (paramValue && siteIds.includes(paramValue)) { return { siteIds, value: paramValue, isAll: false, siteId: paramValue }; } if (paramValue === ALL_SITES) { return all; } // Missing or invalid: default to the lone site when there's exactly one. if (siteIds.length === 1) { return { siteIds, value: siteIds[0], isAll: false, siteId: siteIds[0] }; } return all; } // THE PRECEDENCE, in one place: the first candidate that names a configured // site (or ALL_SITES) wins; when none does, the default above. The server // passes [?site=, cookie]; the picker passes [its pending choice, the path's // site, ?site=, the stored value]. export function resolveActiveSiteFrom( candidates: ReadonlyArray, siteIds: string[], ): ResolvedActiveSite { const chosen = candidates.find( (c) => c === ALL_SITES || (!!c && siteIds.includes(c)), ); return resolveActiveSite(chosen, siteIds); } // The routes under /sites where the path names the site. `new` is /sites/new, // a static route that beats [siteId] and is not a site. The id pattern is // SITE_ID_RE re-spelled, as above. const SITE_PATH_RE = /^\/sites\/([a-z0-9][a-z0-9-]*)(?:\/([a-z-]+))?\/?$/; export type SitePath = { siteId: string; segment: string | null }; // "/sites/alpha" → { alpha, null }; "/sites/alpha/charts" → { alpha, "charts" }; // "/sites", "/sites/new", "/channels", "/sites/a/b/c" → null. export function siteIdFromPathname(pathname: string): SitePath | null { const m = SITE_PATH_RE.exec(pathname); if (!m || m[1] === "new") return null; return { siteId: m[1], segment: m[2] ?? null }; } // The same URL with its `site` param dropped (every other param kept, in // order): where the picker goes when a choice replaces a `?site=` link's scope. export function withoutSiteParam(pathname: string, search: string): string { const params = new URLSearchParams(search); params.delete("site"); const q = params.toString(); return q ? `${pathname}?${q}` : pathname; }