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
+136
View File
@@ -0,0 +1,136 @@
/* ============================================================================
I18N CONFIG
----------------------------------------------------------------------------
SHARP-ER is served with an EXPLICIT locale prefix on BOTH languages:
/en/... and /ar/.... Bare "/" is redirected by the server on Accept-Language.
There is no ?lang= query parameter anywhere in the product.
The locale primitives themselves live in the LanguageSwitcher's pure
`locale-path` module - it is imported by <Navigation />, <LanguageSwitcher />
and <Footer />, so re-exporting from there (rather than declaring a second
`Locale` union) keeps one type across the whole app. A relative import is
used because this module is reachable from `app/routes.ts`, which the dev
plugin evaluates outside the app's normal alias resolution.
========================================================================= */
import {
DEFAULT_LOCALE,
getDirection,
getLocaleFromPathname,
isLocale,
LOCALES,
LOCALE_ENDONYM,
LOCALE_SHORT_LABEL,
type Direction,
type Locale,
} from "../components/ui/LanguageSwitcher/locale-path";
export type { Direction, Locale };
export {
DEFAULT_LOCALE,
getLocaleFromPathname,
isLocale,
LOCALE_ENDONYM,
LOCALE_SHORT_LABEL,
};
/** Every locale the site is published in, in preference order. */
export const SUPPORTED_LOCALES = LOCALES;
/** `<html dir>` for a locale. Alias of the component library's helper. */
export const dirFor = getDirection;
/**
* The BCP-47 tag handed to `Intl.*` and to `<html lang>`'s consumers.
* Libyan Arabic content is written in MSA, and the draft copy uses Western
* (latn) digits throughout, so number formatting stays latn - see ./format.ts.
*/
export const INTL_LOCALE: Readonly<Record<Locale, string>> = {
en: "en-GB",
ar: "ar-LY",
};
/** Open Graph `og:locale` values. */
export const OG_LOCALE: Readonly<Record<Locale, string>> = {
en: "en_GB",
ar: "ar_LY",
};
/* ---------------------------------------------------------------------------
ACCEPT-LANGUAGE NEGOTIATION
Only ever used for bare "/" - once the reader is on a prefixed URL the URL
wins, so a shared /ar/... link never silently flips to English.
------------------------------------------------------------------------ */
/** One parsed entry of an `Accept-Language` header. */
export interface LanguageRange {
readonly tag: string;
readonly quality: number;
}
/**
* Parse an `Accept-Language` header into ranges sorted by descending quality.
* Malformed entries are dropped rather than throwing - this runs on a header
* an arbitrary client controls.
*/
export function parseAcceptLanguage(
header: string | null | undefined,
): LanguageRange[] {
if (!header) return [];
return header
.split(",")
.map((part, index): LanguageRange | null => {
const [rawTag, ...parameters] = part.trim().split(";");
const tag = rawTag?.trim().toLowerCase();
if (!tag) return null;
const qParameter = parameters
.map((parameter) => parameter.trim())
.find((parameter) => parameter.startsWith("q="));
const parsed = qParameter ? Number.parseFloat(qParameter.slice(2)) : 1;
const quality = Number.isFinite(parsed) ? parsed : 0;
// Index keeps the header's own order stable inside one quality band.
return { tag, quality: quality - index * 1e-6 };
})
.filter((range): range is LanguageRange => range !== null && range.quality > 0)
.sort((a, b) => b.quality - a.quality);
}
/**
* The best supported locale for an `Accept-Language` header.
* "ar-LY,ar;q=0.9,en;q=0.8" -> "ar"; "fr-FR,fr;q=0.9" -> the default locale.
*/
export function negotiateLocale(
header: string | null | undefined,
fallback: Locale = DEFAULT_LOCALE,
): Locale {
for (const { tag } of parseAcceptLanguage(header)) {
if (tag === "*") return fallback;
const base = tag.split("-")[0];
if (isLocale(base)) return base;
}
return fallback;
}
/** `negotiateLocale()` applied to a Fetch `Request`'s headers. */
export function negotiateLocaleFromRequest(
request: Request,
fallback: Locale = DEFAULT_LOCALE,
): Locale {
return negotiateLocale(request.headers.get("accept-language"), fallback);
}
/**
* The locale a URL addresses. Every page URL carries its prefix, so this is
* the authoritative resolver used by the root layout and by `useLocale()`.
*/
export function localeFromRequest(
request: Request,
fallback: Locale = DEFAULT_LOCALE,
): Locale {
return getLocaleFromPathname(new URL(request.url).pathname) ?? fallback;
}
+103
View File
@@ -0,0 +1,103 @@
/* ============================================================================
LOCALE-AWARE FORMATTING
----------------------------------------------------------------------------
Thin `Intl` wrappers so a route never hard-codes a date pattern. Arabic uses
ar-LY with the `latn` numbering system: the approved draft copy writes
Arabic dates and figures in Western digits ("30 يونيو", "2025"), so Eastern
Arabic numerals would contradict the source content.
========================================================================= */
import { INTL_LOCALE, type Locale } from "./config";
const NUMBERING_SYSTEM = "latn";
/** The BCP-47 tag (with numbering system) handed to `Intl`. */
export function intlLocale(locale: Locale): string {
return `${INTL_LOCALE[locale]}-u-nu-${NUMBERING_SYSTEM}`;
}
function toDate(value: Date | string | number): Date | null {
const date = value instanceof Date ? value : new Date(value);
return Number.isNaN(date.getTime()) ? null : date;
}
export type DateStyle = "full" | "long" | "medium" | "short";
/** "12 March 2026" / "12 مارس 2026". Returns "" for an unparseable input. */
export function formatDate(
value: Date | string | number | null | undefined,
locale: Locale,
options: Intl.DateTimeFormatOptions = { dateStyle: "long" },
): string {
if (value === null || value === undefined) return "";
const date = toDate(value);
if (!date) return "";
return new Intl.DateTimeFormat(intlLocale(locale), options).format(date);
}
/** Date plus time of day, for an event card or an event detail page. */
export function formatDateTime(
value: Date | string | number | null | undefined,
locale: Locale,
options: Intl.DateTimeFormatOptions = {
dateStyle: "long",
timeStyle: "short",
},
): string {
return formatDate(value, locale, options);
}
/** "12–14 March 2026". Falls back to a single date when `end` is missing. */
export function formatDateRange(
start: Date | string | number,
end: Date | string | number | null | undefined,
locale: Locale,
options: Intl.DateTimeFormatOptions = { dateStyle: "long" },
): string {
const from = toDate(start);
if (!from) return "";
const to = end === null || end === undefined ? null : toDate(end);
if (!to || from.getTime() === to.getTime()) {
return formatDate(from, locale, options);
}
return new Intl.DateTimeFormat(intlLocale(locale), options).formatRange(
from,
to,
);
}
/** The machine-readable value for `<time dateTime>` - always ISO, never localised. */
export function toIsoDate(
value: Date | string | number | null | undefined,
): string | undefined {
if (value === null || value === undefined) return undefined;
return toDate(value)?.toISOString();
}
export function formatNumber(
value: number,
locale: Locale,
options?: Intl.NumberFormatOptions,
): string {
return new Intl.NumberFormat(intlLocale(locale), options).format(value);
}
/** "1.2 MB" - used by the resources list for a download's file size. */
export function formatFileSize(bytes: number, locale: Locale): string {
const units = ["B", "KB", "MB", "GB"] as const;
let size = Math.max(0, bytes);
let unit = 0;
while (size >= 1024 && unit < units.length - 1) {
size /= 1024;
unit += 1;
}
const formatted = formatNumber(size, locale, {
maximumFractionDigits: unit === 0 ? 0 : 1,
});
return `${formatted} ${units[unit]}`;
}
+111
View File
@@ -0,0 +1,111 @@
/* ============================================================================
I18N HOOKS
----------------------------------------------------------------------------
The locale is a property of the URL, never of a cookie or of client state,
so every hook here is derived from the router - which means SSR and the
first client render agree by construction and hydration never mismatches.
`useParams().lang` is the fast path inside a page route. The root layout and
the root ErrorBoundary sit ABOVE the ":lang" segment, where that param does
not exist, so the pathname is the fallback and both are correct.
========================================================================= */
import { useMemo } from "react";
import { useLocation, useMatches, useParams } from "react-router";
import {
DEFAULT_LOCALE,
dirFor,
getLocaleFromPathname,
isLocale,
type Direction,
type Locale,
} from "./config";
import { alternatePath, localizedPath, sectionPath } from "./paths";
import type { RouteSlugKey } from "./paths";
import {
createTranslator,
getMessages,
type Messages,
type Translator,
} from "./t";
/** The locale of the page being rendered. */
export function useLocale(): Locale {
const params = useParams();
const { pathname } = useLocation();
const lang = params.lang;
return useMemo(() => {
if (isLocale(lang)) return lang;
return getLocaleFromPathname(pathname) ?? DEFAULT_LOCALE;
}, [lang, pathname]);
}
/** "ltr" for /en/*, "rtl" for /ar/*. Mirrors `<html dir>`. */
export function useDirection(): Direction {
return dirFor(useLocale());
}
export interface Translation {
readonly locale: Locale;
readonly dir: Direction;
/** `t("nav.sections.about")`, `t("pagination.page", { page, pageCount })`. */
readonly t: Translator;
/** The raw catalogue, for the rare case a whole namespace is needed. */
readonly messages: Messages;
/** `/{locale}` prefixed URL for an unprefixed path. */
readonly localizedPath: (path: string) => string;
/** The locale-prefixed URL of one of the ten sections. */
readonly sectionPath: (key: RouteSlugKey) => string;
}
/** Everything a component needs to render in the current language. */
export function useTranslation(): Translation {
const locale = useLocale();
return useMemo(
() => ({
locale,
dir: dirFor(locale),
t: createTranslator(locale),
messages: getMessages(locale),
localizedPath: (path: string) => localizedPath(path, locale),
sectionPath: (key: RouteSlugKey) => sectionPath(key, locale),
}),
[locale],
);
}
/**
* The counterpart-language URL for the current location, query string and
* fragment preserved. This is what the header's language switch points at.
*/
export function useAlternatePath(targetLocale: Locale): string {
const { pathname, search, hash } = useLocation();
return useMemo(
() => alternatePath(pathname, targetLocale, { search, hash }),
[pathname, search, hash, targetLocale],
);
}
/**
* Read a value off the deepest route `handle` that declares it. Used by the
* shell to let a page opt into a light navigation or footer surface.
*/
export function useRouteHandleValue<T>(
read: (handle: Record<string, unknown>) => T | undefined,
): T | undefined {
const matches = useMatches();
for (let index = matches.length - 1; index >= 0; index -= 1) {
const handle = matches[index]?.handle;
if (handle && typeof handle === "object") {
const value = read(handle as Record<string, unknown>);
if (value !== undefined) return value;
}
}
return undefined;
}
+78
View File
@@ -0,0 +1,78 @@
/* ============================================================================
I18N - public surface
----------------------------------------------------------------------------
Import everything from "~/i18n". The sub-modules are implementation detail,
except `~/i18n/config` which loaders and `app/routes.ts` may import directly
to stay clear of anything that pulls in React.
========================================================================= */
export {
DEFAULT_LOCALE,
dirFor,
getLocaleFromPathname,
INTL_LOCALE,
isLocale,
LOCALE_ENDONYM,
LOCALE_SHORT_LABEL,
localeFromRequest,
negotiateLocale,
negotiateLocaleFromRequest,
OG_LOCALE,
parseAcceptLanguage,
SUPPORTED_LOCALES,
} from "./config";
export type { Direction, LanguageRange, Locale } from "./config";
export {
createTranslator,
getMessages,
interpolate,
translate,
} from "./t";
export type {
Messages,
MessageKey,
TranslationKey,
TranslationParams,
Translator,
} from "./t";
export {
alternatePath,
buildLocalizedHref,
getCounterpartLocale,
isCurrentPath,
localeAlternates,
localizedPath,
ROUTE_SECTION_GROUPS,
ROUTE_SLUGS,
ROUTE_SLUG_KEYS,
sectionPath,
sectionSlugPath,
stripLocale,
} from "./paths";
export type {
CounterpartPathOptions,
RouteSectionGroupId,
RouteSlugKey,
} from "./paths";
export {
formatDate,
formatDateRange,
formatDateTime,
formatFileSize,
formatNumber,
intlLocale,
toIsoDate,
} from "./format";
export type { DateStyle } from "./format";
export {
useAlternatePath,
useDirection,
useLocale,
useRouteHandleValue,
useTranslation,
} from "./hooks";
export type { Translation } from "./hooks";
+169
View File
@@ -0,0 +1,169 @@
{
"site": {
"name": "SHARP-ER",
"fullName": "العمل المشترك لتعزيز سيادة القانون – مسار الخروج",
"tagline": "نحو تعزيز سيادة القانون، معًا",
"description": "مبادرة وطنية تدعم المجتمع المدني والمؤسسات المحلية والمجتمعات الليبية في تعزيز العدالة والمساءلة والحوكمة الرشيدة. يمتد المشروع من ديسمبر 2025 إلى ديسمبر 2027.",
"metaTitle": "SHARP-ER — العمل المشترك لتعزيز سيادة القانون – مسار الخروج",
"euStatement": "بتمويل من الاتحاد الأوروبي",
"euEmblemAlt": "شعار الاتحاد الأوروبي",
"implementedBy": "الجهات المنفّذة",
"followUs": "تابعوا SHARP-ER"
},
"nav": {
"primaryLabel": "التنقل الرئيسي",
"footerLabel": "أقسام الموقع",
"openMenu": "فتح القائمة",
"closeMenu": "إغلاق القائمة",
"homeLinkLabel": "الصفحة الرئيسية لمشروع SHARP-ER",
"skipToContent": "تخطَّ إلى المحتوى",
"sections": {
"home": "الرئيسية",
"aboutAndStrategy": "عن المشروع والاستراتيجية",
"about": "عن المشروع",
"whatWeDo": "ماذا نفعل",
"activities": "الأنشطة والمبادرات",
"engagementAndOpportunities": "المشاركة والفرص",
"events": "تقويم الفعاليات",
"applications": "طلبات وفرص المشاركة",
"news": "الأخبار والمستجدات",
"contact": "اتصل بنا",
"knowledgeAndImpact": "المعرفة والتأثير",
"resources": "الموارد والمنشورات",
"knowledgeHub": "مركز المعرفة"
}
},
"language": {
"label": "اللغة",
"switchTo": "التبديل إلى {language}",
"en": "English",
"ar": "العربية"
},
"actions": {
"readMore": "اقرأ المزيد",
"learnMore": "اعرف المزيد",
"viewDetails": "عرض التفاصيل",
"viewAll": "عرض الكل",
"download": "تحميل",
"register": "سجّل الآن",
"apply": "قدّم طلبك",
"submit": "إرسال",
"cancel": "إلغاء",
"close": "إغلاق",
"share": "مشاركة",
"print": "طباعة",
"backToHome": "العودة إلى الصفحة الرئيسية",
"backToTop": "العودة إلى الأعلى",
"readRecap": "اقرأ الملخص",
"browseHub": "تصفّح المركز",
"seeActivities": "اطّلع على أنشطتنا",
"learnWhatWeDo": "تعرّف على عملنا",
"seeCurrentCalls": "اطّلع على الدعوات الحالية",
"applyToJoin": "قدّم طلب الانضمام",
"viewUpcomingSessions": "اطّلع على الجلسات القادمة",
"submitYourUpdate": "شاركونا مستجداتكم",
"addToCalendar": "أضف إلى التقويم",
"tryAgain": "حاول مرة أخرى"
},
"filters": {
"heading": "تصفية",
"all": "الكل",
"upcoming": "القادمة",
"ongoing": "جارية",
"completed": "منتهية",
"region": "المنطقة",
"topic": "الموضوع",
"date": "التاريخ",
"author": "الكاتب",
"partner": "المنظمة الشريكة",
"category": "التصنيف",
"year": "السنة",
"clear": "مسح عوامل التصفية",
"apply": "تطبيق عوامل التصفية",
"resultCount": "{count} نتيجة",
"noResults": "لا توجد نتائج.",
"noResultsHint": "جرّب إزالة أحد عوامل التصفية أو البحث عن شيء آخر.",
"sortBy": "الترتيب حسب",
"sortNewest": "الأحدث أولًا",
"sortOldest": "الأقدم أولًا"
},
"search": {
"label": "بحث",
"placeholder": "ابحث في موقع SHARP-ER",
"submit": "بحث",
"clear": "مسح البحث",
"resultsFor": "نتائج البحث عن ”{query}“"
},
"pagination": {
"label": "ترقيم الصفحات",
"previous": "السابق",
"next": "التالي",
"page": "صفحة {page} من {pageCount}",
"goToPage": "الانتقال إلى صفحة {page}",
"showing": "عرض {from}–{to} من {total}"
},
"events": {
"upcoming": "الفعاليات القادمة",
"past": "الفعاليات السابقة",
"dateTime": "التاريخ والوقت",
"location": "الصيغة/الموقع",
"online": "عبر الإنترنت",
"inPerson": "حضوري",
"registrationStatus": "حالة التسجيل",
"statusOpen": "مفتوح",
"statusClosed": "مغلق",
"statusFull": "مكتمل",
"register": "تسجيل",
"joinWaitlist": "الانضمام إلى قائمة الانتظار",
"confirmationTitle": "تم تسجيلك بنجاح",
"confirmationBody": "شكرًا لك — تم تأكيد مكانك في {event} بتاريخ {date}. سنرسل لك تذكيرًا وأي تفاصيل إضافية على البريد الإلكتروني الذي قدّمته.",
"noEvents": "لا توجد فعاليات لعرضها بعد."
},
"news": {
"tabProject": "أخبار SHARP-ER",
"tabNetwork": "من الشبكة",
"publishedOn": "نُشر بتاريخ {date}",
"postedBy": "نشرته {organisation}",
"partnerPrompt": "هل أنتم منظمة شريكة؟"
},
"resources": {
"fileMeta": "{type} · {size}",
"categoryResearch": "دراسات بحثية واستطلاعية",
"categoryPolicy": "مقالات سياساتية",
"categoryReports": "تقارير المشروع",
"categoryVisibility": "مواد الرؤية وأوراق الحقائق"
},
"opportunities": {
"statusOpen": "مفتوحة",
"statusClosed": "مغلقة",
"deadline": "الموعد النهائي: {date}",
"eligibility": "معايير الأهلية"
},
"form": {
"name": "الاسم",
"organisation": "المنظمة",
"email": "البريد الإلكتروني",
"subject": "الموضوع",
"message": "الرسالة",
"phone": "رقم الهاتف",
"optional": "اختياري",
"required": "مطلوب",
"send": "إرسال",
"sending": "جارٍ الإرسال…",
"sent": "تم إرسال الرسالة",
"errorRequired": "هذا الحقل مطلوب.",
"errorEmail": "أدخل بريدًا إلكترونيًا صالحًا.",
"errorConsent": "يرجى تأكيد الموافقة لإتمام التسجيل.",
"errorGeneric": "حدث خطأ ما. يرجى المحاولة مرة أخرى.",
"successTitle": "شكرًا لك",
"successBody": "تم إرسال رسالتك. سنعاود التواصل معك في أقرب وقت ممكن."
},
"errors": {
"statusLabel": "خطأ {status}",
"notFoundTitle": "الصفحة غير موجودة",
"notFoundBody": "الصفحة التي تبحث عنها غير موجودة أو تم نقلها.",
"genericTitle": "حدث خطأ ما",
"genericBody": "حدث خطأ غير متوقع. يرجى المحاولة مرة أخرى بعد قليل.",
"detailsLabel": "تفاصيل تقنية"
}
}
+169
View File
@@ -0,0 +1,169 @@
{
"site": {
"name": "SHARP-ER",
"fullName": "Shared Action on Rule of Law Progress – Exit Route",
"tagline": "Strengthening the Rule of Law, Together",
"description": "A nationwide initiative supporting Libya’s civil society, local institutions and communities in advancing justice, accountability and good governance. Running from December 2025 to December 2027.",
"metaTitle": "SHARP-ER — Shared Action on Rule of Law Progress – Exit Route",
"euStatement": "Funded by the European Union",
"euEmblemAlt": "Emblem of the European Union",
"implementedBy": "Implemented by",
"followUs": "Follow SHARP-ER"
},
"nav": {
"primaryLabel": "Main navigation",
"footerLabel": "Site sections",
"openMenu": "Open menu",
"closeMenu": "Close menu",
"homeLinkLabel": "SHARP-ER home",
"skipToContent": "Skip to content",
"sections": {
"home": "Home",
"aboutAndStrategy": "About and Strategy",
"about": "About",
"whatWeDo": "What We Do",
"activities": "Activities and Initiatives",
"engagementAndOpportunities": "Engagement and Opportunities",
"events": "Events Calendar",
"applications": "Applications and Opportunities",
"news": "News and Updates",
"contact": "Contact",
"knowledgeAndImpact": "Knowledge and Impact",
"resources": "Resources and Publications",
"knowledgeHub": "Knowledge Hub"
}
},
"language": {
"label": "Language",
"switchTo": "Switch to {language}",
"en": "English",
"ar": "العربية"
},
"actions": {
"readMore": "Read more",
"learnMore": "Learn more",
"viewDetails": "View details",
"viewAll": "View all",
"download": "Download",
"register": "Register",
"apply": "Apply",
"submit": "Submit",
"cancel": "Cancel",
"close": "Close",
"share": "Share",
"print": "Print",
"backToHome": "Back to home",
"backToTop": "Back to top",
"readRecap": "Read recap",
"browseHub": "Browse the hub",
"seeActivities": "See our activities",
"learnWhatWeDo": "Learn what we do",
"seeCurrentCalls": "See current calls",
"applyToJoin": "Apply to join",
"viewUpcomingSessions": "View upcoming sessions",
"submitYourUpdate": "Submit your update",
"addToCalendar": "Add to calendar",
"tryAgain": "Try again"
},
"filters": {
"heading": "Filter",
"all": "All",
"upcoming": "Upcoming",
"ongoing": "Ongoing",
"completed": "Completed",
"region": "Region",
"topic": "Topic",
"date": "Date",
"author": "Author",
"partner": "Partner organisation",
"category": "Category",
"year": "Year",
"clear": "Clear filters",
"apply": "Apply filters",
"resultCount": "{count} results",
"noResults": "No results found.",
"noResultsHint": "Try removing a filter or searching for something else.",
"sortBy": "Sort by",
"sortNewest": "Newest first",
"sortOldest": "Oldest first"
},
"search": {
"label": "Search",
"placeholder": "Search SHARP-ER",
"submit": "Search",
"clear": "Clear search",
"resultsFor": "Results for “{query}”"
},
"pagination": {
"label": "Pagination",
"previous": "Previous",
"next": "Next",
"page": "Page {page} of {pageCount}",
"goToPage": "Go to page {page}",
"showing": "Showing {from}–{to} of {total}"
},
"events": {
"upcoming": "Upcoming events",
"past": "Past events",
"dateTime": "Date and time",
"location": "Format and location",
"online": "Online",
"inPerson": "In person",
"registrationStatus": "Registration status",
"statusOpen": "Open",
"statusClosed": "Closed",
"statusFull": "Full",
"register": "Register",
"joinWaitlist": "Join the waitlist",
"confirmationTitle": "You’re registered.",
"confirmationBody": "Thank you — your spot for {event} on {date} is confirmed. We’ll send a reminder and any joining details to the email you provided.",
"noEvents": "There are no events to show yet."
},
"news": {
"tabProject": "SHARP-ER News",
"tabNetwork": "From the Network",
"publishedOn": "Published {date}",
"postedBy": "Posted by {organisation}",
"partnerPrompt": "Are you a partner organisation?"
},
"resources": {
"fileMeta": "{type} · {size}",
"categoryResearch": "Research and Perception Studies",
"categoryPolicy": "Policy Articles",
"categoryReports": "Project Reports",
"categoryVisibility": "Visibility and Fact Sheets"
},
"opportunities": {
"statusOpen": "Open",
"statusClosed": "Closed",
"deadline": "Deadline: {date}",
"eligibility": "Eligibility"
},
"form": {
"name": "Name",
"organisation": "Organisation",
"email": "Email",
"subject": "Subject",
"message": "Message",
"phone": "Phone",
"optional": "optional",
"required": "required",
"send": "Send",
"sending": "Sending…",
"sent": "Message sent",
"errorRequired": "This field is required.",
"errorEmail": "Enter a valid email address.",
"errorConsent": "Please confirm this to complete your registration.",
"errorGeneric": "Something went wrong. Please try again.",
"successTitle": "Thank you",
"successBody": "Your message has been sent. We will get back to you as soon as we can."
},
"errors": {
"statusLabel": "Error {status}",
"notFoundTitle": "Page not found",
"notFoundBody": "The page you are looking for does not exist, or it has moved.",
"genericTitle": "Something went wrong",
"genericBody": "An unexpected error occurred. Please try again in a moment.",
"detailsLabel": "Technical details"
}
}
+80
View File
@@ -0,0 +1,80 @@
/* ============================================================================
LOCALISED PATHS
----------------------------------------------------------------------------
Thin, typed wrappers over the pure helpers the component library already
ships, plus the section-level helpers the route modules need. Pure and
import-light: safe in a loader, in a `meta` function and during SSR.
========================================================================= */
import {
buildLocalizedHref,
getCounterpartLocale,
getLocaleFromPathname,
isCurrentPath,
localizePath,
stripLocale,
type CounterpartPathOptions,
type Locale,
} from "../components/ui/LanguageSwitcher/locale-path";
import {
ROUTE_SECTION_GROUPS,
ROUTE_SLUGS,
ROUTE_SLUG_KEYS,
sectionSlugPath,
type RouteSectionGroupId,
type RouteSlugKey,
} from "../lib/route-slugs";
export {
buildLocalizedHref,
getCounterpartLocale,
getLocaleFromPathname,
isCurrentPath,
ROUTE_SECTION_GROUPS,
ROUTE_SLUGS,
ROUTE_SLUG_KEYS,
sectionSlugPath,
stripLocale,
};
export type { CounterpartPathOptions, RouteSectionGroupId, RouteSlugKey };
/**
* The locale-prefixed URL for a path. Accepts an already-prefixed path, an
* unprefixed one, or a bare "/".
* ("/contact", "ar") -> "/ar/contact" ("/en/contact", "ar") -> "/ar/contact"
*/
export function localizedPath(path: string, locale: Locale): string {
return localizePath(path, locale);
}
/**
* The same page in the other language, query string and fragment preserved.
* This is what the header's language switch links to.
* ("/en/news-and-updates?page=2", "ar") -> "/ar/news-and-updates?page=2"
*/
export function alternatePath(
pathname: string,
targetLocale: Locale,
options: CounterpartPathOptions = {},
): string {
return buildLocalizedHref(pathname, targetLocale, options);
}
/** The locale-prefixed URL of one of the ten sections. */
export function sectionPath(key: RouteSlugKey, locale: Locale): string {
return localizePath(sectionSlugPath(key), locale);
}
/**
* Both language URLs for the page currently at `pathname`, in the order
* `SUPPORTED_LOCALES` declares. Used to emit hreflang alternates.
*/
export function localeAlternates(
pathname: string,
options: CounterpartPathOptions = {},
): { locale: Locale; path: string }[] {
return (["en", "ar"] as const).map((locale) => ({
locale,
path: buildLocalizedHref(pathname, locale, options),
}));
}
+118
View File
@@ -0,0 +1,118 @@
/* ============================================================================
TRANSLATOR
----------------------------------------------------------------------------
~2 KB of dependency-free lookup: a dotted key path into the JSON catalogue
plus {placeholder} interpolation. No i18next, no ICU MessageFormat - the
catalogues hold UI chrome only, page prose comes from Strapi.
English is the reference catalogue: `Messages` is derived from en.json, and
ar.json is typechecked against it, so a key added on one side and forgotten
on the other is a compile error rather than a blank label in production.
========================================================================= */
import en from "./messages/en.json";
import ar from "./messages/ar.json";
import { DEFAULT_LOCALE, type Locale } from "./config";
/** The shape of every catalogue. Derived from the English source of truth. */
export type Messages = typeof en;
/** Anything that may be interpolated into a message. */
export type TranslationParams = Readonly<
Record<string, string | number | undefined>
>;
/**
* Every valid dotted key path in the catalogue, e.g. "nav.sections.about".
* Keys that address a nested object rather than a string are not assignable,
* so `t("nav")` does not typecheck.
*/
export type MessageKey<T> = T extends string
? never
: {
[K in Extract<keyof T, string>]: T[K] extends string
? K
: `${K}.${MessageKey<T[K]>}`;
}[Extract<keyof T, string>];
export type TranslationKey = MessageKey<Messages>;
/** A bound translator. Stable per locale - see `useTranslation()`. */
export type Translator = (
key: TranslationKey,
params?: TranslationParams,
) => string;
/* The `Messages` annotation is the parity check: ar.json must supply every
key en.json declares, with a string in every leaf position. */
const arabicMessages: Messages = ar;
const CATALOGUES: Readonly<Record<Locale, Messages>> = {
en,
ar: arabicMessages,
};
/** The whole catalogue for a locale - handy for spreading into a component. */
export function getMessages(locale: Locale): Messages {
return CATALOGUES[locale] ?? CATALOGUES[DEFAULT_LOCALE];
}
/** Walk a dotted path. Returns undefined for a missing or non-string leaf. */
function lookup(catalogue: Messages, key: string): string | undefined {
let current: unknown = catalogue;
for (const segment of key.split(".")) {
if (typeof current !== "object" || current === null) return undefined;
current = (current as Record<string, unknown>)[segment];
}
return typeof current === "string" ? current : undefined;
}
const PLACEHOLDER = /\{(\w+)\}/g;
/**
* Replace every `{name}` with `params.name`. An unknown or undefined
* placeholder is left in place rather than replaced with "undefined", so a
* missing value is visible in review instead of silently shipping.
*/
export function interpolate(
template: string,
params?: TranslationParams,
): string {
if (!params) return template;
return template.replace(PLACEHOLDER, (match, name: string) => {
const value = params[name];
return value === undefined ? match : String(value);
});
}
/**
* Translate one key. Falls back to the English string when a locale is
* missing it, and finally to the key itself so a page never renders "".
*/
export function translate(
locale: Locale,
key: TranslationKey,
params?: TranslationParams,
): string {
const template =
lookup(getMessages(locale), key) ??
lookup(CATALOGUES[DEFAULT_LOCALE], key);
if (template === undefined) {
if (import.meta.env?.DEV) {
console.warn(`[i18n] missing translation for "${key}" (${locale})`);
}
return key;
}
return interpolate(template, params);
}
/** A translator bound to one locale. Cheap - safe to call in a `useMemo`. */
export function createTranslator(locale: Locale): Translator {
return (key, params) => translate(locale, key, params);
}