From 7dcc509a54fd1657610467dd2a93e7d85b516b03 Mon Sep 17 00:00:00 2001 From: neon <18559658+neonsolstice@users.noreply.github.com> Date: Tue, 11 Aug 2026 07:25:09 +0000 Subject: [PATCH] feat(client): add nine interface languages and a searchable language picker Traditional Chinese cannot reuse the `%two_letters_code%` filename because it would collide with Simplified under `zh.json`, so `crowdin.yml` maps `zh-TW` to a `zh-Hant` catalog and locale matching resolves by script subtag before falling back to the base language. Every other new locale keeps its bare two-letter ID. Twenty-five entries outgrew the old menus. The mobile submenu inherited `overflow-hidden` with no max-height, so the last nine languages were unreachable on a phone, and the list was ordered by ISO code while displaying endonyms, which reads as unsorted. Both header menus now render one LanguagePicker that searches the native name, the English name and the locale code, groups the browser's own languages as suggestions, and orders the rest with a collator on the string it actually shows. Mobile opens a sheet instead of a nested flyout, since a flyout is the wrong shape for touch regardless of height. Settings gains the Appearance language section its copy has anticipated since the keys were added. Tighten the Crowdin progress chart, which the longer language list had stretched to 810px tall. --- .github/workflows/crowdin-translations.yml | 9 ++ client/scripts/locale-configuration.mjs | 1 + client/src/components/AppHeader.vue | 63 ++++---- client/src/components/LanguagePicker.vue | 142 ++++++++++++++++++ .../components/__tests__/AppHeader.spec.ts | 1 + .../__tests__/LanguagePicker.spec.ts | 118 +++++++++++++++ .../dropdown-menu/DropdownMenuSubContent.vue | 2 +- .../settings/AppearanceLanguageSettings.vue | 61 ++++++++ .../features/settings/AppearanceSettings.vue | 2 + .../lib/__tests__/appearance-tabs.spec.ts | 2 +- .../features/settings/lib/appearance-tabs.ts | 2 +- client/src/i18n/icu.spec.ts | 18 +++ client/src/locales/el.json | 1 + client/src/locales/en.json | 15 +- client/src/locales/hu.json | 1 + client/src/locales/id.json | 1 + client/src/locales/ja.json | 1 + client/src/locales/ko.json | 1 + client/src/locales/ro.json | 1 + client/src/locales/sk.json | 1 + client/src/locales/tr.json | 1 + client/src/locales/zh-Hant.json | 1 + client/src/stores/__tests__/locale.spec.ts | 20 ++- client/src/stores/locale.ts | 15 +- crowdin.yml | 12 ++ docs/LOCALIZATION.md | 49 +++--- packages/types/src/locale.ts | 46 +++++- scripts/classify-crowdin-pr.sh | 9 ++ scripts/crowdin-stats/generate.mjs | 34 +++-- scripts/crowdin-stats/generate.test.mjs | 27 ++-- .../user-preferences.service.test.ts | 4 +- 31 files changed, 573 insertions(+), 88 deletions(-) create mode 100644 client/src/components/LanguagePicker.vue create mode 100644 client/src/components/__tests__/LanguagePicker.spec.ts create mode 100644 client/src/features/settings/AppearanceLanguageSettings.vue create mode 100644 client/src/locales/el.json create mode 100644 client/src/locales/hu.json create mode 100644 client/src/locales/id.json create mode 100644 client/src/locales/ja.json create mode 100644 client/src/locales/ko.json create mode 100644 client/src/locales/ro.json create mode 100644 client/src/locales/sk.json create mode 100644 client/src/locales/tr.json create mode 100644 client/src/locales/zh-Hant.json diff --git a/.github/workflows/crowdin-translations.yml b/.github/workflows/crowdin-translations.yml index c482ed873..c69eab09f 100644 --- a/.github/workflows/crowdin-translations.yml +++ b/.github/workflows/crowdin-translations.yml @@ -80,18 +80,27 @@ jobs: client/src/locales/cs.json client/src/locales/da.json client/src/locales/de.json + client/src/locales/el.json client/src/locales/es.json client/src/locales/fi.json client/src/locales/fr.json + client/src/locales/hu.json + client/src/locales/id.json client/src/locales/it.json + client/src/locales/ja.json + client/src/locales/ko.json client/src/locales/nl.json client/src/locales/pl.json client/src/locales/pt.json + client/src/locales/ro.json client/src/locales/ru.json + client/src/locales/sk.json client/src/locales/sl.json client/src/locales/sv.json + client/src/locales/tr.json client/src/locales/uk.json client/src/locales/zh.json + client/src/locales/zh-Hant.json commit-message: |- i18n(client): sync translations from Crowdin diff --git a/client/scripts/locale-configuration.mjs b/client/scripts/locale-configuration.mjs index fe4b03deb..9cfeef48b 100644 --- a/client/scripts/locale-configuration.mjs +++ b/client/scripts/locale-configuration.mjs @@ -19,6 +19,7 @@ const CROWDIN_LANGUAGE_ID_OVERRIDES = new Map([ ['pt', 'pt-BR'], ['sv', 'sv-SE'], ['zh', 'zh-CN'], + ['zh-Hant', 'zh-TW'], ]) for (const locale of CROWDIN_LANGUAGE_ID_OVERRIDES.keys()) { diff --git a/client/src/components/AppHeader.vue b/client/src/components/AppHeader.vue index 92d441028..f9b00f2aa 100644 --- a/client/src/components/AppHeader.vue +++ b/client/src/components/AppHeader.vue @@ -17,7 +17,6 @@ import { Star, ExternalLink, Sparkles, - Check, Languages, } from '@lucide/vue' import { useRouter, useRoute } from 'vue-router' @@ -38,7 +37,9 @@ import { DropdownMenuSubContent, DropdownMenuSubTrigger, } from '@/components/ui/dropdown-menu' +import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet' import AccentPicker from '@/components/AccentPicker.vue' +import LanguagePicker from '@/components/LanguagePicker.vue' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import RadiusPicker from '@/components/RadiusPicker.vue' import BackgroundPicker from '@/components/BackgroundPicker.vue' @@ -55,7 +56,7 @@ import NotificationSheet from '@/features/notifications/components/NotificationS import { useNotifications } from '@/features/notifications/composables/useNotifications' import { useWhatsNew } from '@/features/whats-new/composables/useWhatsNew' import UserAvatar from '@/components/UserAvatar.vue' -import { DEFAULT_FORMAT_PRIORITY, LOCALE_LABELS, SUPPORTED_LOCALES, type Locale } from '@bookorbit/types' +import { DEFAULT_FORMAT_PRIORITY, LOCALE_LABELS, type Locale } from '@bookorbit/types' import { useThemeStore } from '@/stores/theme' import { useLocaleStore } from '@/stores/locale' import { getFormatColor } from '@/features/book/lib/format-colors' @@ -71,7 +72,7 @@ const { subscribe: subscribeNotifications } = useNotifications() const { hasUnseen: hasUnseenWhatsNew } = useWhatsNew() const themeStore = useThemeStore() const localeStore = useLocaleStore() -const languageOptions = SUPPORTED_LOCALES.map((id) => ({ id, label: LOCALE_LABELS[id] })) +const currentLanguageLabel = computed(() => LOCALE_LABELS[localeStore.locale]) const documentationUrl = 'https://bookorbit.app/what-is-bookorbit' const githubRepositoryUrl = 'https://github.com/bookorbit/bookorbit' const githubStarPopoverOpen = ref(false) @@ -125,7 +126,13 @@ function navigateToSettings() { router.push({ name: 'settings-libraries' }) } +const languageSheetOpen = ref(false) +const languagePopoverOpen = ref(false) + async function selectLanguage(locale: Locale) { + languageSheetOpen.value = false + languagePopoverOpen.value = false + try { await localeStore.setLocale(locale) } catch { @@ -133,6 +140,10 @@ async function selectLanguage(locale: Locale) { } } +function openLanguageSheet() { + languageSheetOpen.value = true +} + function navigateToWhatsNew() { router.push({ name: 'whats-new' }) } @@ -667,19 +678,11 @@ function formatBadgeStyle(fmt: string) { - - - - {{ t('settings.appearance.language.label') }} - - - - - - {{ option.label }} - - - + + + {{ t('settings.appearance.language.label') }} + {{ currentLanguageLabel }} + @@ -851,9 +854,9 @@ function formatBadgeStyle(fmt: string) { - + - + - + - - {{ t('settings.appearance.language.label') }} - - - - {{ option.label }} - - - + + + + {{ t('settings.appearance.language.label') }} @@ -929,4 +927,13 @@ function formatBadgeStyle(fmt: string) { + + + + + {{ t('settings.appearance.language.label') }} + + + + diff --git a/client/src/components/LanguagePicker.vue b/client/src/components/LanguagePicker.vue new file mode 100644 index 000000000..b1bbae7f0 --- /dev/null +++ b/client/src/components/LanguagePicker.vue @@ -0,0 +1,142 @@ + + + diff --git a/client/src/components/__tests__/AppHeader.spec.ts b/client/src/components/__tests__/AppHeader.spec.ts index e06973322..29299b11a 100644 --- a/client/src/components/__tests__/AppHeader.spec.ts +++ b/client/src/components/__tests__/AppHeader.spec.ts @@ -83,6 +83,7 @@ vi.mock('@/stores/theme', () => ({ vi.mock('@/stores/locale', () => ({ useLocaleStore: () => ({ locale: 'en', setLocale: vi.fn<(...args: unknown[]) => Promise>().mockResolvedValue(undefined) }), + matchSupportedLocale: () => null, })) vi.mock('@/components/ui/sidebar', () => ({ diff --git a/client/src/components/__tests__/LanguagePicker.spec.ts b/client/src/components/__tests__/LanguagePicker.spec.ts new file mode 100644 index 000000000..3fe9b7f70 --- /dev/null +++ b/client/src/components/__tests__/LanguagePicker.spec.ts @@ -0,0 +1,118 @@ +import { mount } from '@vue/test-utils' +import { reactive } from 'vue' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import LanguagePicker from '../LanguagePicker.vue' + +const localeStore = reactive({ locale: 'en' }) +const matchSupportedLocale = vi.fn<(candidates: readonly string[]) => string | null>(() => null) + +vi.mock('@/stores/locale', () => ({ + useLocaleStore: () => localeStore, + matchSupportedLocale: (candidates: readonly string[]) => matchSupportedLocale(candidates), +})) + +function setBrowserLanguages(languages: string[]) { + Object.defineProperty(window.navigator, 'languages', { value: languages, configurable: true }) +} + +/** Row labels for one heading, so assertions do not depend on the other group's contents. */ +function groupLabels(wrapper: ReturnType, heading: string): string[] { + const group = wrapper.findAll('div.pb-1').find((candidate) => candidate.find('p')?.text() === heading) + if (!group) return [] + return group.findAll('button').map((button) => button.find('span[lang]').text()) +} + +describe('LanguagePicker', () => { + beforeEach(() => { + localeStore.locale = 'en' + matchSupportedLocale.mockReset() + matchSupportedLocale.mockReturnValue(null) + setBrowserLanguages(['en-US']) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('sorts every language by the name it actually renders, not by locale code', () => { + const labels = groupLabels(mount(LanguagePicker), 'All languages') + + // Ordering by code would put Suomi between Español and Français, and Bahasa + // Indonesia between Magyar and Italiano, which reads as unsorted to a user. + expect(labels.slice(0, 6)).toEqual(['Bahasa Indonesia', 'Čeština', 'Dansk', 'Deutsch', 'English', 'Español']) + expect(labels).toHaveLength(25) + }) + + it('pairs each native name with its English name', () => { + const wrapper = mount(LanguagePicker) + const german = wrapper.findAll('button').find((button) => button.find('span[lang]').text() === 'Deutsch') + + expect(german?.text()).toContain('German') + expect(german?.find('span[lang]').attributes('lang')).toBe('de') + }) + + it('distinguishes the two Chinese catalogs by script rather than by bare language', () => { + const wrapper = mount(LanguagePicker) + const texts = wrapper.findAll('button').map((button) => button.text()) + + expect(texts.some((text) => text.includes('简体中文') && text.includes('Simplified Chinese'))).toBe(true) + expect(texts.some((text) => text.includes('繁體中文') && text.includes('Traditional Chinese'))).toBe(true) + }) + + it('suggests the current locale plus the browser languages it can match', () => { + localeStore.locale = 'de' + setBrowserLanguages(['fr-FR', 'ja-JP']) + matchSupportedLocale.mockImplementation((candidates) => { + const [candidate] = candidates + if (candidate === 'fr-FR') return 'fr' + if (candidate === 'ja-JP') return 'ja' + return null + }) + + expect(groupLabels(mount(LanguagePicker), 'Suggested')).toEqual(['Deutsch', 'Français', '日本語']) + }) + + it('never repeats the current locale in the suggestions', () => { + localeStore.locale = 'fr' + setBrowserLanguages(['fr-FR', 'fr-CA']) + matchSupportedLocale.mockReturnValue('fr') + + expect(groupLabels(mount(LanguagePicker), 'Suggested')).toEqual(['Français']) + }) + + it('filters on the native name, the English name, and the locale code', async () => { + const wrapper = mount(LanguagePicker) + const input = wrapper.find('input') + + for (const [query, expected] of [ + ['deuts', 'Deutsch'], + ['germ', 'Deutsch'], + ['ko', '한국어'], + ] as const) { + await input.setValue(query) + expect(groupLabels(wrapper, 'Results')).toContain(expected) + } + }) + + it('reports an empty search instead of rendering an empty list', async () => { + const wrapper = mount(LanguagePicker) + + await wrapper.find('input').setValue('klingon') + + expect(wrapper.findAll('button')).toHaveLength(0) + expect(wrapper.text()).toContain('No languages match "klingon"') + }) + + it('marks the active language and emits the one that was chosen', async () => { + localeStore.locale = 'pl' + const wrapper = mount(LanguagePicker) + + const active = wrapper.findAll('button').filter((button) => button.attributes('aria-current') === 'true') + expect(active.every((button) => button.find('span[lang]').text() === 'Polski')).toBe(true) + + const italian = wrapper.findAll('button').find((button) => button.find('span[lang]').text() === 'Italiano') + await italian!.trigger('click') + + expect(wrapper.emitted('select')?.at(-1)).toEqual(['it']) + }) +}) diff --git a/client/src/components/ui/dropdown-menu/DropdownMenuSubContent.vue b/client/src/components/ui/dropdown-menu/DropdownMenuSubContent.vue index 3872214d7..34f15845c 100644 --- a/client/src/components/ui/dropdown-menu/DropdownMenuSubContent.vue +++ b/client/src/components/ui/dropdown-menu/DropdownMenuSubContent.vue @@ -19,7 +19,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits) v-bind="forwarded" :class=" cn( - 'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--reka-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg', + 'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--reka-dropdown-menu-content-available-height) min-w-[8rem] origin-(--reka-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-lg', props.class, ) " diff --git a/client/src/features/settings/AppearanceLanguageSettings.vue b/client/src/features/settings/AppearanceLanguageSettings.vue new file mode 100644 index 000000000..c7db9c227 --- /dev/null +++ b/client/src/features/settings/AppearanceLanguageSettings.vue @@ -0,0 +1,61 @@ + + + diff --git a/client/src/features/settings/AppearanceSettings.vue b/client/src/features/settings/AppearanceSettings.vue index bfa2e8f38..255d5f6f9 100644 --- a/client/src/features/settings/AppearanceSettings.vue +++ b/client/src/features/settings/AppearanceSettings.vue @@ -5,6 +5,7 @@ import { ACCENT_PASTEL, ACCENT_VIVID, BACKGROUND_OPTIONS, useThemeStore } from ' import AppearanceBehaviorSettings from './AppearanceBehaviorSettings.vue' import AppearanceBookCoverSettings from './AppearanceBookCoverSettings.vue' import AppearanceIconsSettings from './AppearanceIconsSettings.vue' +import AppearanceLanguageSettings from './AppearanceLanguageSettings.vue' import AppearanceLayoutSettings from './AppearanceLayoutSettings.vue' import AppearanceThemeSettings from './AppearanceThemeSettings.vue' import SettingsPageHeader from './SettingsPageHeader.vue' @@ -59,4 +60,5 @@ const themeLabel = computed(() => t(`settings.appearance.themeMode.${themeStore. + diff --git a/client/src/features/settings/lib/__tests__/appearance-tabs.spec.ts b/client/src/features/settings/lib/__tests__/appearance-tabs.spec.ts index b79857c8a..d1e9b9645 100644 --- a/client/src/features/settings/lib/__tests__/appearance-tabs.spec.ts +++ b/client/src/features/settings/lib/__tests__/appearance-tabs.spec.ts @@ -3,7 +3,7 @@ import { APPEARANCE_TABS, normalizeAppearanceTab } from '../appearance-tabs' describe('appearance-tabs', () => { it('keeps the public tab order stable', () => { - expect(APPEARANCE_TABS).toEqual(['theme', 'book-covers', 'icons', 'layout', 'behavior']) + expect(APPEARANCE_TABS).toEqual(['theme', 'book-covers', 'icons', 'layout', 'behavior', 'language']) }) it('normalizes invalid values to theme', () => { diff --git a/client/src/features/settings/lib/appearance-tabs.ts b/client/src/features/settings/lib/appearance-tabs.ts index 0d1850f73..de922bb40 100644 --- a/client/src/features/settings/lib/appearance-tabs.ts +++ b/client/src/features/settings/lib/appearance-tabs.ts @@ -1,4 +1,4 @@ -export const APPEARANCE_TABS = ['theme', 'book-covers', 'icons', 'layout', 'behavior'] as const +export const APPEARANCE_TABS = ['theme', 'book-covers', 'icons', 'layout', 'behavior', 'language'] as const export type AppearanceTab = (typeof APPEARANCE_TABS)[number] diff --git a/client/src/i18n/icu.spec.ts b/client/src/i18n/icu.spec.ts index 4021a9bfe..d8059ef7a 100644 --- a/client/src/i18n/icu.spec.ts +++ b/client/src/i18n/icu.spec.ts @@ -4,19 +4,28 @@ import type { Locale } from '@bookorbit/types' import cs from '@/locales/cs.json' import da from '@/locales/da.json' import de from '@/locales/de.json' +import greek from '@/locales/el.json' import en from '@/locales/en.json' import fi from '@/locales/fi.json' import spanish from '@/locales/es.json' import french from '@/locales/fr.json' +import hungarian from '@/locales/hu.json' +import indonesian from '@/locales/id.json' import italian from '@/locales/it.json' +import japanese from '@/locales/ja.json' +import korean from '@/locales/ko.json' import nl from '@/locales/nl.json' import polish from '@/locales/pl.json' import pt from '@/locales/pt.json' +import romanian from '@/locales/ro.json' import ru from '@/locales/ru.json' +import slovak from '@/locales/sk.json' import sl from '@/locales/sl.json' import sv from '@/locales/sv.json' +import turkish from '@/locales/tr.json' import uk from '@/locales/uk.json' import zh from '@/locales/zh.json' +import traditionalChinese from '@/locales/zh-Hant.json' import { compileIcuCatalog, icuCountValues, isIcuPluralMessage, splitIcuCount } from './icu' import { i18n as applicationI18n } from './index' @@ -313,20 +322,29 @@ describe('ICU message compilation', () => { ['cs', cs, [0, 1, 2, 5, 1.5]], ['da', da, [0, 1, 2]], ['de', de, [0, 1, 2]], + ['el', greek, [0, 1, 2]], ['es', spanish, [0, 1, 2, 1_000_000]], ['fi', fi, [0, 1, 2]], ['fr', french, [0, 1, 2, 1_000_000]], + ['hu', hungarian, [0, 1, 2]], + ['id', indonesian, [0, 1, 2]], ['it', italian, [0, 1, 2, 1_000_000]], + ['ja', japanese, [0, 1, 2]], + ['ko', korean, [0, 1, 2]], ['nl', nl, [0, 1, 2]], // Polish reserves `other` for fractions, exactly as Czech does. ['pl', polish, [0, 1, 2, 5, 1.5, 1_000_000]], ['pt', pt, [0, 1, 2, 1_000_000]], + ['ro', romanian, [0, 1, 2, 20]], // Russian and Ukrainian reach `other` only through a fraction; 5 is `many`. ['ru', ru, [0, 1, 2, 5, 1.5]], + ['sk', slovak, [0, 1, 2, 5, 1.1]], ['sl', sl, [0, 1, 2, 3, 5]], ['sv', sv, [0, 1, 2]], + ['tr', turkish, [0, 1, 2]], ['uk', uk, [0, 1, 2, 5, 1.5]], ['zh', zh, [0, 1, 2]], + ['zh-Hant', traditionalChinese, [0, 1, 2]], ] as const)('formats every ICU message for all relevant plural categories in %s', (locale, catalog, counts) => { const testI18n = createCatalogI18n(locale, catalog) diff --git a/client/src/locales/el.json b/client/src/locales/el.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/client/src/locales/el.json @@ -0,0 +1 @@ +{} diff --git a/client/src/locales/en.json b/client/src/locales/en.json index cb5a75cf5..2925c39c7 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -83,7 +83,7 @@ "language": { "title": "Language", "description": "Choose the language used across the interface.", - "label": "Interface language", + "label": "Language", "loadError": "Failed to load the selected language", "saveError": "Failed to save language preference" }, @@ -464,7 +464,8 @@ "book-covers": "Book Covers", "icons": "Icons", "layout": "Layout", - "behavior": "Behavior" + "behavior": "Behavior", + "language": "Language" } }, "account": { @@ -4755,6 +4756,13 @@ "dark": "Dark", "system": "System" }, + "languagePicker": { + "searchPlaceholder": "Search languages", + "suggested": "Suggested", + "all": "All languages", + "results": "Results", + "empty": "No languages match \"{query}\"" + }, "userAvatar": { "profilePicture": "Profile picture", "profilePictureOf": "{name} profile picture" @@ -7107,7 +7115,8 @@ "book-covers": "Book Covers", "icons": "Icons", "layout": "Display Layout", - "behavior": "Library Behavior" + "behavior": "Library Behavior", + "language": "Language" }, "reader": { "general": "Reader Settings", diff --git a/client/src/locales/hu.json b/client/src/locales/hu.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/client/src/locales/hu.json @@ -0,0 +1 @@ +{} diff --git a/client/src/locales/id.json b/client/src/locales/id.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/client/src/locales/id.json @@ -0,0 +1 @@ +{} diff --git a/client/src/locales/ja.json b/client/src/locales/ja.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/client/src/locales/ja.json @@ -0,0 +1 @@ +{} diff --git a/client/src/locales/ko.json b/client/src/locales/ko.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/client/src/locales/ko.json @@ -0,0 +1 @@ +{} diff --git a/client/src/locales/ro.json b/client/src/locales/ro.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/client/src/locales/ro.json @@ -0,0 +1 @@ +{} diff --git a/client/src/locales/sk.json b/client/src/locales/sk.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/client/src/locales/sk.json @@ -0,0 +1 @@ +{} diff --git a/client/src/locales/tr.json b/client/src/locales/tr.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/client/src/locales/tr.json @@ -0,0 +1 @@ +{} diff --git a/client/src/locales/zh-Hant.json b/client/src/locales/zh-Hant.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/client/src/locales/zh-Hant.json @@ -0,0 +1 @@ +{} diff --git a/client/src/stores/__tests__/locale.spec.ts b/client/src/stores/__tests__/locale.spec.ts index 630066b71..73d44d37f 100644 --- a/client/src/stores/__tests__/locale.spec.ts +++ b/client/src/stores/__tests__/locale.spec.ts @@ -31,22 +31,30 @@ describe('locale store', () => { expect(matchSupportedLocale(['nl-NL', 'en-US'])).toBe('nl') expect(matchSupportedLocale(['de-DE', 'en-GB'])).toBe('de') + expect(matchSupportedLocale(['el-GR', 'en-GB'])).toBe('el') expect(matchSupportedLocale(['es-ES', 'en-GB'])).toBe('es') expect(matchSupportedLocale(['fr-FR', 'en-GB'])).toBe('fr') + expect(matchSupportedLocale(['hu-HU', 'en-GB'])).toBe('hu') + expect(matchSupportedLocale(['id-ID', 'en-GB'])).toBe('id') expect(matchSupportedLocale(['it-IT', 'en-GB'])).toBe('it') + expect(matchSupportedLocale(['ja-JP', 'en-GB'])).toBe('ja') + expect(matchSupportedLocale(['ko-KR', 'en-GB'])).toBe('ko') expect(matchSupportedLocale(['nl-BE', 'en'])).toBe('nl') expect(matchSupportedLocale(['pl-PL', 'en-GB'])).toBe('pl') expect(matchSupportedLocale(['pt-BR'])).toBe('pt') + expect(matchSupportedLocale(['ro-RO', 'en-GB'])).toBe('ro') expect(matchSupportedLocale(['ru-RU', 'en-GB'])).toBe('ru') + expect(matchSupportedLocale(['sk-SK', 'en-GB'])).toBe('sk') expect(matchSupportedLocale(['uk-UA', 'en-GB'])).toBe('uk') expect(matchSupportedLocale(['cs-CZ', 'en-GB'])).toBe('cs') expect(matchSupportedLocale(['da-DK', 'en-GB'])).toBe('da') expect(matchSupportedLocale(['fi-FI', 'en-GB'])).toBe('fi') expect(matchSupportedLocale(['sv-SE', 'en-GB'])).toBe('sv') expect(matchSupportedLocale(['sv-FI', 'en-GB'])).toBe('sv') + expect(matchSupportedLocale(['tr-TR', 'en-GB'])).toBe('tr') }) - it('resolves every Chinese script and region variant to the Simplified catalog', async () => { + it('distinguishes Simplified and Traditional Chinese scripts and regions', async () => { const { matchSupportedLocale } = await import('../locale') expect(matchSupportedLocale(['zh'])).toBe('zh') @@ -54,11 +62,11 @@ describe('locale store', () => { expect(matchSupportedLocale(['zh-Hans-CN'])).toBe('zh') expect(matchSupportedLocale(['zh-SG'])).toBe('zh') - // Traditional readers intentionally land on Simplified until a zh-Hant - // catalog exists. Adding one means matching by script before base language. - expect(matchSupportedLocale(['zh-TW'])).toBe('zh') - expect(matchSupportedLocale(['zh-Hant'])).toBe('zh') - expect(matchSupportedLocale(['zh-HK'])).toBe('zh') + expect(matchSupportedLocale(['zh-TW'])).toBe('zh-Hant') + expect(matchSupportedLocale(['zh-Hant'])).toBe('zh-Hant') + expect(matchSupportedLocale(['zh-Hant-HK'])).toBe('zh-Hant') + expect(matchSupportedLocale(['zh-HK'])).toBe('zh-Hant') + expect(matchSupportedLocale(['zh-MO'])).toBe('zh-Hant') }) it('prefers the stored locale over browser detection', async () => { diff --git a/client/src/stores/locale.ts b/client/src/stores/locale.ts index 9261779d8..0ab8857b1 100644 --- a/client/src/stores/locale.ts +++ b/client/src/stores/locale.ts @@ -16,9 +16,15 @@ function canonicalizeLocale(value: string): string | null { export function matchSupportedLocale(candidates: readonly string[]): Locale | null { const supportedByCanonical = new Map() + const supportedByLanguageAndScript = new Map() for (const locale of SUPPORTED_LOCALES) { const canonical = canonicalizeLocale(locale) - if (canonical) supportedByCanonical.set(canonical.toLowerCase(), locale) + if (!canonical) continue + supportedByCanonical.set(canonical.toLowerCase(), locale) + const maximized = new Intl.Locale(canonical).maximize() + if (maximized.script) { + supportedByLanguageAndScript.set(`${maximized.language.toLowerCase()}-${maximized.script.toLowerCase()}`, locale) + } } for (const candidate of candidates) { @@ -26,7 +32,12 @@ export function matchSupportedLocale(candidates: readonly string[]): Locale | nu if (!canonical) continue const exact = supportedByCanonical.get(canonical.toLowerCase()) if (exact) return exact - const language = new Intl.Locale(canonical).language.toLowerCase() + const maximized = new Intl.Locale(canonical).maximize() + const language = maximized.language.toLowerCase() + if (maximized.script) { + const scriptMatch = supportedByLanguageAndScript.get(`${language}-${maximized.script.toLowerCase()}`) + if (scriptMatch) return scriptMatch + } const baseLocale = supportedByCanonical.get(language) if (baseLocale) return baseLocale } diff --git a/crowdin.yml b/crowdin.yml index 6064daf9c..2848e018e 100644 --- a/crowdin.yml +++ b/crowdin.yml @@ -4,18 +4,27 @@ export_languages: - cs - da - de + - el - es-ES - fi - fr + - hu + - id - it + - ja + - ko - nl - pl - pt-BR + - ro - ru + - sk - sl - sv-SE + - tr - uk - zh-CN + - zh-TW pull_request_title: "i18n(client): sync translations from Crowdin" commit_message: |- @@ -27,4 +36,7 @@ append_commit_message: false files: - source: /client/src/locales/en.json translation: /client/src/locales/%two_letters_code%.json + languages_mapping: + two_letters_code: + zh-TW: zh-Hant update_option: update_as_unapproved diff --git a/docs/LOCALIZATION.md b/docs/LOCALIZATION.md index 6a3eb766b..fa78a9b81 100644 --- a/docs/LOCALIZATION.md +++ b/docs/LOCALIZATION.md @@ -4,30 +4,39 @@ BookOrbit uses Vue I18n catalogs under `client/src/locales/`. English is the sou ## Supported Catalogs -| Application locale | Crowdin language ID | Catalog | -| -------------------- | ------------------- | --------- | -| English | Source language | `en.json` | -| Czech | `cs` | `cs.json` | -| Danish | `da` | `da.json` | -| German | `de` | `de.json` | -| Spanish | `es-ES` | `es.json` | -| Finnish | `fi` | `fi.json` | -| French | `fr` | `fr.json` | -| Italian | `it` | `it.json` | -| Dutch | `nl` | `nl.json` | -| Polish | `pl` | `pl.json` | -| Brazilian Portuguese | `pt-BR` | `pt.json` | -| Russian | `ru` | `ru.json` | -| Slovenian | `sl` | `sl.json` | -| Swedish | `sv-SE` | `sv.json` | -| Ukrainian | `uk` | `uk.json` | -| Simplified Chinese | `zh-CN` | `zh.json` | +| Application locale | Crowdin language ID | Catalog | +| -------------------- | ------------------- | -------------- | +| English | Source language | `en.json` | +| Czech | `cs` | `cs.json` | +| Danish | `da` | `da.json` | +| German | `de` | `de.json` | +| Greek | `el` | `el.json` | +| Spanish | `es-ES` | `es.json` | +| Finnish | `fi` | `fi.json` | +| French | `fr` | `fr.json` | +| Hungarian | `hu` | `hu.json` | +| Indonesian | `id` | `id.json` | +| Italian | `it` | `it.json` | +| Japanese | `ja` | `ja.json` | +| Korean | `ko` | `ko.json` | +| Dutch | `nl` | `nl.json` | +| Polish | `pl` | `pl.json` | +| Brazilian Portuguese | `pt-BR` | `pt.json` | +| Romanian | `ro` | `ro.json` | +| Russian | `ru` | `ru.json` | +| Slovak | `sk` | `sk.json` | +| Slovenian | `sl` | `sl.json` | +| Swedish | `sv-SE` | `sv.json` | +| Turkish | `tr` | `tr.json` | +| Ukrainian | `uk` | `uk.json` | +| Simplified Chinese | `zh-CN` | `zh.json` | +| Traditional Chinese | `zh-TW` | `zh-Hant.json` | Crowdin's `%two_letters_code%` placeholder reduces the regional IDs above to BookOrbit's application locale and filename: `es-ES` to `es`, `pt-BR` to `pt`, `sv-SE` to `sv`, and `zh-CN` to `zh`. Verify every ID against Crowdin's language list rather than assuming the two-letter form exists. Several languages have no bare two-letter ID at all: Swedish is `sv-SE`, and `sv-FI` reduces to the same `sv.json`, so enabling both would collide. -Chinese ships as Simplified only under the bare `zh` locale, so every `zh-*` browser resolves to it, including Traditional readers. Adding Traditional later means a new `zh-Hant` catalog with a `languages_mapping` entry for `zh-TW`; `zh` keeps its Simplified meaning, so no rename or stored-preference migration is needed. That addition also requires matching by script subtag in `client/src/stores/locale.ts`, because the current base-language fallback would send `zh-Hant` to `zh`. +Chinese uses separate application locales for each script. Simplified Chinese remains under `zh`, while Traditional Chinese uses `zh-Hant`. The `languages_mapping` entry in `crowdin.yml` maps Crowdin's `zh-TW` target to `zh-Hant.json` so it cannot collide with the Simplified catalog. Browser matching uses explicit or inferred script subtags: `zh-CN` and `zh-SG` resolve to `zh`, while `zh-TW`, `zh-HK`, and `zh-MO` resolve to `zh-Hant`. ## Adding User-Facing Copy @@ -92,7 +101,7 @@ After an English source change reaches `main`: 4. The workflow requests only translated strings, removes the empty values Crowdin emits for untranslated nested JSON entries, and validates the resulting sparse catalogs. 5. Before writing, the workflow rejects exports that omit an existing translation or replace one with English source text. 6. Only after validation and retention checks pass, the workflow updates `l10n_main` and opens a pull request to `main`. -7. CI verifies that the pull request changes only the fifteen target catalogs and runs the normal client checks. +7. CI verifies that the pull request changes only the twenty-four target catalogs and runs the normal client checks. 8. A maintainer reviews and squash-merges the pull request. 9. The `.github/workflows/crowdin-branch-cleanup.yml` workflow deletes the `l10n_main` service branch. The next export recreates it from the current `main` when translations change. diff --git a/packages/types/src/locale.ts b/packages/types/src/locale.ts index ae63cd625..b4bd8be2a 100644 --- a/packages/types/src/locale.ts +++ b/packages/types/src/locale.ts @@ -1,4 +1,30 @@ -export const SUPPORTED_LOCALES = ["en", "cs", "da", "de", "es", "fi", "fr", "it", "nl", "pl", "pt", "ru", "sl", "sv", "uk", "zh"] as const; +export const SUPPORTED_LOCALES = [ + "en", + "cs", + "da", + "de", + "el", + "es", + "fi", + "fr", + "hu", + "id", + "it", + "ja", + "ko", + "nl", + "pl", + "pt", + "ro", + "ru", + "sk", + "sl", + "sv", + "tr", + "uk", + "zh", + "zh-Hant", +] as const; export type Locale = (typeof SUPPORTED_LOCALES)[number]; export const DEFAULT_LOCALE: Locale = "en"; @@ -9,18 +35,27 @@ export const LOCALE_LABELS: Record = { cs: "Čeština", da: "Dansk", de: "Deutsch", + el: "Ελληνικά", es: "Español", fi: "Suomi", fr: "Français", + hu: "Magyar", + id: "Bahasa Indonesia", it: "Italiano", + ja: "日本語", + ko: "한국어", nl: "Nederlands", pl: "Polski", pt: "Português", + ro: "Română", ru: "Русский", + sk: "Slovenčina", sl: "Slovenščina", sv: "Svenska", + tr: "Türkçe", uk: "Українська", zh: "简体中文", + "zh-Hant": "繁體中文", }; export const LOCALE_DIRECTIONS: Record = { @@ -28,18 +63,27 @@ export const LOCALE_DIRECTIONS: Record = { cs: "ltr", da: "ltr", de: "ltr", + el: "ltr", es: "ltr", fi: "ltr", fr: "ltr", + hu: "ltr", + id: "ltr", it: "ltr", + ja: "ltr", + ko: "ltr", nl: "ltr", pl: "ltr", pt: "ltr", + ro: "ltr", ru: "ltr", + sk: "ltr", sl: "ltr", sv: "ltr", + tr: "ltr", uk: "ltr", zh: "ltr", + "zh-Hant": "ltr", }; export interface LocalePreferences { diff --git a/scripts/classify-crowdin-pr.sh b/scripts/classify-crowdin-pr.sh index b8ef3793f..2231187ce 100755 --- a/scripts/classify-crowdin-pr.sh +++ b/scripts/classify-crowdin-pr.sh @@ -22,18 +22,27 @@ allowed_paths=( "client/src/locales/cs.json" "client/src/locales/da.json" "client/src/locales/de.json" + "client/src/locales/el.json" "client/src/locales/es.json" "client/src/locales/fi.json" "client/src/locales/fr.json" + "client/src/locales/hu.json" + "client/src/locales/id.json" "client/src/locales/it.json" + "client/src/locales/ja.json" + "client/src/locales/ko.json" "client/src/locales/nl.json" "client/src/locales/pl.json" "client/src/locales/pt.json" + "client/src/locales/ro.json" "client/src/locales/ru.json" + "client/src/locales/sk.json" "client/src/locales/sl.json" "client/src/locales/sv.json" + "client/src/locales/tr.json" "client/src/locales/uk.json" "client/src/locales/zh.json" + "client/src/locales/zh-Hant.json" ) is_allowed_path() { diff --git a/scripts/crowdin-stats/generate.mjs b/scripts/crowdin-stats/generate.mjs index dccd98fe1..5a3015331 100644 --- a/scripts/crowdin-stats/generate.mjs +++ b/scripts/crowdin-stats/generate.mjs @@ -14,21 +14,21 @@ const REQUEST_TIMEOUT_MS = 30_000; const MAX_RESPONSE_BYTES = 1_000_000; const SOURCE_LANGUAGE_ID = "en"; -const WIDTH = 540; -const PADDING_X = 24; +const WIDTH = 460; +const PADDING_X = 20; const DIVIDER_Y = 51; const ROW_BASELINE = 78; -const ROW_HEIGHT = 28; -const BAR_HEIGHT = 8; -const BAR_RADIUS = 4; -const BAR_BASELINE_OFFSET = 9; +const ROW_HEIGHT = 25; +const BAR_HEIGHT = 6; +const BAR_RADIUS = 3; +const BAR_BASELINE_OFFSET = 8; const FLAG_WIDTH = 26; -const LABEL_CHAR_WIDTH = 7.6; -const LABEL_MAX_WIDTH = 180; +const LABEL_CHAR_WIDTH = 7.1; +const LABEL_MAX_WIDTH = 140; const LABEL_GAP = 18; -const PERCENT_WIDTH = 42; +const PERCENT_WIDTH = 38; const PERCENT_GAP = 14; const LEGEND_SWATCH = 8; @@ -179,9 +179,17 @@ function flagFromLocale(locale) { return String.fromCodePoint(...[...region.toUpperCase()].map((letter) => letter.codePointAt(0) + 0x1f1a5)); } +// The label column is sized by the single widest name, so Crowdin's Chinese names +// would cost every other row roughly 60px of bar. The flags already carry the +// region, leaving the script marker as the only part the name has to supply. +const DISPLAY_NAME_OVERRIDES = new Map([ + ["zh-CN", "Chinese (S)"], + ["zh-TW", "Chinese (T)"], +]); + function displayFor(languageId, apiLanguage) { const flag = flagFromLocale(apiLanguage?.locale); - const name = apiLanguage?.name || languageId; + const name = DISPLAY_NAME_OVERRIDES.get(languageId) || apiLanguage?.name || languageId; return { name: flag ? name.split(",")[0].trim() : name, @@ -320,7 +328,7 @@ export function generateSvg(statsData) { const targetCount = languages.filter((language) => !language.isSource).length; const summary = targetCount > 0 - ? `Crowdin · ${targetCount} target ${targetCount === 1 ? "language" : "languages"} · ${averagePercent(languages)}% average` + ? `Crowdin · ${targetCount} ${targetCount === 1 ? "language" : "languages"} · ${averagePercent(languages)}% average` : "Crowdin · no translation data"; return ` @@ -329,8 +337,8 @@ export function generateSvg(statsData) { .card { fill: #0d1117; stroke: #30363d; stroke-width: 1px; rx: 12px; } .title { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; font-size: 15px; font-weight: 600; fill: #f0f6fc; } .subtitle { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; font-size: 12px; font-weight: 400; fill: #8b949e; } - .lang-label { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; font-size: 14px; font-weight: 500; fill: #e6edf3; } - .pct-label { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; font-size: 13.5px; font-weight: 600; fill: #e6edf3; font-variant-numeric: tabular-nums; } + .lang-label { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; font-size: 13px; font-weight: 500; fill: #e6edf3; } + .pct-label { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; font-size: 12.5px; font-weight: 600; fill: #e6edf3; font-variant-numeric: tabular-nums; } .legend-label { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; font-size: 11px; font-weight: 400; fill: #8b949e; } .grid { stroke: #21262d; stroke-width: 1; } .bar-bg { fill: #21262d; } diff --git a/scripts/crowdin-stats/generate.test.mjs b/scripts/crowdin-stats/generate.test.mjs index ebde5da36..034fcd984 100644 --- a/scripts/crowdin-stats/generate.test.mjs +++ b/scripts/crowdin-stats/generate.test.mjs @@ -95,6 +95,13 @@ test("extractLanguages derives flags from the locale and defaults missing progre const [unknown] = extractLanguages({ progress: [{ data: { languageId: "eo" } }] }); assert.equal(unknown.name, "eo"); + + // Crowdin's own Chinese names are long enough to widen the label column for every row. + const [simplified] = extractLanguages({ progress: [language("zh-CN", "Chinese Simplified", "zh-CN", "99%")] }); + assert.equal(simplified.name, "Chinese (S)"); + + const [traditional] = extractLanguages({ progress: [language("zh-TW", "Chinese Traditional", "zh-TW", "0%")] }); + assert.equal(traditional.name, "Chinese (T)"); assert.equal(unknown.flag, ""); assert.equal(unknown.percent, 0); }); @@ -107,7 +114,7 @@ test("generateSvg renders every target language with its percentage", () => { assert.match(svg, /🇧🇷 {2}Portuguese97% { const { height } = computeLayout(extractLanguages(sampleStats)); - assert.match(generateSvg(sampleStats), new RegExp(`]*width="540" height="${height}" viewBox="0 0 540 ${height}"`)); + assert.match(generateSvg(sampleStats), new RegExp(`]*width="460" height="${height}" viewBox="0 0 460 ${height}"`)); }); test("generateSvg grows the canvas instead of overflowing when languages are added", () => { const withExtra = generateSvg({ progress: [...sampleStats.progress, language("es", "Spanish", "es-ES", "40%", 4000, 19358)] }); - assert.match(withExtra, /viewBox="0 0 540 278"/); + assert.match(withExtra, /viewBox="0 0 460 263"/); assert.match(withExtra, /🇪🇸 {2}Spanish/); - assert.match(generateSvg(sampleStats), /viewBox="0 0 540 250"/); + assert.match(generateSvg(sampleStats), /viewBox="0 0 460 238"/); }); test("computeLayout keeps horizontal rows at any language count", () => { @@ -142,7 +149,7 @@ test("computeLayout keeps horizontal rows at any language count", () => { const twenty = computeLayout(rows(20)); assert.equal(nine.count, 9); - assert.equal(twenty.height - nine.height, 11 * 28, "each extra language adds exactly one row"); + assert.equal(twenty.height - nine.height, 11 * 25, "each extra language adds exactly one row"); }); test("generateSvg renders many languages as rows without rotating labels", () => { @@ -156,7 +163,7 @@ test("generateSvg renders many languages as rows without rotating labels", () => const svg = generateSvg(many); assert.equal(layout.count, 13); - assert.match(svg, new RegExp(`viewBox="0 0 540 ${layout.height}"`)); + assert.match(svg, new RegExp(`viewBox="0 0 460 ${layout.height}"`)); assert.equal(svg.match(/class="bar-bg"/g).length, 13); assert.doesNotMatch(svg, /rotate\(/); }); @@ -183,8 +190,8 @@ test("generateSvg keeps bars inside the card", () => { assert.equal(svg.match(/class="bar-bg"/g).length, 5, "one track per language row"); for (const [, x, width] of svg.matchAll(/]*class="bar-(?:bg|done|progress|low)"/g)) { - assert.ok(Number(x) >= 24, "bar must not overflow the left padding"); - assert.ok(Number(x) + Number(width) <= 540 - 24, "bar must not overflow the right padding"); + assert.ok(Number(x) >= 20, "bar must not overflow the left padding"); + assert.ok(Number(x) + Number(width) <= 460 - 20, "bar must not overflow the right padding"); } }); @@ -209,6 +216,6 @@ test("generateSvg escapes language names and handles empty data", () => { const empty = generateSvg({ progress: [] }); assert.match(empty, /no translation data/); - assert.match(empty, /viewBox="0 0 540 96"/); + assert.match(empty, /viewBox="0 0 460 96"/); assert.doesNotMatch(empty, /NaN|undefined/); }); diff --git a/server/src/modules/user-preferences/user-preferences.service.test.ts b/server/src/modules/user-preferences/user-preferences.service.test.ts index ccf8e3773..a0436c4a9 100644 --- a/server/src/modules/user-preferences/user-preferences.service.test.ts +++ b/server/src/modules/user-preferences/user-preferences.service.test.ts @@ -1,7 +1,7 @@ import { BadRequestException } from '@nestjs/common'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { Accent, DisplayPreferences, LocalePreferences, ServerFontPreferences, ThemePreferences } from '@bookorbit/types'; -import { MAX_SERVER_FONTS } from '@bookorbit/types'; +import { MAX_SERVER_FONTS, SUPPORTED_LOCALES } from '@bookorbit/types'; import { UserPreferencesRepository } from './user-preferences.repository'; import { UserPreferencesService } from './user-preferences.service'; @@ -139,7 +139,7 @@ describe('UserPreferencesService', () => { expect(repo.upsert).toHaveBeenCalledWith(11, 'locale', validLocalePreferences); }); - it.each(['es', 'fr', 'pl'] as const)('upsertLocalePreferences accepts the %s locale', async (locale) => { + it.each(SUPPORTED_LOCALES)('upsertLocalePreferences accepts the %s locale', async (locale) => { const preferences: LocalePreferences = { locale }; await expect(service.upsertLocalePreferences(11, preferences)).resolves.toBeUndefined();