initial commit
This commit is contained in:
@@ -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}`);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"];
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -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),
|
||||
})),
|
||||
}));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user