diff options
| author | Leitwolf11 | 2026-10-03 15:42:08 +0300 |
|---|---|---|
| committer | Leitwolf11 | 2026-10-03 15:42:08 +0300 |
| commit | 549b164464855ae3605c03b58497f91d26555418 (patch) | |
| tree | 829df551495a11df26b1ebd4cd877db3297deaae | |
| parent | 715953aeaa98ba977e694e8897ccd40151ee0ac0 (diff) | |
| parent | 282b015e23474c3bb78fe303af627248f5bd2d7a (diff) | |
| download | progmaweb-ci/docfx.tar.gz | |
Merge remote-tracking branch 'origin/main' into ci/docfxci/docfx
| -rw-r--r-- | apps/web/app/globals.css | 22 | ||||
| -rw-r--r-- | apps/web/app/layout.tsx | 8 | ||||
| -rw-r--r-- | apps/web/app/page.tsx | 6 | ||||
| -rw-r--r-- | apps/web/app/viget/dslplugins/page.tsx | 4 | ||||
| -rw-r--r-- | apps/web/app/viget/page.tsx | 6 | ||||
| -rw-r--r-- | apps/web/lib/locale.server.ts | 20 | ||||
| -rw-r--r-- | apps/web/lib/localization.ts | 376 | ||||
| -rw-r--r-- | apps/web/tests/localization.test.mjs | 61 |
8 files changed, 490 insertions, 13 deletions
diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 2022863..3a27983 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -1393,3 +1393,25 @@ a { transition-duration: 0.01ms !important; } } + +/* + * Right-to-left text. Only the text follows the direction of the language: + * the page layout, the navigation and code keep their left-to-right + * arrangement. Blocks of prose run right to left, and short pieces such as + * links, buttons and list items take the direction of their own first + * letter, so a product name on its own still reads left to right. + */ +[data-text-direction="rtl"] + :is(h1, h2, h3, h4, h5, h6, p, blockquote, figcaption, dt, dd, legend) { + direction: rtl; +} + +[data-text-direction="rtl"] + :is(a, button, label, li, small, strong, span, option, summary) { + unicode-bidi: plaintext; +} + +[data-text-direction="rtl"] :is(code, pre, kbd, samp, input) { + direction: ltr; + unicode-bidi: isolate; +} diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 4eae3fd..de04763 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -4,6 +4,7 @@ import type { Metadata } from "next"; import type { ReactNode } from "react"; import { getLocale } from "@/lib/locale.server"; +import { textDirection } from "@/lib/localization"; import "./globals.css"; import "./viget.css"; @@ -42,7 +43,12 @@ export default async function RootLayout({ }: Readonly<{ children: ReactNode }>) { const locale = await getLocale(); return ( - <html lang={locale} data-theme="light" suppressHydrationWarning> + <html + lang={locale} + data-text-direction={textDirection(locale)} + data-theme="light" + suppressHydrationWarning + > <head> <script dangerouslySetInnerHTML={{ __html: themeBootstrap }} /> </head> diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index 345c835..2aabe5a 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -6,7 +6,7 @@ import { Fragment } from "react"; import { Footer } from "@/components/Footer"; import { SiteHeader } from "@/components/SiteHeader"; import { getLocale } from "@/lib/locale.server"; -import { getMessages } from "@/lib/localization"; +import { forwardArrow, getMessages } from "@/lib/localization"; export async function generateMetadata(): Promise<Metadata> { const { metadata } = getMessages(await getLocale()); @@ -118,7 +118,9 @@ export default async function Homepage() { <p className="eyebrow">{eyebrow}</p> <h3>{name}</h3> <p>{description}</p> - <a href={productLinks[index]?.href}>{action} →</a> + <a href={productLinks[index]?.href}> + {action} {forwardArrow(locale)} + </a> </article> ), )} diff --git a/apps/web/app/viget/dslplugins/page.tsx b/apps/web/app/viget/dslplugins/page.tsx index 8a4be8c..c17c0cf 100644 --- a/apps/web/app/viget/dslplugins/page.tsx +++ b/apps/web/app/viget/dslplugins/page.tsx @@ -5,7 +5,7 @@ import type { Metadata } from "next"; import { ViGetFooter } from "@/components/ViGetFooter"; import { ViGetHeader } from "@/components/ViGetHeader"; import { getLocale } from "@/lib/locale.server"; -import { getMessages } from "@/lib/localization"; +import { forwardArrow, getMessages } from "@/lib/localization"; export async function generateMetadata(): Promise<Metadata> { const { metadata } = getMessages(await getLocale()); @@ -43,7 +43,7 @@ export default async function DslPluginsPage() { className="viget-text-link" href="https://github.com/Progmasoft/visual-xsharp" > - {viget.followDevelopment} → + {viget.followDevelopment} {forwardArrow(locale)} </a> </div> </div> diff --git a/apps/web/app/viget/page.tsx b/apps/web/app/viget/page.tsx index 2bf9161..b1c697a 100644 --- a/apps/web/app/viget/page.tsx +++ b/apps/web/app/viget/page.tsx @@ -5,7 +5,7 @@ import type { Metadata } from "next"; import { ViGetFooter } from "@/components/ViGetFooter"; import { ViGetHeader } from "@/components/ViGetHeader"; import { getLocale } from "@/lib/locale.server"; -import { getMessages } from "@/lib/localization"; +import { forwardArrow, getMessages } from "@/lib/localization"; export async function generateMetadata(): Promise<Metadata> { const { metadata } = getMessages(await getLocale()); @@ -103,7 +103,9 @@ export default async function ViGetPage() { viget.progmasoft.com/dslplugins/<Publisher>/<Name>/ </code> <small>{viget.kotlinDescription}</small> - <strong>{viget.openCatalog} →</strong> + <strong> + {viget.openCatalog} {forwardArrow(locale)} + </strong> </a> </div> </div> diff --git a/apps/web/lib/locale.server.ts b/apps/web/lib/locale.server.ts index 590bf2d..977e16b 100644 --- a/apps/web/lib/locale.server.ts +++ b/apps/web/lib/locale.server.ts @@ -2,12 +2,26 @@ // SPDX-License-Identifier: AGPL-3.0-or-later WITH AdditionRef-Progmasoft-Patent-Grant-1.1 import "server-only"; -import { cookies } from "next/headers"; -import { isLocale, localeCookieName, type Locale } from "./localization"; +import { cookies, headers } from "next/headers"; +import { + isLocale, + localeCookieName, + negotiateLocale, + type Locale, +} from "./localization"; export { localeCookieName }; +/** + * The language of the current request. + * + * A language the visitor chose is stored in a cookie and always wins. Without + * that choice the page follows the language preference of the browser. + */ export async function getLocale(): Promise<Locale> { const value = (await cookies()).get(localeCookieName)?.value; - return isLocale(value) ? value : "en-US"; + if (isLocale(value)) { + return value; + } + return negotiateLocale((await headers()).get("accept-language")); } diff --git a/apps/web/lib/localization.ts b/apps/web/lib/localization.ts index 7410520..3ef8460 100644 --- a/apps/web/lib/localization.ts +++ b/apps/web/lib/localization.ts @@ -1,9 +1,12 @@ // SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com> // SPDX-License-Identifier: AGPL-3.0-or-later WITH AdditionRef-Progmasoft-Patent-Grant-1.1 -export const supportedLocales = ["en-US", "de-DE", "ru-RU"] as const; +export const supportedLocales = ["en-US", "de-DE", "ru-RU", "he-IL"] as const; export type Locale = (typeof supportedLocales)[number]; +/** The language of a visitor whose browser asks for none that is published. */ +export const fallbackLocale: Locale = "en-US"; + export function isLocale(value: string | undefined): value is Locale { return supportedLocales.includes(value as Locale); } @@ -13,8 +16,75 @@ export const localeNames: Record<Locale, string> = { "en-US": "English", "de-DE": "Deutsch", "ru-RU": "Русский", + "he-IL": "עברית", }; +/** + * The direction in which the text of a language runs. + * + * Only text follows it. The page layout, the navigation and code samples keep + * their left-to-right arrangement in every language. + */ +export function textDirection(locale: Locale): "ltr" | "rtl" { + return locale === "he-IL" ? "rtl" : "ltr"; +} + +/** The arrow that points onward in the reading direction of a language. */ +export function forwardArrow(locale: Locale): string { + return textDirection(locale) === "rtl" ? "←" : "→"; +} + +// Primary language subtags and the published locale each one selects. `iw` is +// the former code for Hebrew, which some browsers still send. +const localeByLanguage = new Map<string, Locale>([ + ["en", "en-US"], + ["de", "de-DE"], + ["ru", "ru-RU"], + ["he", "he-IL"], + ["iw", "he-IL"], +]); + +/** + * Chooses the published locale a browser prefers. + * + * The argument is an `Accept-Language` header: a list of language ranges, + * each with an optional quality. The range with the highest quality whose + * primary language is published wins, and the order of the header breaks + * ties. A missing header, a header without a published language and a range + * the visitor refuses with quality zero all give the fallback locale. The + * header comes from the browser and is not trusted, so only its beginning is + * read. + */ +export function negotiateLocale(acceptLanguage: string | null): Locale { + if (!acceptLanguage) { + return fallbackLocale; + } + let selected: Locale = fallbackLocale; + let selectedQuality = 0; + for (const range of acceptLanguage.slice(0, 1024).split(",")) { + const [tag = "", ...parameters] = range.trim().split(";"); + const language = tag.trim().split("-", 1)[0] ?? ""; + const locale = localeByLanguage.get(language.toLowerCase()); + if (!locale) { + continue; + } + let quality = 1; + for (const parameter of parameters) { + const [name, value] = parameter.trim().split("="); + if (name === "q") { + quality = /^(?:0(?:\.\d{0,3})?|1(?:\.0{0,3})?)$/.test(value ?? "") + ? Number(value) + : 0; + } + } + if (quality > selectedQuality) { + selected = locale; + selectedQuality = quality; + } + } + return selected; +} + export const localeCookieName = "progmasoft_locale"; export interface LocaleCookieOptions { @@ -1055,6 +1125,310 @@ export const messages = { footerClosing: "Реестр пакетов ViGet от Progmasoft", }, }, + "he-IL": { + metadata: { + homeTitle: "Progmasoft", + homeDescription: + "Progmasoft בונה מערכות לשפות תכנות, לניהול חבילות ולכלי פיתוח.", + accountTitle: "חשבון", + accountDescription: "גישה לחשבון Progmasoft שלך וניהולו.", + loginTitle: "כניסה", + registerTitle: "יצירת חשבון", + recoveryTitle: "שחזור החשבון", + recoveryDescription: "קבלת עזרה בשחזור הגישה לחשבון Progmasoft.", + dashboardTitle: "לוח בקרה", + vigetTitle: "מאגר החבילות ViGet מבית Progmasoft", + vigetDescription: + "מאגר החבילות של Visual X# פעיל, אך עדיין לא פורסמו בו חבילות.", + dslPluginsTitle: "תוספי DSL · מאגר החבילות ViGet מבית Progmasoft", + dslPluginsDescription: + "קטלוג תוספי Kotlin DSL של ViGet פעיל, אך עדיין לא פורסמו בו תוספים.", + }, + navigation: { + primary: "ניווט ראשי", + organization: "הארגון", + products: "מוצרים", + principles: "עקרונות", + account: "חשבון", + dashboard: "לוח בקרה", + signIn: "כניסה", + createAccount: "יצירת חשבון", + theme: "ערכת נושא", + light: "בהיר", + dark: "כהה", + language: "שפה", + }, + footer: { + summary: "תשתית לשפות תכנות ומערכות למפתחים, הבנויות על חוזים יציבים.", + products: "מוצרים", + openSource: "קוד פתוח", + organization: "הארגון", + support: "תמיכה", + websiteSource: "קוד המקור של האתר", + closing: "מתוכנן לבהירות, לאבטחה ולתחזוקה ארוכת טווח.", + }, + home: { + heroEyebrow: "מערכות למפתחים מבית Progmasoft", + heroTitleStart: "כלים צריכים להפוך עבודה קשה", + heroTitleAccent: "למובנת.", + heroDescription: + "אנחנו בונים תשתית לשפות תכנות, מערכות חבילות וכלי פיתוח סביב חוזים מפורשים במקום מורכבות מקרית.", + exploreProducts: "למוצרים", + browseSource: "לעיון בקוד המקור", + facts: [ + ["פתוח", "הנדסה גלויה לציבור"], + ["מבוסס טיפוסים", "חוזים לפני קיצורי דרך"], + ["קוד מכונה", "ביצועים בלי מסתורין"], + ], + visualLabel: "עקרונות המוצר של Progmasoft", + stack: [ + ["מוצרים", "חוויות ממוקדות", "מטרה ברורה · שמות יציבים · זהות ציבורית"], + ["פלטפורמה", "בסיס חשבון משותף", "אימות · גישה לשירותים · שחזור"], + [ + "תפעול", + "תשתית בבעלותנו", + "ניתנת לניטור · ניתנת לתחזוקה · בהפעלה ישירה", + ], + ], + productsEyebrow: "מוצרים ופרויקטים", + productsTitle: "אקוסיסטם אחד, גבולות ברורים.", + productsDescription: "לכל רכיב יש אחריות אחת וחוזה מתועד מול השכבה הבאה.", + products: [ + [ + "שפת תכנות", + "Visual X#", + "שפת תכנות מודרנית שמפתחת Progmasoft, עם אתר מוצר ותיעוד ייעודי משלה.", + "להכיר את Visual X#", + ], + [ + "מאגר חבילות", + "ViGet", + "המאגר הרשמי של חבילות ותוספי DSL לאקוסיסטם של Visual X#, בהפעלה ישירה של Progmasoft.", + "לפתיחת ViGet", + ], + [ + "כלי פיתוח", + "הנדסה פתוחה", + "מהדר, מעצב קוד, linter, מנתח, מערכת פרויקטים ועורך, המפותחים במאגרים ציבוריים.", + "לצפייה ב-GitHub", + ], + ], + principlesEyebrow: "עקרונות הנדסיים", + principlesTitle: "בנוי כדי להישאר קריא.", + principlesDescription: + "ארכיטקטורה מועילה רק כאשר תורם חדש יכול להבין לאן שייכת החלטה וכיצד לאמת אותה.", + principles: [ + [ + "חוזים מפורשים", + "גבולות מבוססי טיפוסים חושפים בעלות, תאימות והתנהגות בעת כשל.", + ], + [ + "אימות אמיתי", + "הבדיקות מפעילות תוצרים מותקנים, נתיבים דמויי סביבת ייצור והתנהגות נצפית.", + ], + [ + "שמות יציבים", + "אוצר המילים הציבורי נגזר ממודל המוצר ולא משאריות היסטוריות של המימוש.", + ], + ], + accountEyebrow: "חשבון Progmasoft", + accountTitle: "זהות אחת לשירותי Progmasoft.", + accountDescription: + "ניהול הפרופיל והגישה העתידית לשירותים מאזור החשבון הייעודי.", + openAccount: "לחשבון", + }, + account: { + securityLabel: "מאפייני האבטחה של החשבון", + trust: [ + "עוגיית הפעלה מאובטחת", + "גיבוב סיסמאות בצד השרת", + "אימות עם הגבלת קצב", + ], + landingEyebrow: "חשבון Progmasoft", + landingTitle: "זהות ברורה אחת לכל שירות נתמך.", + landingDescription: + "החשבון שומר את הפרופיל, האימות והגישה לשירותים בתוך גבול אבטחה ייעודי.", + identity: [ + ["שם החשבון", "הזהות הציבורית היציבה שלך ושם המפרסם שלך ב-ViGet"], + ["דוא״ל", "שחזור והודעות אבטחה"], + ["הפעלה", "גישה מאובטחת מהדפדפן שניתן לבטל"], + ], + features: [ + [ + "אבטחה מכוונת", + "הסיסמאות מגובבות בצד השרת, ואסימוני ההפעלה נשמרים כתקצירים בלבד.", + ], + [ + "שמות צפויים", + "שמות חשבון קנוניים מונעים כתובות דו־משמעיות והתחזות שמבוססת על הבדלי אותיות גדולות וקטנות בלבד. ViGet משתמש בשם הזה בדיוק כשם מפרסם החבילה ואינו יוצר זהות שנייה.", + ], + [ + "גבולות בין שירותים", + "המוצרים מבקשים גישה מפורשת לחשבון במקום לשתף מצב יישום נסתר.", + ], + ], + loginEyebrow: "חשבון Progmasoft", + loginTitle: "כניסה מאובטחת.", + loginDescription: "המשך ללוח הבקרה של החשבון ולשירותים המקושרים.", + registerEyebrow: "יצירת זהות", + registerTitle: "מתחילים עם חשבון יציב.", + registerDescription: "בחירת השם שישמש בכתובת חשבון Progmasoft שלך.", + recoveryEyebrow: "תמיכה בחשבון", + recoveryTitle: "שחזור החשבון.", + recoveryDescription: + "כשאי אפשר עוד להיכנס, יש לפנות בערוץ התמיכה המאומת.", + recoveryPanelEyebrow: "שחזור חשבון", + recoveryPanelTitle: "שחזור גישה בבטחה", + recoveryPanelDescription: + "שחזור סיסמה אוטומטי אינו זמין בשלב ההשקה הראשוני של מערכת החשבונות. יש לפנות לתמיכה של Progmasoft מכתובת הדוא״ל הרשומה בחשבון, כדי שניתן יהיה לאמת את הבעלות.", + recoveryEmailSubject: "שחזור חשבון Progmasoft", + contactSupport: "פנייה לתמיכה", + rememberedPassword: "נזכרת בסיסמה?", + returnToSignIn: "חזרה לכניסה", + }, + auth: { + createHeading: "יצירת החשבון שלך", + loginHeading: "ברוכים השבים", + createDescription: "זהות Progmasoft אחת לכל השירותים הנתמכים.", + loginDescription: "כניסה עם כתובת הדוא״ל המשויכת לחשבון Progmasoft שלך.", + accountName: "שם החשבון", + accountHint: + "8 עד 128 אותיות ASCII או ספרות, כשהתו הראשון הוא אות גדולה.", + email: "כתובת דוא״ל", + password: "סיסמה", + forgotPassword: "שכחת את הסיסמה?", + passwordHint: "לפחות 12 תווים. יש תמיכה במשפטי סיסמה ארוכים.", + confirmPassword: "אימות הסיסמה", + show: "הצגה", + hide: "הסתרה", + working: "בתהליך…", + create: "יצירת חשבון", + signIn: "כניסה", + or: "או", + google: "המשך עם Google", + already: "כבר יש לך חשבון?", + newUser: "פעם ראשונה ב-Progmasoft?", + createLink: "יצירת חשבון", + mismatch: "אימות הסיסמה אינו תואם.", + unreachable: "שירות החשבונות אינו זמין זמנית. יש לנסות שוב בקרוב.", + missingName: "השרת לא החזיר שם חשבון.", + unreadableName: "לא ניתן היה לקרוא את השדה ״שם החשבון״.", + requestFailed: "לא ניתן היה להשלים את הבקשה.", + googleSignInFailed: + "לא ניתן היה להשלים את הכניסה עם Google. יש לנסות שוב.", + googleAccountNameRequired: "יש לבחור שם חשבון לפני ההמשך עם Google.", + googleAccountNameUnavailable: "שם החשבון הזה אינו זמין. יש לבחור שם אחר.", + googleInvalidAccountName: "יש להזין שם חשבון תקין לפני ההמשך עם Google.", + }, + dashboard: { + loading: "החשבון נטען…", + loadFailed: "לא ניתן היה לטעון את לוח הבקרה.", + unreachable: "שירות החשבונות אינו זמין זמנית.", + navigation: "ניווט בלוח הבקרה", + overview: "סקירה", + security: "אבטחה", + services: "שירותים", + signOut: "יציאה", + eyebrow: "סקירת החשבון", + welcome: "ברוכים הבאים", + description: "ניהול הזהות שמשמשת את שירותי Progmasoft.", + profile: "פרופיל", + identity: "זהות החשבון", + accountName: "שם החשבון", + publisherName: "שם המפרסם ב-ViGet", + email: "דוא״ל", + created: "נוצר", + passwordSessions: "סיסמה והפעלות", + securityDescription: + "הדפדפן שלך משתמש בעוגיית הפעלה מאובטחת מסוג HttpOnly. סודות ההפעלה לעולם אינם נשמרים כטקסט גלוי.", + changePassword: "שינוי סיסמה", + products: "מוצרי Progmasoft מקושרים", + publisherPrefix: "שם המפרסם", + sameAccount: "שם החשבון שלך", + profileDescription: "פרופיל באקוסיסטם המפתחים", + planned: "מתוכנן", + }, + viget: { + navigationLabel: "ניווט במאגר", + packages: "חבילות", + dslPlugins: "תוספי DSL", + account: "חשבון", + login: "כניסה", + register: "הרשמה", + homeEyebrow: "מאגר החבילות ViGet", + homeTitle: "חבילות לאקוסיסטם של Visual X#.", + homeDescription: + "ViGet הוא המקור הרשמי לחבילות Visual X# ולתוספי DSL לפרויקטים, בהפעלה ישירה של Progmasoft.", + createAccount: "יצירת חשבון", + exploreVisualXSharp: "להכיר את Visual X#", + available: "המאגר זמין", + emptyTitle: "הקטלוג הציבורי ריק.", + emptyDescription: + "עדיין לא פורסמו גרסאות של חבילות. ViGet יציג כאן חבילות אמיתיות כשיהיו זמינות.", + packageFormat: "תבנית החבילה", + publisherIdentity: "זהות המפרסם", + publishing: "פרסום", + publishingClosed: "עדיין לא נפתח", + catalogsEyebrow: "קטלוגים", + catalogsTitle: "שני סוגי תוצרים, מאגר אחד.", + catalogsDescription: + "לחבילות ולתוספי DSL לפרויקטים יש מרחבי קואורדינטות נפרדים וצפויים.", + visualPackages: "חבילות Visual X#", + vipkgCatalog: "קטלוג ViPkg", + vipkgDescription: + "ספריות ויישומים שנכתבו ב-Visual X# ומופצים כתוצרי .vipkg.", + projectExtensions: "הרחבות לפרויקטים", + kotlinPlugins: "תוספי Kotlin DSL", + kotlinDescription: + "תוספי Kotlin בתבנית JAR שמרחיבים את תצורת הפרויקט Visual.XSharp.kts.", + openCatalog: "לפתיחת הקטלוג", + contractEyebrow: "חוזה המאגר", + contractTitle: "בעלות ברורה מהזהות ועד לתוצר.", + principles: [ + [ + "שם חשבון אחד", + "שם חשבון Progmasoft שלך הוא גם שם המפרסם שלך ב-ViGet.", + ], + [ + "קואורדינטות תלויות רישיות", + "שמות המפרסם והחבילה שומרים על האיות הציבורי המדויק שלהם.", + ], + ["בלי גרסאות דמה", "הקטלוג נשאר ריק ביושר עד שמתפרסם תוצר חתום אמיתי."], + ], + pluginEyebrow: "ViGet · תוספי Kotlin DSL", + pluginTitle: "להרחיב את מודל הפרויקט.", + pluginDescription: + "תוספי Kotlin DSL הם תוצרי JAR עבור Visual.XSharp.kts. הם נפרדים מחבילות .vipkg של Visual X#.", + backToPackages: "חזרה לחבילות", + followDevelopment: "למעקב אחר הפיתוח", + pluginAvailable: "הקטלוג זמין", + pluginEmptyTitle: "עדיין אין תוספי DSL ציבוריים.", + pluginEmptyDescription: "הקטלוג מוכן, ובכוונה אינו מכיל תוצרי דמה.", + guidance: [ + [ + "תבנית", + "Kotlin JAR", + "תוספי DSL פועלים כהרחבות פרויקט של Kotlin/JVM.", + ], + [ + "מיקום", + "קטלוג ייעודי", + "קואורדינטות של תוסף מתחילות תמיד ב-/dslplugins.", + ], + [ + "זמינות", + "הפרסום סגור", + "הפרסום ייפתח רק לאחר השלמת חוזה התוספים החתומים.", + ], + ], + footerDescription: + "ViGet הוא מאגר החבילות ותוספי Kotlin DSL של האקוסיסטם של Visual X#.", + registry: "מאגר", + support: "תמיכה", + source: "קוד מקור", + footerClosing: "מאגר החבילות ViGet מבית Progmasoft", + }, + }, } as const; export function getMessages(locale: Locale) { diff --git a/apps/web/tests/localization.test.mjs b/apps/web/tests/localization.test.mjs index 47d7549..9e4a361 100644 --- a/apps/web/tests/localization.test.mjs +++ b/apps/web/tests/localization.test.mjs @@ -15,11 +15,21 @@ import { } from "../lib/localization.ts"; test("only the published locales are accepted", () => { - assert.deepEqual(supportedLocales, ["en-US", "de-DE", "ru-RU"]); + assert.deepEqual(supportedLocales, ["en-US", "de-DE", "ru-RU", "he-IL"]); assert.equal(isLocale("en-US"), true); assert.equal(isLocale("de-DE"), true); assert.equal(isLocale("ru-RU"), true); - for (const value of ["en", "de", "ru", "en-us", "tr-TR", "", undefined]) { + for (const value of [ + "en", + "de", + "ru", + "he", + "iw", + "en-us", + "tr-TR", + "", + undefined, + ]) { assert.equal(isLocale(value), false, `unexpected locale: ${value}`); } }); @@ -162,3 +172,50 @@ test("the Russian messages are written in Cyrillic", () => { } assert.notEqual(russian.auth.signIn, messages["en-US"].auth.signIn); }); + +test("Hebrew is published and is the only right-to-left language", async () => { + const { forwardArrow, textDirection } = + await import("../lib/localization.ts"); + assert.equal(isLocale("he-IL"), true); + assert.equal(localeNames["he-IL"], "עברית"); + for (const locale of supportedLocales) { + const expected = locale === "he-IL" ? "rtl" : "ltr"; + assert.equal(textDirection(locale), expected, locale); + assert.equal(forwardArrow(locale), expected === "rtl" ? "←" : "→", locale); + } + assert.match(messages["he-IL"].auth.signIn, /[-]/); +}); + +test("the browser preference selects the language when nothing was chosen", async () => { + const { fallbackLocale, negotiateLocale } = + await import("../lib/localization.ts"); + assert.equal(fallbackLocale, "en-US"); + const cases = [ + [null, "en-US"], + ["", "en-US"], + ["de", "de-DE"], + ["de-AT,de;q=0.9,en;q=0.8", "de-DE"], + ["ru-RU,ru;q=0.9", "ru-RU"], + ["he-IL,he;q=0.9,en-US;q=0.8", "he-IL"], + ["iw", "he-IL"], + ["HE", "he-IL"], + // Quality decides, not position. + ["en;q=0.3,ru;q=0.9", "ru-RU"], + // The first of equally preferred languages wins. + ["de,ru", "de-DE"], + // An unpublished first choice falls through to a published one. + ["tr-TR,tr;q=0.9,de;q=0.5", "de-DE"], + ["tr-TR,fr;q=0.9", "en-US"], + ["*", "en-US"], + // A refused or malformed quality never selects a language. + ["de;q=0", "en-US"], + ["de;q=abc,ru;q=0.1", "ru-RU"], + ["de;q=5", "en-US"], + ["constructor,__proto__", "en-US"], + ]; + for (const [header, expected] of cases) { + assert.equal(negotiateLocale(header), expected, String(header)); + } + // Only the beginning of an oversized header is read. + assert.equal(negotiateLocale(`${"x,".repeat(2000)}de`), "en-US"); +}); |