initial commit

This commit is contained in:
2026-09-11 02:46:56 +02:00
commit 20b384039c
288 changed files with 46557 additions and 0 deletions
+135
View File
@@ -0,0 +1,135 @@
/* ============================================================================
SERVER ENVIRONMENT
----------------------------------------------------------------------------
The `.server` suffix is enforced by the React Router Vite plugin: this
module is stripped from the browser bundle, and importing it from component
code fails the build rather than leaking STRAPI_TOKEN to the client.
Validation is lazy and memoised. Reading it at module load would crash the
whole server on a typo in one variable; reading it on first use lets the
error surface as an ordinary 500 with every problem listed at once.
========================================================================= */
export type NodeEnv = "development" | "production" | "test";
export interface ServerEnv {
readonly NODE_ENV: NodeEnv;
/** Strapi REST origin, no trailing slash. Default http://localhost:1337 */
readonly STRAPI_URL: string;
/** Optional API token. Sent as a bearer token when present. */
readonly STRAPI_TOKEN?: string;
/** Public origin of this site, no trailing slash. Used for canonical URLs. */
readonly SITE_URL: string;
readonly isProduction: boolean;
readonly isDevelopment: boolean;
}
/** Thrown when one or more variables are missing or malformed. */
export class EnvError extends Error {
override readonly name = "EnvError";
readonly issues: readonly string[];
constructor(issues: readonly string[]) {
super(`Invalid server environment:\n - ${issues.join("\n - ")}`);
this.issues = issues;
}
}
const DEFAULTS = {
STRAPI_URL: "http://localhost:1337",
SITE_URL: "https://sharper.ly",
} as const;
function trimTrailingSlash(value: string): string {
return value.replace(/\/+$/, "");
}
function readOrigin(
name: string,
raw: string | undefined,
fallback: string,
issues: string[],
): string {
const value = raw?.trim() || fallback;
try {
const url = new URL(value);
if (url.protocol !== "http:" && url.protocol !== "https:") {
issues.push(`${name} must be an http(s) URL, received "${value}"`);
return trimTrailingSlash(fallback);
}
return trimTrailingSlash(value);
} catch {
issues.push(`${name} must be an absolute URL, received "${value}"`);
return trimTrailingSlash(fallback);
}
}
function readNodeEnv(raw: string | undefined): NodeEnv {
return raw === "production" || raw === "test" ? raw : "development";
}
let cached: ServerEnv | undefined;
/** The validated environment. Memoised after the first successful read. */
export function getServerEnv(): ServerEnv {
if (cached) return cached;
const source = process.env;
const issues: string[] = [];
const NODE_ENV = readNodeEnv(source.NODE_ENV);
const STRAPI_URL = readOrigin(
"STRAPI_URL",
source.STRAPI_URL,
DEFAULTS.STRAPI_URL,
issues,
);
const SITE_URL = readOrigin(
"SITE_URL",
source.SITE_URL,
DEFAULTS.SITE_URL,
issues,
);
const token = source.STRAPI_TOKEN?.trim();
if (issues.length > 0) throw new EnvError(issues);
cached = {
NODE_ENV,
STRAPI_URL,
SITE_URL,
...(token ? { STRAPI_TOKEN: token } : {}),
isProduction: NODE_ENV === "production",
isDevelopment: NODE_ENV === "development",
};
return cached;
}
/** Drop the memoised value. Only useful in tests. */
export function resetServerEnv(): void {
cached = undefined;
}
/**
* The origin canonical URLs and hreflang alternates are built from.
*
* SITE_URL wins when it is set to something other than the default, so a
* preview deployment behind a proxy still emits its own canonical. Otherwise
* the incoming request is trusted, honouring the standard forwarding headers.
*/
export function getSiteUrl(request: Request): string {
const { SITE_URL } = getServerEnv();
if (process.env.SITE_URL?.trim()) return SITE_URL;
const url = new URL(request.url);
const forwardedHost =
request.headers.get("x-forwarded-host") ?? request.headers.get("host");
const forwardedProto = request.headers.get("x-forwarded-proto");
const protocol = forwardedProto?.split(",")[0]?.trim() ?? url.protocol.replace(":", "");
const host = forwardedHost?.split(",")[0]?.trim() ?? url.host;
return trimTrailingSlash(`${protocol}://${host}`);
}
+59
View File
@@ -0,0 +1,59 @@
/* ============================================================================
ROUTE HANDLE
----------------------------------------------------------------------------
The shell reads a few presentation decisions off the deepest route's
`handle`, so a page can flip the header or footer to the light surface
without the root layout knowing anything about that page.
export const handle = { navSurface: "light" } satisfies RouteHandle;
========================================================================= */
/** Which tier-2 surface a shell element paints. `:root` is the dark one. */
export type ShellSurface = "dark" | "light";
export interface RouteHandle {
/** Surface for the sticky header. Default "dark". */
readonly navSurface?: ShellSurface;
/** Surface for the footer. Default "dark". */
readonly footerSurface?: ShellSurface;
/**
* Override the canonical pathname the shell emits for this route - e.g. a
* paginated listing that should point page 2 at page 1. Locale-prefixed.
*/
readonly canonicalPath?: string;
/**
* "This page emits its own canonical + hreflang - do not add the shell's
* prefix-swapped pair." Set by detail routes, whose Arabic counterpart has
* its own Strapi slug, so swapping the locale prefix alone points the
* alternate at a URL that does not exist.
*/
readonly selfAlternates?: boolean;
/** Anything a route wants to hang off its own handle. */
readonly [key: string]: unknown;
}
function readSurface(value: unknown): ShellSurface | undefined {
return value === "dark" || value === "light" ? value : undefined;
}
export function readNavSurface(handle: Record<string, unknown>): ShellSurface | undefined {
return readSurface(handle.navSurface);
}
export function readFooterSurface(
handle: Record<string, unknown>,
): ShellSurface | undefined {
return readSurface(handle.footerSurface);
}
export function readCanonicalPath(
handle: Record<string, unknown>,
): string | undefined {
return typeof handle.canonicalPath === "string" ? handle.canonicalPath : undefined;
}
export function readSelfAlternates(
handle: Record<string, unknown>,
): boolean | undefined {
return handle.selfAlternates === true ? true : undefined;
}
+75
View File
@@ -0,0 +1,75 @@
/* ============================================================================
ROUTE SLUGS - the single source of truth for the site's ten sections
----------------------------------------------------------------------------
This module has ZERO imports on purpose: it is loaded by `app/routes.ts`,
which the React Router dev plugin evaluates in Node while building the route
config, long before the React tree exists.
This table is the source of truth for the site's URLs, and the consumers
are checked against it rather than repeating it: the footer sitemap imports
`ROUTE_SLUGS` directly, and `NAV_PATHS` in
~/components/ui/Navigation/nav-items.ts is constrained by `RouteSlugs`, so
a slug changed here and forgotten there is a type error, not a dead link.
Both locales share the Latin slug; the locale lives in the URL prefix
(/en/contact, /ar/contact) and never in the slug itself.
========================================================================= */
/**
* Slug for every top-level section, WITHOUT a leading slash so it can be
* handed straight to `route()` in the route config. `home` is the index of the
* `:lang` layout and therefore has no slug of its own.
*/
export const ROUTE_SLUGS = {
home: "",
about: "about",
whatWeDo: "what-we-do",
activities: "activities-and-initiatives",
events: "events-calendar",
applications: "applications-and-opportunities",
news: "news-and-updates",
contact: "contact",
resources: "resources-and-publications",
knowledgeHub: "knowledge-hub",
} as const;
export type RouteSlugKey = keyof typeof ROUTE_SLUGS;
/**
* The table's own shape, so a consumer can be typed against the exact slugs
* without importing the value and taking on this module's Node-time role.
*/
export type RouteSlugs = typeof ROUTE_SLUGS;
/** The ten section keys in navigation order. */
export const ROUTE_SLUG_KEYS = Object.keys(ROUTE_SLUGS) as readonly RouteSlugKey[];
/**
* The unprefixed pathname of a section, e.g. "/" or "/what-we-do".
* Use `sectionPath()` from ~/i18n when you want the locale-prefixed URL.
*/
export function sectionSlugPath(key: RouteSlugKey): string {
const slug = ROUTE_SLUGS[key];
return slug === "" ? "/" : `/${slug}`;
}
/** The IA groups the navigation and the footer sitemap both render. */
export const ROUTE_SECTION_GROUPS = [
{
id: "about-and-strategy",
children: ["about", "whatWeDo", "activities"],
},
{
id: "engagement-and-opportunities",
children: ["events", "applications", "news", "contact"],
},
{
id: "knowledge-and-impact",
children: ["resources", "knowledgeHub"],
},
] as const satisfies readonly {
id: string;
children: readonly RouteSlugKey[];
}[];
export type RouteSectionGroupId = (typeof ROUTE_SECTION_GROUPS)[number]["id"];
+71
View File
@@ -0,0 +1,71 @@
/* ============================================================================
SAFE HREF
----------------------------------------------------------------------------
Every URL the site renders into an `href` comes out of Strapi, which means
it is typed by an editor account rather than by this codebase. An `href` is
NOT inert: `javascript:...`, `data:text/html,...` and `vbscript:...` all
execute on click, so a single compromised or careless editor account is an
XSS vector unless the scheme is checked here, at the point of rendering.
`safeHref` is that check, and it is the ONLY thing allowed to decide whether
a CMS-supplied URL becomes a link. It returns the cleaned URL when it is
safe to put in an `href`, and `undefined` when it is not - and `undefined`
must be rendered as plain text, never as an `<a>` with a missing or empty
`href`.
WHAT PASSES
- a fragment (`#section`) or a relative/root-relative path (`/en/about`,
`about`, `?page=2`) - no scheme, so nothing can be smuggled
- an absolute URL whose protocol is http, https, mailto or tel
WHAT IS STRIPPED FIRST
Browsers ignore control characters and whitespace inside a scheme, so
a newline- or tab-split `java<LF>script:alert(1)` navigates exactly like
`javascript:alert(1)`. Those characters are normalised away BEFORE the
scheme is read, so this check sees what the browser will see.
========================================================================= */
/**
* Characters a browser drops (or treats as insignificant) while parsing a URL:
* C0/C1 controls, tab and newlines, plus the zero-width and bidi marks that can
* be pasted invisibly into a CMS field.
*/
const IGNORED_CHARS =
/[\u0000-\u001F\u007F-\u009F\u200B-\u200F\u2028\u2029\u202A-\u202E\uFEFF]/g;
/** A leading `scheme:`, per RFC 3986. */
const HAS_SCHEME = /^[a-zA-Z][a-zA-Z0-9+.-]*:/;
/** The only protocols an editor-supplied absolute URL may use. */
const ALLOWED_PROTOCOLS: ReadonlySet<string> = new Set([
"http:",
"https:",
"mailto:",
"tel:",
]);
/**
* The URL to render, or `undefined` when the value must not become a link.
*
* @param url anything a CMS field might hold - unknown on purpose, because a
* JSON body is not typed until it has been checked.
*/
export function safeHref(url: unknown): string | undefined {
if (typeof url !== "string") return undefined;
const value = url.replace(IGNORED_CHARS, "").trim();
if (value === "") return undefined;
// No scheme means a fragment, a query or a path: there is nothing to execute.
if (!HAS_SCHEME.test(value)) return value;
let parsed: URL;
try {
parsed = new URL(value);
} catch {
// A scheme that does not parse is not a URL anyone should be sent to.
return undefined;
}
return ALLOWED_PROTOCOLS.has(parsed.protocol) ? value : undefined;
}
+566
View File
@@ -0,0 +1,566 @@
/* ============================================================================
SEO
----------------------------------------------------------------------------
One function builds the whole `meta` descriptor array for a page: title,
description, robots, canonical, the en/ar/x-default hreflang set, Open Graph,
Twitter, and any JSON-LD the route wants to attach.
Pure and client-safe: `meta` also runs in the browser on a client-side
navigation, so nothing here may touch `process.env`. The origin arrives as
`siteUrl`, which the root loader resolves once per request via
`getSiteUrl(request)` from ~/lib/env.server.
========================================================================= */
import type { MetaDescriptor } from "react-router";
import {
INTL_LOCALE,
OG_LOCALE,
SUPPORTED_LOCALES,
type Locale,
} from "~/i18n/config";
import { getCounterpartLocale, localizedPath, stripLocale } from "~/i18n/paths";
import { getMessages } from "~/i18n/t";
export const SITE_NAME = "SHARP-ER";
/** Separator between the page title and the site name. */
const TITLE_SEPARATOR = " | ";
/** A JSON-LD node. Kept structural - schema.org has no useful TS type. */
export type JsonLd = Readonly<Record<string, unknown>>;
export interface SeoImage {
/** Absolute URL, or a path rooted at the site (e.g. "/assets/og.png"). */
readonly url: string;
readonly alt?: string;
readonly width?: number;
readonly height?: number;
/** MIME type, e.g. "image/png". */
readonly type?: string;
}
export interface SeoArticle {
readonly publishedTime?: string;
readonly modifiedTime?: string;
readonly authors?: readonly string[];
readonly section?: string;
readonly tags?: readonly string[];
}
export interface SeoInput {
readonly locale: Locale;
/** Pathname of the page INCLUDING its locale prefix, e.g. "/ar/contact". */
readonly pathname: string;
/** Site origin without a trailing slash, from the root loader. */
readonly siteUrl: string;
/** Page title without the site name - it is appended for you. */
readonly title: string;
readonly description?: string;
readonly image?: SeoImage;
readonly type?: "website" | "article";
/** Emit `robots: noindex, nofollow`. Use on search and filter result pages. */
readonly noIndex?: boolean;
readonly keywords?: readonly string[];
readonly article?: SeoArticle;
/** Append " | SHARP-ER" to the title. Default true. */
readonly appendSiteName?: boolean;
/**
* Also emit `<link rel="canonical">` and the hreflang set from THIS page's
* meta. Default false, and it should normally stay false: the app shell
* emits both for every route from `root.tsx`'s `Layout`, so a page that
* writes its own `meta` cannot accidentally drop them. Turn it on only when
* rendering a document outside that shell.
*/
readonly alternates?: boolean;
/** JSON-LD to embed, from the builders below or hand-written. */
readonly jsonLd?: JsonLd | readonly JsonLd[];
}
/* ---------------------------------------------------------------------------
URLS
------------------------------------------------------------------------ */
const ABSOLUTE_URL = /^(?:[a-z][a-z\d+\-.]*:|\/\/)/i;
/** Join a site origin and a path into an absolute URL. */
export function absoluteUrl(siteUrl: string, path: string): string {
if (ABSOLUTE_URL.test(path)) return path;
return `${siteUrl.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`;
}
/** The canonical URL of a page. Trailing slashes are normalised away. */
export function canonicalUrl(siteUrl: string, pathname: string): string {
const path = pathname.replace(/\/+$/, "") || "/";
return absoluteUrl(siteUrl, path);
}
export interface LocaleAlternate {
readonly locale: Locale;
readonly hrefLang: string;
readonly href: string;
}
/**
* The hreflang set for a page: one entry per locale plus `x-default`, which
* points at the English URL because English is the default locale that bare
* "/" negotiates towards.
*/
export function alternateUrls(
siteUrl: string,
pathname: string,
): LocaleAlternate[] {
const unprefixed = stripLocale(pathname);
const alternates: LocaleAlternate[] = SUPPORTED_LOCALES.map((locale) => ({
locale,
hrefLang: locale,
href: canonicalUrl(siteUrl, localizedPath(unprefixed, locale)),
}));
return [
...alternates,
{
locale: "en",
hrefLang: "x-default",
href: canonicalUrl(siteUrl, localizedPath(unprefixed, "en")),
},
];
}
/* ---------------------------------------------------------------------------
META
------------------------------------------------------------------------ */
function formatTitle(input: SeoInput): string {
const { title, appendSiteName = true } = input;
if (!appendSiteName) return title;
if (title === SITE_NAME || title.includes(SITE_NAME)) return title;
return `${title}${TITLE_SEPARATOR}${SITE_NAME}`;
}
/**
* The full `meta` descriptor array for a page.
*
* export function meta({ matches }: Route.MetaArgs) {
* const root = matches[0].data;
* return buildMeta({
* locale: root.locale, pathname: root.pathname, siteUrl: root.siteUrl,
* title: "Contact", description: "...",
* });
* }
*/
export function buildMeta(input: SeoInput): MetaDescriptor[] {
const {
locale,
pathname,
siteUrl,
description,
image,
type = "website",
noIndex = false,
keywords,
article,
alternates = false,
jsonLd,
} = input;
const messages = getMessages(locale);
const title = formatTitle(input);
const url = canonicalUrl(siteUrl, pathname);
const descriptionText = description ?? messages.site.description;
const counterpart = getCounterpartLocale(locale);
const descriptors: MetaDescriptor[] = [
{ title },
{ name: "description", content: descriptionText },
];
if (keywords && keywords.length > 0) {
descriptors.push({ name: "keywords", content: keywords.join(", ") });
}
descriptors.push({
name: "robots",
content: noIndex ? "noindex, nofollow" : "index, follow",
});
if (alternates) {
descriptors.push({ tagName: "link", rel: "canonical", href: url });
for (const alternate of alternateUrls(siteUrl, pathname)) {
descriptors.push({
tagName: "link",
rel: "alternate",
hrefLang: alternate.hrefLang,
href: alternate.href,
});
}
}
descriptors.push(
{ property: "og:type", content: type },
{ property: "og:site_name", content: SITE_NAME },
{ property: "og:title", content: title },
{ property: "og:description", content: descriptionText },
{ property: "og:url", content: url },
{ property: "og:locale", content: OG_LOCALE[locale] },
{ property: "og:locale:alternate", content: OG_LOCALE[counterpart] },
);
if (image) {
const imageUrl = absoluteUrl(siteUrl, image.url);
descriptors.push({ property: "og:image", content: imageUrl });
if (image.alt) {
descriptors.push({ property: "og:image:alt", content: image.alt });
}
if (image.width) {
descriptors.push({
property: "og:image:width",
content: String(image.width),
});
}
if (image.height) {
descriptors.push({
property: "og:image:height",
content: String(image.height),
});
}
if (image.type) {
descriptors.push({ property: "og:image:type", content: image.type });
}
}
if (type === "article" && article) {
if (article.publishedTime) {
descriptors.push({
property: "article:published_time",
content: article.publishedTime,
});
}
if (article.modifiedTime) {
descriptors.push({
property: "article:modified_time",
content: article.modifiedTime,
});
}
if (article.section) {
descriptors.push({
property: "article:section",
content: article.section,
});
}
for (const author of article.authors ?? []) {
descriptors.push({ property: "article:author", content: author });
}
for (const tag of article.tags ?? []) {
descriptors.push({ property: "article:tag", content: tag });
}
}
descriptors.push(
{
name: "twitter:card",
content: image ? "summary_large_image" : "summary",
},
{ name: "twitter:title", content: title },
{ name: "twitter:description", content: descriptionText },
);
if (image) {
descriptors.push({
name: "twitter:image",
content: absoluteUrl(siteUrl, image.url),
});
if (image.alt) {
descriptors.push({ name: "twitter:image:alt", content: image.alt });
}
}
for (const node of toArray(jsonLd)) {
descriptors.push({ "script:ld+json": node });
}
return descriptors;
}
function toArray(value: JsonLd | readonly JsonLd[] | undefined): JsonLd[] {
if (!value) return [];
return Array.isArray(value) ? [...value] : [value as JsonLd];
}
/* ---------------------------------------------------------------------------
JSON-LD BUILDERS
Facts are taken from the ToR and the approved draft copy: EU-funded,
December 2025 - December 2027, nationwide Libya, Acted (lead) with UEC and
SBE. Nothing here is speculative.
------------------------------------------------------------------------ */
const SCHEMA_CONTEXT = "https://schema.org";
export const CONSORTIUM_PARTNERS = [
{ name: "Acted", role: "Lead implementing partner" },
{ name: "United Efforts Consultancy", role: "Consortium partner" },
{ name: "Stichting Bureau Ensemble", role: "Consortium partner" },
] as const;
export interface OrganizationJsonLdInput {
readonly siteUrl: string;
readonly locale: Locale;
/** Public profiles, e.g. the project's Facebook page. */
readonly sameAs?: readonly string[];
readonly email?: string;
}
/** Organization JSON-LD for the site itself. Emit it once, from the home page. */
export function buildOrganizationJsonLd({
siteUrl,
locale,
sameAs,
email,
}: OrganizationJsonLdInput): JsonLd {
const messages = getMessages(locale);
return {
"@context": SCHEMA_CONTEXT,
"@type": "Organization",
name: messages.site.name,
alternateName: messages.site.fullName,
description: messages.site.description,
url: canonicalUrl(siteUrl, localizedPath("/", locale)),
logo: absoluteUrl(siteUrl, "/assets/sharp-er-logo.png"),
inLanguage: INTL_LOCALE[locale],
areaServed: { "@type": "Country", name: "Libya" },
funder: { "@type": "Organization", name: "European Union" },
member: CONSORTIUM_PARTNERS.map((partner) => ({
"@type": "Organization",
name: partner.name,
})),
...(email ? { email } : {}),
...(sameAs && sameAs.length > 0 ? { sameAs: [...sameAs] } : {}),
};
}
export interface WebSiteJsonLdInput {
readonly siteUrl: string;
readonly locale: Locale;
/** Path of the site search route, e.g. "/en/search". Omit to skip the action. */
readonly searchPath?: string;
}
export function buildWebSiteJsonLd({
siteUrl,
locale,
searchPath,
}: WebSiteJsonLdInput): JsonLd {
const messages = getMessages(locale);
return {
"@context": SCHEMA_CONTEXT,
"@type": "WebSite",
name: messages.site.name,
url: canonicalUrl(siteUrl, localizedPath("/", locale)),
inLanguage: INTL_LOCALE[locale],
...(searchPath
? {
potentialAction: {
"@type": "SearchAction",
target: {
"@type": "EntryPoint",
urlTemplate: `${absoluteUrl(siteUrl, searchPath)}?q={search_term_string}`,
},
"query-input": "required name=search_term_string",
},
}
: {}),
};
}
export interface EventJsonLdInput {
readonly siteUrl: string;
readonly locale: Locale;
readonly name: string;
/** Path of the event page, including the locale prefix. */
readonly pathname: string;
/** ISO 8601. */
readonly startDate: string;
readonly endDate?: string;
readonly description?: string;
readonly image?: string;
/** Venue name, e.g. "Tripoli". Omit for an online-only event. */
readonly locationName?: string;
readonly addressLocality?: string;
readonly addressCountry?: string;
/** Joining URL for an online or hybrid event. */
readonly onlineUrl?: string;
readonly isOnline?: boolean;
readonly registrationUrl?: string;
readonly status?: "scheduled" | "cancelled" | "postponed" | "rescheduled";
readonly organizerName?: string;
}
const EVENT_STATUS: Readonly<Record<NonNullable<EventJsonLdInput["status"]>, string>> = {
scheduled: "https://schema.org/EventScheduled",
cancelled: "https://schema.org/EventCancelled",
postponed: "https://schema.org/EventPostponed",
rescheduled: "https://schema.org/EventRescheduled",
};
/** Event JSON-LD for an event detail page or an events listing. */
export function buildEventJsonLd(input: EventJsonLdInput): JsonLd {
const {
siteUrl,
locale,
name,
pathname,
startDate,
endDate,
description,
image,
locationName,
addressLocality,
addressCountry = "LY",
onlineUrl,
isOnline = false,
registrationUrl,
status = "scheduled",
organizerName = SITE_NAME,
} = input;
const hasPhysicalLocation = Boolean(locationName ?? addressLocality);
const attendanceMode = isOnline
? hasPhysicalLocation
? "https://schema.org/MixedEventAttendanceMode"
: "https://schema.org/OnlineEventAttendanceMode"
: "https://schema.org/OfflineEventAttendanceMode";
const physicalLocation = hasPhysicalLocation
? {
"@type": "Place",
name: locationName ?? addressLocality,
address: {
"@type": "PostalAddress",
...(addressLocality ? { addressLocality } : {}),
addressCountry,
},
}
: undefined;
const virtualLocation = onlineUrl
? { "@type": "VirtualLocation", url: onlineUrl }
: undefined;
const locations = [physicalLocation, virtualLocation].filter(Boolean);
return {
"@context": SCHEMA_CONTEXT,
"@type": "Event",
name,
startDate,
...(endDate ? { endDate } : {}),
...(description ? { description } : {}),
...(image ? { image: absoluteUrl(siteUrl, image) } : {}),
url: canonicalUrl(siteUrl, pathname),
inLanguage: INTL_LOCALE[locale],
eventAttendanceMode: attendanceMode,
eventStatus: EVENT_STATUS[status],
...(locations.length === 1
? { location: locations[0] }
: locations.length > 1
? { location: locations }
: {}),
organizer: { "@type": "Organization", name: organizerName },
...(registrationUrl
? {
offers: {
"@type": "Offer",
url: registrationUrl,
price: "0",
priceCurrency: "LYD",
availability: "https://schema.org/InStock",
},
}
: {}),
};
}
export interface ArticleJsonLdInput {
readonly siteUrl: string;
readonly locale: Locale;
readonly headline: string;
/** Path of the article, including the locale prefix. */
readonly pathname: string;
readonly datePublished: string;
readonly dateModified?: string;
readonly description?: string;
readonly image?: string;
readonly authorName?: string;
readonly section?: string;
readonly keywords?: readonly string[];
}
/** Article JSON-LD for a news item, a publication or a knowledge-hub page. */
export function buildArticleJsonLd(input: ArticleJsonLdInput): JsonLd {
const {
siteUrl,
locale,
headline,
pathname,
datePublished,
dateModified,
description,
image,
authorName,
section,
keywords,
} = input;
const url = canonicalUrl(siteUrl, pathname);
return {
"@context": SCHEMA_CONTEXT,
"@type": "Article",
headline,
...(description ? { description } : {}),
...(image ? { image: absoluteUrl(siteUrl, image) } : {}),
datePublished,
dateModified: dateModified ?? datePublished,
inLanguage: INTL_LOCALE[locale],
url,
mainEntityOfPage: { "@type": "WebPage", "@id": url },
author: { "@type": "Organization", name: authorName ?? SITE_NAME },
publisher: {
"@type": "Organization",
name: SITE_NAME,
logo: {
"@type": "ImageObject",
url: absoluteUrl(siteUrl, "/assets/sharp-er-logo.png"),
},
},
...(section ? { articleSection: section } : {}),
...(keywords && keywords.length > 0 ? { keywords: keywords.join(", ") } : {}),
};
}
export interface BreadcrumbItem {
readonly name: string;
/** Path including the locale prefix. */
readonly pathname: string;
}
/** BreadcrumbList JSON-LD. Pass the trail from the home page to this page. */
export function buildBreadcrumbJsonLd(
siteUrl: string,
items: readonly BreadcrumbItem[],
): JsonLd {
return {
"@context": SCHEMA_CONTEXT,
"@type": "BreadcrumbList",
itemListElement: items.map((item, index) => ({
"@type": "ListItem",
position: index + 1,
name: item.name,
item: canonicalUrl(siteUrl, item.pathname),
})),
};
}
+79
View File
@@ -0,0 +1,79 @@
/* ============================================================================
SITE NAVIGATION DATA
----------------------------------------------------------------------------
Builds the <Navigation /> tree and the <Footer /> sitemap from ONE source:
`ROUTE_SLUGS` (which `app/routes.ts` also builds the route table from) plus
the `nav.sections.*` labels in the message catalogues. Slugs, routes, header
links and footer links therefore cannot drift apart.
<Footer />'s own defaults use a shorter slug set (/en/events, /en/news, ...);
the shell passes these groups explicitly so both bars point at the routes
that actually exist.
When the CMS is wired, replace these two functions with a Strapi
site-settings query - the shapes they return are exactly what both
components accept as props.
========================================================================= */
import type { FooterLinkGroup } from "~/components/ui/Footer";
import type { NavItem } from "~/components/ui/Navigation";
import type { Locale } from "~/i18n/config";
import { ROUTE_SECTION_GROUPS, sectionPath, type RouteSlugKey } from "~/i18n/paths";
import { getMessages } from "~/i18n/t";
/** Section labels for a locale, keyed the same way as `ROUTE_SLUGS`. */
function sectionLabels(locale: Locale) {
return getMessages(locale).nav.sections;
}
/** Heading for one of the three IA groups. */
function groupHeading(locale: Locale, groupId: string): string {
const labels = sectionLabels(locale);
switch (groupId) {
case "about-and-strategy":
return labels.aboutAndStrategy;
case "engagement-and-opportunities":
return labels.engagementAndOpportunities;
default:
return labels.knowledgeAndImpact;
}
}
/**
* The header navigation: Home as a link, then the three groups as
* disclosures. Every href is already locale-prefixed.
*/
export function buildNavItems(locale: Locale): NavItem[] {
const labels = sectionLabels(locale);
return [
{ id: "home", label: labels.home, href: sectionPath("home", locale) },
...ROUTE_SECTION_GROUPS.map((group) => ({
id: group.id,
label: groupHeading(locale, group.id),
children: group.children.map((key: RouteSlugKey) => ({
id: key,
label: labels[key],
href: sectionPath(key, locale),
})),
})),
];
}
/**
* The footer sitemap: the same three groups, without Home - the wordmark
* above the columns already links there.
*/
export function buildFooterLinkGroups(locale: Locale): FooterLinkGroup[] {
const labels = sectionLabels(locale);
return ROUTE_SECTION_GROUPS.map((group) => ({
id: group.id,
heading: groupHeading(locale, group.id),
links: group.children.map((key: RouteSlugKey) => ({
label: labels[key],
href: sectionPath(key, locale),
})),
}));
}
+700
View File
@@ -0,0 +1,700 @@
/* ============================================================================
STRAPI 5 REST CLIENT
----------------------------------------------------------------------------
SERVER ONLY. This module imports ~/lib/env.server, which the React Router
Vite plugin refuses to include in the browser bundle - so calling any of
these from component code is a build error, not a leaked API token. Call
them from `loader` / `action` only.
Strapi 5 returns FLAT entities: `{ data: { id, documentId, title, ... },
meta }` rather than v4's `{ data: { id, attributes: { ... } } }`, and a
record is addressed by its `documentId`, not by `id`. The normaliser below
still unwraps the v4 shapes, because a plugin route or a hand-written
controller can hand back the old envelope.
========================================================================= */
import { data } from "react-router";
import { getServerEnv } from "./env.server";
/* ---------------------------------------------------------------------------
RESPONSE SHAPES
------------------------------------------------------------------------ */
/** Fields Strapi 5 puts on every entity. Extend it with the content type. */
export interface StrapiEntity {
readonly id: number;
readonly documentId: string;
readonly createdAt: string;
readonly updatedAt: string;
readonly publishedAt: string | null;
readonly locale?: string;
}
export interface StrapiPagination {
readonly page: number;
readonly pageSize: number;
readonly pageCount: number;
readonly total: number;
}
export interface StrapiMeta {
readonly pagination?: StrapiPagination;
readonly [key: string]: unknown;
}
/** A single-entity or single-type response. */
export interface StrapiSingleResponse<T> {
readonly data: T;
readonly meta: StrapiMeta;
}
/** A collection response, with `pagination` lifted out of `meta` for ease. */
export interface StrapiCollectionResponse<T> {
readonly data: T[];
readonly meta: StrapiMeta;
readonly pagination?: StrapiPagination;
}
/** An upload-plugin media entity, as returned inside a populated field. */
export interface StrapiMedia {
readonly id: number;
readonly documentId?: string;
readonly name: string;
readonly alternativeText: string | null;
readonly caption: string | null;
readonly width: number | null;
readonly height: number | null;
readonly ext: string | null;
readonly mime: string;
readonly size: number;
readonly url: string;
readonly previewUrl: string | null;
readonly formats: Readonly<Record<string, StrapiMediaFormat>> | null;
}
export interface StrapiMediaFormat {
readonly name: string;
readonly ext: string;
readonly mime: string;
readonly width: number;
readonly height: number;
readonly size: number;
readonly url: string;
}
export type StrapiMediaFormatName =
| "thumbnail"
| "small"
| "medium"
| "large";
/* ---------------------------------------------------------------------------
QUERY
------------------------------------------------------------------------ */
export type StrapiSort = string | readonly string[];
/** `"*"`, a field list, or Strapi's nested populate object. */
export type StrapiPopulate =
| "*"
| readonly string[]
| { readonly [key: string]: unknown };
export interface StrapiPaginationQuery {
readonly page?: number;
readonly pageSize?: number;
readonly start?: number;
readonly limit?: number;
readonly withCount?: boolean;
}
export interface StrapiQuery {
/** Content locale. Pass the site locale so /ar/* reads Arabic entries. */
readonly locale?: string;
readonly populate?: StrapiPopulate;
/** Nested filter object, e.g. `{ slug: { $eq: "about" } }`. */
readonly filters?: Readonly<Record<string, unknown>>;
readonly sort?: StrapiSort;
readonly fields?: readonly string[];
readonly pagination?: StrapiPaginationQuery;
/** Strapi 5 draft & publish. Defaults to the API default (published). */
readonly status?: "published" | "draft";
/** Anything else Strapi or a plugin accepts, serialised verbatim. */
readonly params?: Readonly<Record<string, unknown>>;
}
export interface StrapiRequestInit {
/** Abort the request after this many ms. Default 10000. */
readonly timeoutMs?: number;
/** Forwarded to `fetch`, e.g. to chain a route's own AbortSignal. */
readonly signal?: AbortSignal;
readonly headers?: Readonly<Record<string, string>>;
/** Standard `fetch` caching hint, honoured by the runtime's fetch. */
readonly cache?: RequestCache;
}
/* ---------------------------------------------------------------------------
ERRORS
------------------------------------------------------------------------ */
/** Strapi's own error envelope, when the response body carries one. */
export interface StrapiErrorBody {
readonly status?: number;
readonly name?: string;
readonly message?: string;
readonly details?: unknown;
}
/** Any non-2xx response, or a transport/timeout failure. */
export class StrapiError extends Error {
override readonly name = "StrapiError";
/** HTTP status, or 0 when the request never completed. */
readonly status: number;
readonly statusText: string;
readonly url: string;
readonly body: StrapiErrorBody | undefined;
constructor(init: {
message: string;
status: number;
statusText?: string;
url: string;
body?: StrapiErrorBody;
cause?: unknown;
}) {
super(init.message, init.cause === undefined ? undefined : { cause: init.cause });
this.status = init.status;
this.statusText = init.statusText ?? "";
this.url = init.url;
this.body = init.body;
}
}
export function isStrapiError(value: unknown): value is StrapiError {
return value instanceof StrapiError;
}
/** True for a 404 from Strapi - i.e. "the entity is not there". */
export function isStrapiNotFound(value: unknown): boolean {
return isStrapiError(value) && value.status === 404;
}
/**
* Throw a React Router 404 so the root ErrorBoundary renders the localised
* "Page not found" page. `isRouteErrorResponse(error)` sees it as a 404.
*/
export function notFound(message = "Not Found"): never {
throw data(message, { status: 404, statusText: "Not Found" });
}
/**
* Turn "the CMS returned nothing" into a 404 inside a loader:
* const event = requireEntity(await getBySlug<Event>("events", params.slug));
*/
export function requireEntity<T>(
entity: T | null | undefined,
message = "Not Found",
): T {
if (entity === null || entity === undefined) notFound(message);
return entity;
}
/* ---------------------------------------------------------------------------
QUERY SERIALISATION
Strapi speaks `qs` bracket syntax - filters[slug][$eq]=about,
populate[cover][fields][0]=url, sort[0]=date:desc - so the serialiser walks
the object rather than pulling in the `qs` package.
------------------------------------------------------------------------ */
function appendDeep(
search: URLSearchParams,
prefix: string,
value: unknown,
): void {
if (value === undefined || value === null) return;
if (value instanceof Date) {
search.append(prefix, value.toISOString());
return;
}
if (Array.isArray(value)) {
value.forEach((item, index) => {
appendDeep(search, `${prefix}[${index}]`, item);
});
return;
}
if (typeof value === "object") {
for (const [key, nested] of Object.entries(value as Record<string, unknown>)) {
appendDeep(search, `${prefix}[${key}]`, nested);
}
return;
}
search.append(prefix, String(value));
}
/** Serialise a `StrapiQuery` into Strapi's `qs` bracket syntax. */
export function stringifyStrapiQuery(query: StrapiQuery = {}): string {
const search = new URLSearchParams();
const { params, ...rest } = query;
for (const [key, value] of Object.entries(rest)) {
appendDeep(search, key, value);
}
if (params) {
for (const [key, value] of Object.entries(params)) {
appendDeep(search, key, value);
}
}
// Strapi accepts these unescaped, and a query is far easier to read in a
// log as `filters[date][$gte]=2026-01-01` than as `%5Bdate%5D%5B%24gte%5D`.
return search
.toString()
.replace(/%5B/g, "[")
.replace(/%5D/g, "]")
.replace(/%24/g, "$")
.replace(/%3A/g, ":");
}
/* ---------------------------------------------------------------------------
NORMALISATION
------------------------------------------------------------------------ */
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
/**
* Flatten a v4-style `{ id, attributes }` entity and unwrap a v4 relation
* envelope `{ data, meta? }`. Strapi 5 payloads pass through untouched: they
* match neither signature. Both checks are deliberately narrow so a content
* field genuinely called `data` is not mistaken for an envelope.
*/
export function normalizeStrapiValue<T>(value: unknown): T {
if (Array.isArray(value)) {
return value.map((item) => normalizeStrapiValue(item)) as T;
}
if (!isPlainObject(value)) return value as T;
const keys = Object.keys(value);
const isRelationEnvelope =
"data" in value &&
keys.length <= 2 &&
keys.every((key) => key === "data" || key === "meta");
if (isRelationEnvelope) {
return normalizeStrapiValue(value.data) as T;
}
const isV4Entity =
typeof value.id === "number" && isPlainObject(value.attributes);
if (isV4Entity) {
return {
id: value.id,
...(normalizeStrapiValue(value.attributes) as Record<string, unknown>),
} as T;
}
const result: Record<string, unknown> = {};
for (const [key, nested] of Object.entries(value)) {
result[key] = normalizeStrapiValue(nested);
}
return result as T;
}
/* ---------------------------------------------------------------------------
MEDIA
------------------------------------------------------------------------ */
const ABSOLUTE_URL = /^(?:[a-z][a-z\d+\-.]*:|\/\/)/i;
/**
* Absolutise a Strapi media URL. Local uploads come back as "/uploads/x.jpg";
* an S3/Cloudinary provider already returns an absolute URL, which is passed
* through untouched.
*/
export function mediaUrl(
url: string | null | undefined,
baseUrl?: string,
): string | undefined {
if (!url) return undefined;
if (ABSOLUTE_URL.test(url)) return url;
const origin = baseUrl ?? getServerEnv().STRAPI_URL;
return `${origin.replace(/\/+$/, "")}/${url.replace(/^\/+/, "")}`;
}
/** The absolute URL of one responsive format, falling back to the original. */
export function mediaFormatUrl(
media: StrapiMedia | null | undefined,
format: StrapiMediaFormatName,
baseUrl?: string,
): string | undefined {
if (!media) return undefined;
return mediaUrl(media.formats?.[format]?.url ?? media.url, baseUrl);
}
/** `alternativeText` when the CMS has one, otherwise "" (decorative). */
export function mediaAlt(media: StrapiMedia | null | undefined): string {
return media?.alternativeText ?? "";
}
/* ---------------------------------------------------------------------------
CLIENT
------------------------------------------------------------------------ */
export interface StrapiClientOptions {
/** Overrides STRAPI_URL. No trailing slash required. */
readonly baseUrl?: string;
/** Overrides STRAPI_TOKEN. */
readonly token?: string;
/** Injectable for tests. */
readonly fetch?: typeof fetch;
/** Default request timeout in ms. Default 10000. */
readonly timeoutMs?: number;
}
export interface StrapiClient {
readonly baseUrl: string;
/** GET /api/{resource} - a collection type. */
getMany<T>(
resource: string,
query?: StrapiQuery,
init?: StrapiRequestInit,
): Promise<StrapiCollectionResponse<T>>;
/** GET /api/{resource}/{documentId} - one entity of a collection type. */
getOne<T>(
resource: string,
documentId: string,
query?: StrapiQuery,
init?: StrapiRequestInit,
): Promise<T>;
/** As `getOne`, but a 404 from Strapi resolves to null. */
getOneOrNull<T>(
resource: string,
documentId: string,
query?: StrapiQuery,
init?: StrapiRequestInit,
): Promise<T | null>;
/** GET /api/{resource} - a single type (about-page, site-settings, ...). */
getSingle<T>(
resource: string,
query?: StrapiQuery,
init?: StrapiRequestInit,
): Promise<T | null>;
/** The first match for a filter, or null. */
findFirst<T>(
resource: string,
query?: StrapiQuery,
init?: StrapiRequestInit,
): Promise<T | null>;
/** The entry whose `slug` field equals `slug`, or null. */
getBySlug<T>(
resource: string,
slug: string,
query?: StrapiQuery,
init?: StrapiRequestInit,
): Promise<T | null>;
/** Escape hatch: any path under the Strapi origin, normalised and typed. */
request<T>(
path: string,
query?: StrapiQuery,
init?: StrapiRequestInit,
): Promise<T>;
}
const DEFAULT_TIMEOUT_MS = 10_000;
function buildUrl(baseUrl: string, resource: string, query?: StrapiQuery): string {
const path = resource.replace(/^\/+/, "");
const prefixed = path.startsWith("api/") ? path : `api/${path}`;
const qs = stringifyStrapiQuery(query);
return `${baseUrl}/${prefixed}${qs ? `?${qs}` : ""}`;
}
async function readErrorBody(response: Response): Promise<StrapiErrorBody | undefined> {
try {
const payload: unknown = await response.json();
if (isPlainObject(payload) && isPlainObject(payload.error)) {
return payload.error as StrapiErrorBody;
}
return undefined;
} catch {
return undefined;
}
}
export function createStrapiClient(
options: StrapiClientOptions = {},
): StrapiClient {
// Only read the environment when the caller has not supplied both values,
// so a test can build a client without STRAPI_URL / STRAPI_TOKEN being set.
const fullyConfigured =
options.baseUrl !== undefined && options.token !== undefined;
const env = fullyConfigured ? undefined : getServerEnv();
const baseUrl = (options.baseUrl ?? env?.STRAPI_URL ?? "").replace(/\/+$/, "");
const token = options.token ?? env?.STRAPI_TOKEN;
const doFetch = options.fetch ?? fetch;
const defaultTimeout = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
async function rawRequest<T>(
resource: string,
query?: StrapiQuery,
init: StrapiRequestInit = {},
): Promise<T> {
const url = buildUrl(baseUrl, resource, query);
const headers: Record<string, string> = {
accept: "application/json",
...init.headers,
};
if (token) headers.authorization = `Bearer ${token}`;
const timeoutSignal = AbortSignal.timeout(init.timeoutMs ?? defaultTimeout);
const signal = init.signal
? AbortSignal.any([init.signal, timeoutSignal])
: timeoutSignal;
let response: Response;
try {
response = await doFetch(url, {
method: "GET",
headers,
signal,
...(init.cache ? { cache: init.cache } : {}),
});
} catch (cause) {
const aborted = cause instanceof Error && cause.name === "TimeoutError";
throw new StrapiError({
message: aborted
? `Strapi request timed out after ${init.timeoutMs ?? defaultTimeout}ms: ${url}`
: `Strapi request failed: ${url}`,
status: 0,
url,
cause,
});
}
if (!response.ok) {
const body = await readErrorBody(response);
throw new StrapiError({
message:
body?.message ??
`Strapi responded ${response.status} ${response.statusText} for ${url}`,
status: response.status,
statusText: response.statusText,
url,
body,
});
}
if (response.status === 204) return undefined as T;
try {
return (await response.json()) as T;
} catch (cause) {
throw new StrapiError({
message: `Strapi returned a non-JSON body for ${url}`,
status: response.status,
statusText: response.statusText,
url,
cause,
});
}
}
async function envelope<T>(
resource: string,
query?: StrapiQuery,
init?: StrapiRequestInit,
): Promise<{ data: unknown; meta: StrapiMeta }> {
const payload = await rawRequest<{ data?: unknown; meta?: StrapiMeta }>(
resource,
query,
init,
);
return {
data: payload?.data,
meta: (payload?.meta ?? {}) as StrapiMeta,
};
}
const client: StrapiClient = {
baseUrl,
async getMany<T>(
resource: string,
query?: StrapiQuery,
init?: StrapiRequestInit,
): Promise<StrapiCollectionResponse<T>> {
const { data: payload, meta } = await envelope(resource, query, init);
const entries = Array.isArray(payload) ? payload : [];
return {
data: entries.map((entry) => normalizeStrapiValue<T>(entry)),
meta,
pagination: meta.pagination,
};
},
async getOne<T>(
resource: string,
documentId: string,
query?: StrapiQuery,
init?: StrapiRequestInit,
): Promise<T> {
const { data: payload } = await envelope(
`${resource}/${encodeURIComponent(documentId)}`,
query,
init,
);
return normalizeStrapiValue<T>(payload);
},
async getOneOrNull<T>(
resource: string,
documentId: string,
query?: StrapiQuery,
init?: StrapiRequestInit,
): Promise<T | null> {
try {
return await client.getOne<T>(resource, documentId, query, init);
} catch (error) {
if (isStrapiNotFound(error)) return null;
throw error;
}
},
async getSingle<T>(
resource: string,
query?: StrapiQuery,
init?: StrapiRequestInit,
): Promise<T | null> {
try {
const { data: payload } = await envelope(resource, query, init);
if (payload === null || payload === undefined) return null;
return normalizeStrapiValue<T>(payload);
} catch (error) {
if (isStrapiNotFound(error)) return null;
throw error;
}
},
async findFirst<T>(
resource: string,
query?: StrapiQuery,
init?: StrapiRequestInit,
): Promise<T | null> {
const result = await client.getMany<T>(
resource,
{
...query,
pagination: { ...query?.pagination, page: 1, pageSize: 1 },
},
init,
);
return result.data[0] ?? null;
},
async getBySlug<T>(
resource: string,
slug: string,
query?: StrapiQuery,
init?: StrapiRequestInit,
): Promise<T | null> {
return client.findFirst<T>(
resource,
{
...query,
filters: { ...query?.filters, slug: { $eq: slug } },
},
init,
);
},
async request<T>(
path: string,
query?: StrapiQuery,
init?: StrapiRequestInit,
): Promise<T> {
const payload = await rawRequest<unknown>(path, query, init);
return normalizeStrapiValue<T>(payload);
},
};
return client;
}
let defaultClient: StrapiClient | undefined;
/** The process-wide client, built from STRAPI_URL / STRAPI_TOKEN on first use. */
export function getStrapiClient(): StrapiClient {
defaultClient ??= createStrapiClient();
return defaultClient;
}
/** Drop the memoised default client. Only useful in tests. */
export function resetStrapiClient(): void {
defaultClient = undefined;
}
/* Convenience bindings so a loader reads as `getMany<Event>("events", { ... })`. */
export function getMany<T>(
resource: string,
query?: StrapiQuery,
init?: StrapiRequestInit,
): Promise<StrapiCollectionResponse<T>> {
return getStrapiClient().getMany<T>(resource, query, init);
}
export function getOne<T>(
resource: string,
documentId: string,
query?: StrapiQuery,
init?: StrapiRequestInit,
): Promise<T> {
return getStrapiClient().getOne<T>(resource, documentId, query, init);
}
export function getOneOrNull<T>(
resource: string,
documentId: string,
query?: StrapiQuery,
init?: StrapiRequestInit,
): Promise<T | null> {
return getStrapiClient().getOneOrNull<T>(resource, documentId, query, init);
}
export function getSingle<T>(
resource: string,
query?: StrapiQuery,
init?: StrapiRequestInit,
): Promise<T | null> {
return getStrapiClient().getSingle<T>(resource, query, init);
}
export function findFirst<T>(
resource: string,
query?: StrapiQuery,
init?: StrapiRequestInit,
): Promise<T | null> {
return getStrapiClient().findFirst<T>(resource, query, init);
}
export function getBySlug<T>(
resource: string,
slug: string,
query?: StrapiQuery,
init?: StrapiRequestInit,
): Promise<T | null> {
return getStrapiClient().getBySlug<T>(resource, slug, query, init);
}