/** * The top-level destinations, in the order the masthead lists them. * * Two things need this list and they run at different times: the layout, which * renders the masthead into every page's HTML, and chrome.ts, which marks the * current one once the app is running. Keeping it here means a destination is * added once. * * Its own module rather than chrome.ts's, and one that imports nothing, * because the layout reads it at build time: importing chrome.ts starts an API * request, which is right in a browser and wrong in a build. * * Every href is absolute, so a page that is not the app carries the same * masthead without its links resolving against itself. */ export type Destination = { /** Names the link for markCurrent, and is the app's screen where it has one. */ readonly id: string; /** * Where the link goes. Absent exactly when `soon` is set: a destination * nothing has been built behind has nowhere honest to point, and an href to * an address that answers with the not-found screen is worse than no link at * all. */ readonly href?: string; readonly label: string; /** * On the bar, and not a link: under construction. * * The bar is where a player finds out what this is going to be, so a section * that is coming is named rather than hidden until the day it works. It is * rendered as text, not as a disabled anchor — there is no address behind it * to disable, nothing to focus and nothing to click — and it says so to a * screen reader as well as to the eye. */ readonly soon?: boolean; /** * A destination the router will not put up without a session. * * This closes the address, and only the address. Whether the link is on the * bar for a stranger is `hiddenSignedOut`, which is a separate question: * Play is closed and still shown, because a stranger who clicks it should be * asked to sign in rather than be left wondering where the game is. */ readonly signedIn?: boolean; /** * A link that means nothing to a stranger, so it is not shown to one. * * The layout renders it hidden, because the masthead is in the HTML before * anything knows who is here, and account.ts reveals it once the session has * been read. Hiding is never the guard — `signedIn` is, and the router is * what enforces it; the address can always be typed. */ readonly hiddenSignedOut?: boolean; /** * The one destination that starts a game, styled as a filled button in the * masthead rather than one more plain-text link. * * A rendering flag, not a routing one, unlike `signedIn`: it changes the * layout's markup for this link and nothing about who may reach it. */ readonly prominent?: boolean; /** * Drawn joined to the destination before it, as the second half of one * split control rather than as a link of its own. * * A rendering flag like `prominent`, and it only means anything directly * after one: the pair is a filled button with a smaller segment on its * end, which is why the group it sits in is the only one the bar draws * without a gap between its links. */ readonly attached?: boolean; }; /** * A run of destinations that belong together, and how it is set off from the * run before it. * * The bar reads as four things rather than a row of equal links: the way into * a game, the things a player manages between games, what everybody else is * doing, and the site's own pages. `space` is a plain gap; `rule` is the thin * vertical line. */ export type NavGroup = { readonly id: string; readonly separator?: "space" | "rule"; readonly destinations: readonly Destination[]; }; /** * The two labels the prominent link can carry. * * "Play" is what the layout writes into the HTML, because that is the only * one a build can know: one file is behind every address, and whether the * reader has a match still going is a question about the reader. account.ts * swaps in "Resume" once it has read the session and asked. * * Both spellings live here rather than in whichever file wrote one first, so * the label the layout renders and the label the flip restores cannot drift. */ export const PLAY_LABEL = "Play"; export const RESUME_LABEL = "Resume"; export const NAV_GROUPS: readonly NavGroup[] = [ { id: "play", destinations: [ { id: "matches", href: "/#matches", label: PLAY_LABEL, signedIn: true, prominent: true, }, // The day's fight, on the end of the button that starts a game. It was // an address with no link on the bar - reached only from the card on // the front page - which put the one thing here a player can do without // an opponent behind having scrolled to find it. Open signed out, like // the card: pressing Play on a challenge is what asks for an account. { id: "daily", href: "/#daily", label: "Daily", attached: true, }, ], }, { id: "game", separator: "space", destinations: [ { id: "camo", href: "/#camo", label: "Camo" }, { id: "hangar", href: "/#hangar", label: "Hangar" }, ], }, { id: "community", separator: "space", destinations: [ { id: "leaderboards", label: "Leaderboards", soon: true }, { id: "observe", label: "Observe", soon: true }, ], }, { id: "site", separator: "rule", destinations: [ { id: "about", href: "/#about", label: "About" }, { id: "blog", href: "/blog", label: "Updates" }, ], }, ]; /** Every destination, in bar order, for anything that does not care about the * grouping. */ export const DESTINATIONS: readonly Destination[] = NAV_GROUPS.flatMap( (group) => group.destinations, ); /** * The pages under About. * * About is a hub: a short description of what this is, and a way into each of * these. They are addresses of their own — `#about/faq` — so a question can be * linked to, and the masthead still marks About while the reader is in one. * * The copy for each is in content/about.json, keyed by these ids; render.ts is * what ties the two together, and misses a key at build time. */ export const ABOUT_PAGES = [ { id: "faq", label: "FAQ" }, { id: "goals", label: "Development goals" }, { id: "credits", label: "Credits" }, ] as const; export type AboutPageId = (typeof ABOUT_PAGES)[number]["id"]; /** The address of one About page. */ export function aboutHref(id: AboutPageId): string { return `/#about/${id}`; } /** The route ids the About hub owns, hub included. */ const ABOUT_ROUTES = new Set([ "about", ...ABOUT_PAGES.map((page) => `about/${page.id}`), ]); /** * The destination a route belongs to. * * A route is either a destination's own id or a page under one — `about/faq` * belongs to About — and everything that reasons about the masthead or about * who may be here works on the destination. */ export function destinationForRoute(route: string): string { return route.split("/")[0] ?? route; } /** * Whether a route may be put up for whoever is here. * * The one decision, made off the one list, so marking a destination * `signedIn` closes the address as well as hiding the link. The router calls * it against a session it has read; nothing else may decide this, and hiding a * link is not deciding it. * * A route that is not a destination — the router has none today, but the camo * editor's pending-camo return is close to one — is allowed. This list says * what is closed, not what exists. */ export function isAllowed(route: string, signedIn: boolean): boolean { if (signedIn) return true; const id = destinationForRoute(route); return !DESTINATIONS.find((d) => d.id === id)?.signedIn; } /** * The destinations that are a hash on the app's own page, and the pages under * About. Blog is not one: those are pages the build wrote, and the router * never runs on them. */ const HASH_ROUTES = new Set([ ...DESTINATIONS.filter((d) => d.href?.startsWith("/#")).map((d) => d.id), ...ABOUT_ROUTES, ]); /** The addresses the app itself is served at. Everything else is a page. */ const APP_PATHS = ["/", "/index.html"]; /** * Whether this address is the app's own page rather than one the build wrote. * * account.ts asks before it spends a request on anything: it mounts on every * page of the site, the blog's included, and there every link is a fresh page * load. The blog is not where a player goes back into a match, so it does not * pay for the masthead to know. */ export function isAppPage(pathname: string): boolean { return APP_PATHS.includes(pathname); } /** A leading slash is tolerated, so "#/camo" reads the same as "#camo". */ function hashId(hash: string): string { return hash.replace(/^#\/?/, ""); } /** * Addresses that used to be a destination of their own, and where they went. * * The FAQ was a top-level tab before About became a hub, so its address is out * there — in the blog, in whatever anyone has linked. The router sends these on * rather than calling them dead links. */ const MOVED: Readonly> = { faq: "about/faq" }; /** Where a moved address went, or null when the address never moved. */ export function movedRouteForHash(hash: string): string | null { return MOVED[hashId(hash)] ?? null; } /** * The destination a hash names, or null when it names none. * * Null is not Home. The router has its own reasons to end up on Home from an * address with no route of its own, and keeping the two apart is what lets the * masthead say that none of its links is the current page. */ export function routeForHash(hash: string): string | null { const id = hashId(hash); return HASH_ROUTES.has(id) ? id : null; } /** * "#lobby/", the one parameterized address the app has. * * Not one more entry in DESTINATIONS: a lobby is a live, ephemeral, * per-visit thing opened from the Duel and Scenario cards or followed from * a share link, never a fixed place the masthead should list, and every id * is different. It is matched by shape instead of by membership — the same * move HASH_ROUTES/ABOUT_ROUTES and MOVED both make for their own kind of * address, none of them a list this has to be kept in sync with. */ const LOBBY_HASH = /^lobby\/(.+)$/; /** * The lobby id a hash names, or null when the hash does not name one. * * `id` is taken from `hashId()` first, the same leading-slash tolerance * every other hash here gets, then percent-decoded — defensive rather than * exercised, since a matchId minted by `openLobby` is an opaque token with * no "/" or "#" in it to encode in the first place. */ export function lobbyIdFromHash(hash: string): string | null { const match = LOBBY_HASH.exec(hashId(hash)); return match ? decodeURIComponent(match[1]!) : null; } /** * A player's page, the site's second parameterized address. * * A path rather than a hash, unlike every destination above: a profile is * what people link to about each other, and an unfurler has to be able to be * answered per player - which needs an address the distribution can route on * its own, without running the page. See web/src/profile-page.ts. * * The segment is a handle or a fully qualified DID, and both name the same * page; identity.ts resolves whichever arrives. What the site *writes* is the * handle, with its @: `/profile/@alice.example` is an address somebody can * read out, and `/profile/did:plc:h7k...` is one nobody can. The DID form * stays valid for exactly the case the handle cannot cover - an account whose * handle nothing can confirm - and for a link written before a handle moved. * * Neither is percent-encoded. An @ and a colon are both legal in a path * segment, browsers send them as typed, and an address is written to be read. */ const PROFILE_PREFIX = "/profile/"; /** * A player's matches, the site's third parameterized address. * * The same rule as the profile's, one prefix along: a match is a thing two * accounts did together and the record of it is public, so what somebody has * played is an address anybody can be handed. `/matches/@alice.example` is * hers whoever is reading, and your own is the one the account menu leads to * rather than a screen that only exists while you are signed in. */ const MATCHES_PREFIX = "/matches/"; /** * The page's address for an account. * * Handed the handle where there is one and the DID where there is not, which * is the same rule the page and the card follow for what to call somebody. */ export function profileHref(actor: { did: string; handle: string | null; }): string { return `${PROFILE_PREFIX}${segmentFor(actor)}`; } /** Their matches, at the address written the same way. */ export function matchesHref(actor: { did: string; handle: string | null; }): string { return `${MATCHES_PREFIX}${segmentFor(actor)}`; } function segmentFor(actor: { did: string; handle: string | null }): string { return actor.handle ? `@${actor.handle}` : actor.did; } /** * The handle or DID a profile address names, or null where it names nobody. * * A leading @ is part of the handle's address and is left on: identity.ts * takes it off before resolving, the way it takes one off anything typed. * Anything after a second slash is ignored rather than refused: * `/profile/@alice.example/` is the same page, and a trailing slash is not * worth a dead end. */ export function actorFromPath(pathname: string): string | null { return actorUnder(PROFILE_PREFIX, pathname); } /** The same, for a matches address. */ export function actorFromMatchesPath(pathname: string): string | null { return actorUnder(MATCHES_PREFIX, pathname); } function actorUnder(prefix: string, pathname: string): string | null { if (!pathname.startsWith(prefix)) return null; const segment = pathname.slice(prefix.length).split("/")[0] ?? ""; if (segment === "") return null; try { // Nothing here writes an encoded address any more, and one can still // arrive: a link that went through something which encoded it on the way, // or an address typed into a client that tidied it up. return decodeURIComponent(segment); } catch { // A malformed escape is not an address, and decodeURIComponent throws on // one rather than answering. return null; } } /** * Whether the site has this address at all. * * Decided in the browser because it has to be: CloudFront maps both 403 and * 404 to /index.html with a 200, so a request for a path that never existed * arrives here as an ordinary page load and nothing else will ever tell the * player their link is dead. */ export function isKnownAddress(pathname: string, hash: string): boolean { if (!isAppPage(pathname)) return false; if (hash === "" || hash === "#") return true; if (movedRouteForHash(hash)) return true; return routeForHash(hash) !== null || lobbyIdFromHash(hash) !== null; }