NGU-Web/src/lib/hrefs.ts

174 lines
6.7 KiB
TypeScript

/* ═══════════════════════════════════════════════════════════════
PUBLIC HREFS
One place that turns a record into a URL. The API deliberately
never sends paths — it sends `{ kind, id }` and, for an
organization, the `org_kind` that decides which of the three
routes a slug belongs to. Deciding that in each component is how
/regions/x and /chapters/x end up both existing for the same
record, and how the history timeline ended up emitting /event/:id
while the router only knew /retreats/:id.
timelineRefs.ts now delegates here rather than keeping its own
table, so there is one answer to "where does an event live" and
changing it is changing EVENT_BASE below.
navConfig.js stays the source of truth for the *nav*: these are
record routes, which never appear in it.
── On missing ids ──
Every builder takes an id the caller believed it had. When it
doesn't, the old behaviour was to interpolate the string
"undefined" into a path, render a link to it, mount a page and
fetch /api/organizations/undefined — four steps between the
mistake and any sign of it, none of which name the component
that made it.
Now the id is checked here. In dev that's a console.error with a
stack trace pointing at the caller; in production the path still
comes out, because a broken link beats a crashed page, and
useResource refuses to fetch it.
═══════════════════════════════════════════════════════════════ */
export type OrgKind = 'national' | 'region' | 'chapter' | 'partner'
export type RefKind = 'event' | 'organization' | 'team' | 'award' | 'person'
/* A region, a chapter and a partner read as different things to a
visitor even though they are one table, so they get one route
each. 'national' is NGU itself — one record, no listing to sit
under, so it falls through to the generic path. */
const ORG_BASE: Record<string, string> = {
region: '/regions',
chapter: '/chapters',
partner: '/partners',
national: '/organizations',
}
/** Canonical base for an event page.
*
* /events/:id, not /retreats/:id. The listing page is called
* Retreats because that's what NGU calls the gatherings it hosts,
* but the records are `events`, the API route is /api/events, and
* plenty of them — partner events, conferences — aren't retreats
* at all. Naming the record route after one page's editorial
* framing would have been wrong the first time a non-retreat got
* its own page.
*
* Nothing redirects from /retreats/:id, because nothing ever
* linked there. */
export const EVENT_BASE = '/events'
const DEV = Boolean((import.meta as any)?.env?.DEV)
/* Router params arrive as strings, so an id that has already been
through a template literal shows up as the literal word. Those
are as broken as a genuine null. */
const BAD = new Set(['', 'undefined', 'null', 'NaN'])
export const isBadId = (id: unknown): boolean =>
id == null || BAD.has(String(id))
function checkId(id: unknown, what: string): string {
if (!isBadId(id)) return String(id)
if (DEV) {
// console.error rather than warn: this is always a bug, and the
// stack is the whole point — it names the component that passed
// nothing.
console.error(
`hrefs: ${what}() was given ${JSON.stringify(id)}. ` +
`The link it returns will 404. Caller:`,
new Error('hrefs: missing id').stack,
)
}
return 'undefined'
}
/**
* The API path for one record, or null when the id isn't usable.
*
* The mirror image of the builders below: they make the URL a
* visitor sees, this makes the URL the client fetches, and both
* have to agree about what counts as an id.
*
* It lives here rather than next to the hook that calls it because
* it is a path, and paths are this file's job — and because a
* component that interpolates a missing route param produces the
* literal string "undefined", which the server cannot tell apart
* from a slug somebody genuinely typed. It answers 404 either way,
* and the log fills with GET /api/organizations/undefined with
* nothing to say where it came from.
*
* Returning null costs a round trip and turns a mystery 404 into
* the not-found page, which is what a visitor should see anyway.
*/
export function detailPath(base: string, id?: string | null): string | null {
return isBadId(id) ? null : `${base}/${encodeURIComponent(String(id))}`
}
export const eventHref = (id?: string | null) =>
`${EVENT_BASE}/${checkId(id, 'eventHref')}`
export const teamHref = (id?: string | null) => `/teams/${checkId(id, 'teamHref')}`
export const awardHref = (id?: string | null) => `/awards/${checkId(id, 'awardHref')}`
export const personHref = (id?: string | null) => `/people/${checkId(id, 'personHref')}`
export function orgHref(id?: string | null, kind?: string | null): string {
return `${ORG_BASE[kind ?? ''] ?? '/organizations'}/${checkId(id, 'orgHref')}`
}
/* For anything holding a polymorphic reference — timeline entries,
content blocks — where the kind arrives as data rather than being
known at the call site.
Returns null for a kind with no page AND for a reference with no
id, so the caller renders plain text instead of a dead link.
This is the one the timeline wants: an entry whose ref didn't
resolve should read as text, not as a link to nowhere. */
export function refHref(
kind: string | null | undefined,
id: string | null | undefined,
orgKind?: string | null,
): string | null {
if (!kind || isBadId(id)) return null
switch (kind) {
case 'event':
return eventHref(id)
case 'organization':
return orgHref(id, orgKind)
case 'team':
return teamHref(id)
case 'award':
return awardHref(id)
case 'person':
return personHref(id)
default:
return null
}
}
/* What to call the kind in a breadcrumb or a back link. */
export const ORG_KIND_LABEL: Record<string, string> = {
national: 'Next Generation of Unity',
region: 'Region',
chapter: 'Chapter',
partner: 'Partner organization',
}
/* Where "back" goes from a record page. A chapter belongs to
/community, a retreat to /retreats. */
export function orgListHref(kind?: string | null): { to: string; label: string } {
switch (kind) {
case 'region':
return { to: '/community#local', label: 'All regions' }
case 'chapter':
return { to: '/community#local', label: 'All chapters' }
case 'partner':
return { to: '/community#partners', label: 'All partner organizations' }
default:
return { to: '/community', label: 'Community' }
}
}