initial commit
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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]}`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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": "تفاصيل تقنية"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user