diff options
| author | Leitwolf11 | 2026-10-03 13:00:20 +0300 |
|---|---|---|
| committer | Leitwolf11 | 2026-10-03 13:00:20 +0300 |
| commit | 5a321c2eb1c691bdb7cc96bbab1e28b88ef92aab (patch) | |
| tree | f22d4ab61815b98a0a9cbe7ce83461b7b0ea9b8a /apps | |
| parent | 8ba8d26ec4e6e900275fd5cd94e37a7aac701a1f (diff) | |
| download | progmaweb-5a321c2eb1c691bdb7cc96bbab1e28b88ef92aab.tar.gz | |
Add Hebrew and follow the browser language by default
Hebrew (he-IL) joins English, German and Russian with a complete message
tree and its own entry in the language menu.
A visitor who has not chosen a language now gets the language their browser
prefers. The Accept-Language header is negotiated by quality, only the
primary language is compared, and `iw` is accepted as the former code for
Hebrew. A browser that prefers no published language gets en-US, as before.
A language chosen in the menu is still stored in the cookie and always wins.
Hebrew does not mirror the page. The layout, the navigation and code keep
their left-to-right arrangement; only text follows the language. Blocks of
prose run right to left, short pieces such as links and buttons take the
direction of their own first letter, and inputs and code stay left to right.
The onward arrow points left in Hebrew, and a left-to-right mark keeps
"Visual X#" intact inside right-to-left text.
The Hebrew text was not reviewed by a native speaker.
Verified locally: node tests, type check, lint, prettier and a production
build; language selection per Accept-Language header against the dev server;
the Hebrew home and registration pages in a browser.
Diffstat (limited to 'apps')
| -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 | 372 | ||||
| -rw-r--r-- | apps/web/tests/localization.test.mjs | 61 |
8 files changed, 486 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..616349c 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,306 @@ 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"); +}); |