Files

158 lines
6.4 KiB
TypeScript
Raw Permalink Normal View History

2026-09-11 02:46:56 +02:00
/* ============================================================================
ROUTE CONFIG
----------------------------------------------------------------------------
URL shape - an explicit locale prefix on BOTH languages, never a ?lang=:
/ -> 302 to /en or /ar (Accept-Language)
/:lang -> home
/:lang/<section> -> one of the ten top-level sections
/:lang/<section>/<slug> -> one entry of a CMS-backed section
/:lang/events-calendar/<slug>.ics -> that event as an RFC 5545 file
anything else -> 404
ONE set of route modules serves both languages: the ":lang" layout wraps
every page, so a page module is written once and rendered for /en/about and
/ar/about alike. An unknown ":lang" is rejected by that layout's loader, and
any other unmatched URL falls through to the catch-all, which renders the
root ErrorBoundary's localised 404.
LISTING AND DETAIL ARE SIBLINGS, NOT PARENT AND CHILD
-----------------------------------------------------
A detail route is registered as a SIBLING of its listing route - both are
children of the ":lang" layout - and deliberately not as a child of the
listing. React Router only renders a child where its parent renders an
<Outlet/>, and a listing module renders a page, not an outlet: nesting would
either hide the detail page entirely or paint it underneath the whole index
(its filters, its cards, its pagination). "locale-layout.tsx" is the only
module in this tree that renders an <Outlet/>, so it is the only legitimate
parent. The shared URL prefix is expressed in the path string
("events-calendar/:slug"), which costs nothing and keeps each page's route a
leaf that owns its whole screen.
":slug.ics" DOES NOT SHADOW ":slug"
-----------------------------------
Both patterns match /en/events-calendar/kickoff.ics, so ranking - not array
order - has to decide. React Router scores a partial dynamic segment
(":slug.ics") above a whole-segment one (":slug"), so the resource route
wins the .ics URL and the page route keeps every other slug. The .ics module
exports only a `loader`, which is what makes it a resource route: its
Response is handed to the browser verbatim and no parent loader - not even
the ":lang" layout's - runs, which is why that module validates the locale
segment itself.
ROUTE IDS
---------
"root" | "routes/locale-redirect" | "locale" | "routes/not-found", one per
section named after its key: "home", "about", "whatWeDo", "activities",
"events", "applications", "news", "contact", "resources", "knowledgeHub" -
and one per detail route, the section key plus "Detail" ("newsDetail",
"eventsDetail", ...), plus "eventsIcs". So `useRouteLoaderData("locale")`
reads the ":lang" layout's data anywhere.
ADDING A SECTION PAGE
---------------------
Add the section to `ROUTE_SLUGS`, name its module in `SECTION_MODULES` (and
in `DETAIL_MODULES` if entries can be drilled into), and write the file. The
tables below are declared, not discovered: every module named here is
registered unconditionally, so a path that does not resolve fails the build
instead of quietly falling through to the 404 route. Vite does not
re-evaluate this file when a route module appears, so restart `pnpm dev`
after adding one.
========================================================================= */
import {
index,
route,
type RouteConfig,
type RouteConfigEntry,
} from "@react-router/dev/routes";
import { ROUTE_SLUGS, type RouteSlugKey } from "./lib/route-slugs";
/**
* Section -> route module. The file name is always the slug, so the URL and
* the module that serves it can be read off each other.
*/
const SECTION_MODULES = {
home: "routes/home.tsx",
about: "routes/about.tsx",
whatWeDo: "routes/what-we-do.tsx",
activities: "routes/activities-and-initiatives.tsx",
events: "routes/events-calendar.tsx",
applications: "routes/applications-and-opportunities.tsx",
news: "routes/news-and-updates.tsx",
contact: "routes/contact.tsx",
resources: "routes/resources-and-publications.tsx",
knowledgeHub: "routes/knowledge-hub.tsx",
} as const satisfies Record<RouteSlugKey, string>;
/**
* Section -> the module that renders ONE entry of it, at
* `<section slug>/:slug`. Only the six CMS-backed sections have one; "about",
* "what-we-do", "contact" and the home page are single pages with nothing to
* drill into.
*/
const DETAIL_MODULES = {
activities: "routes/activities-and-initiatives.$slug.tsx",
events: "routes/events-calendar.$slug.tsx",
applications: "routes/applications-and-opportunities.$slug.tsx",
news: "routes/news-and-updates.$slug.tsx",
resources: "routes/resources-and-publications.$slug.tsx",
knowledgeHub: "routes/knowledge-hub.$slug.tsx",
} as const satisfies Partial<Record<RouteSlugKey, string>>;
/**
* Resource routes: a module with a `loader` and no component, whose Response
* is served as-is. `path` is relative to the ":lang" layout, like every other
* entry here.
*/
const RESOURCE_ROUTES = [
{
id: "eventsIcs",
path: `${ROUTE_SLUGS.events}/:slug.ics`,
file: "routes/events-calendar.$slug.ics.ts",
},
] as const;
const detailKeys = Object.keys(DETAIL_MODULES) as (keyof typeof DETAIL_MODULES)[];
/**
* Every page under ":lang": the ten sections, then the six detail routes, then
* the .ics resource route. Flat on purpose - see "LISTING AND DETAIL ARE
* SIBLINGS" above.
*/
const localeChildRoutes: RouteConfigEntry[] = [
...(Object.keys(SECTION_MODULES) as RouteSlugKey[]).map((key) =>
key === "home"
? index(SECTION_MODULES.home, { id: "home" })
: route(ROUTE_SLUGS[key], SECTION_MODULES[key], { id: key }),
),
...detailKeys.map((key) =>
route(`${ROUTE_SLUGS[key]}/:slug`, DETAIL_MODULES[key], {
id: `${key}Detail`,
}),
),
...RESOURCE_ROUTES.map((entry) =>
route(entry.path, entry.file, { id: entry.id }),
),
];
export default [
// Bare "/" is not a page: it negotiates and redirects.
index("routes/locale-redirect.tsx"),
// Every page, in both languages, through one shared route module.
route(
":lang",
"routes/locale-layout.tsx",
{ id: "locale" },
localeChildRoutes,
),
// Unknown locale segments are handled by the layout's loader; every other
// unmatched URL lands here.
route("*", "routes/not-found.tsx"),
] satisfies RouteConfig;