Read-only mirrors. Development, issues and pull requests are on GitHub.

summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorLeitwolf112026-10-03 15:42:08 +0300
committerLeitwolf112026-10-03 15:42:08 +0300
commit549b164464855ae3605c03b58497f91d26555418 (patch)
tree829df551495a11df26b1ebd4cd877db3297deaae
parent715953aeaa98ba977e694e8897ccd40151ee0ac0 (diff)
parent282b015e23474c3bb78fe303af627248f5bd2d7a (diff)
downloadprogmaweb-ci/docfx.tar.gz
Merge remote-tracking branch 'origin/main' into ci/docfxci/docfx
-rw-r--r--apps/web/app/globals.css22
-rw-r--r--apps/web/app/layout.tsx8
-rw-r--r--apps/web/app/page.tsx6
-rw-r--r--apps/web/app/viget/dslplugins/page.tsx4
-rw-r--r--apps/web/app/viget/page.tsx6
-rw-r--r--apps/web/lib/locale.server.ts20
-rw-r--r--apps/web/lib/localization.ts376
-rw-r--r--apps/web/tests/localization.test.mjs61
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/&lt;Publisher&gt;/&lt;Name&gt;/
</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");
+});