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
|
2026-10-05 17:32:06 +02:00
|
|
|
/:lang/<section> -> one of the eleven top-level sections
|
2026-09-11 02:46:56 +02:00
|
|
|
/: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
|
2026-10-05 17:32:06 +02:00
|
|
|
section named after its key: "home", "about", "legacy", "whatWeDo", "activities",
|
2026-09-11 02:46:56 +02:00
|
|
|
"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",
|
2026-10-05 17:32:06 +02:00
|
|
|
legacy: "routes/legacy.tsx",
|
2026-09-11 02:46:56 +02:00
|
|
|
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",
|
2026-10-05 17:32:06 +02:00
|
|
|
* "legacy", "what-we-do", "contact" and the home page are single pages with nothing to
|
2026-09-11 02:46:56 +02:00
|
|
|
* 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)[];
|
|
|
|
|
|
|
|
|
|
/**
|
2026-10-05 17:32:06 +02:00
|
|
|
* Every page under ":lang": the eleven sections, then the six detail routes, then
|
2026-09-11 02:46:56 +02:00
|
|
|
* 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;
|