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.
This commit is contained in:
neon
2026-08-11 07:25:09 +00:00
parent 953c9e334b
commit 7dcc509a54
31 changed files with 573 additions and 88 deletions
@@ -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
+1
View File
@@ -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()) {
+35 -28
View File
@@ -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) {
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Languages :size="15" class="mr-2 text-muted-foreground" />
{{ t('settings.appearance.language.label') }}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent class="w-44">
<DropdownMenuItem v-for="option in languageOptions" :key="option.id" @click="selectLanguage(option.id)">
<Check v-if="localeStore.locale === option.id" :size="14" class="mr-2" />
<span v-else class="mr-2 w-3.5" />
{{ option.label }}
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuItem @click="openLanguageSheet">
<Languages :size="15" class="mr-2 text-muted-foreground" />
{{ t('settings.appearance.language.label') }}
<span class="ms-auto ps-3 text-xs text-muted-foreground">{{ currentLanguageLabel }}</span>
</DropdownMenuItem>
<DropdownMenuItem @click="navigateToSettings">
<Settings :size="15" class="mr-2 text-muted-foreground" />
@@ -851,9 +854,9 @@ function formatBadgeStyle(fmt: string) {
</Tooltip>
<Tooltip>
<DropdownMenu>
<Popover v-model:open="languagePopoverOpen">
<TooltipTrigger as-child>
<DropdownMenuTrigger as-child>
<PopoverTrigger as-child>
<Button
data-testid="language-control"
variant="ghost"
@@ -863,17 +866,12 @@ function formatBadgeStyle(fmt: string) {
>
<Languages :size="15" />
</Button>
</DropdownMenuTrigger>
</PopoverTrigger>
</TooltipTrigger>
<DropdownMenuContent align="end" class="w-44">
<DropdownMenuLabel>{{ t('settings.appearance.language.label') }}</DropdownMenuLabel>
<DropdownMenuItem v-for="option in languageOptions" :key="option.id" @click="selectLanguage(option.id)">
<Check v-if="localeStore.locale === option.id" :size="14" class="mr-2" />
<span v-else class="mr-2 w-3.5" />
{{ option.label }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<PopoverContent align="end" class="w-80 p-1">
<LanguagePicker :autofocus="languagePopoverOpen" class="max-h-[min(26rem,calc(100dvh-8rem))]" @select="selectLanguage" />
</PopoverContent>
</Popover>
<TooltipContent>{{ t('settings.appearance.language.label') }}</TooltipContent>
</Tooltip>
@@ -929,4 +927,13 @@ function formatBadgeStyle(fmt: string) {
</header>
<BookUploadModal v-if="uploadOpen" @close="uploadOpen = false" @uploaded="uploadOpen = false" />
<Sheet v-model:open="languageSheetOpen">
<SheetContent side="bottom" class="h-[85dvh] rounded-t-xl px-2 pb-2">
<SheetHeader class="pb-1">
<SheetTitle>{{ t('settings.appearance.language.label') }}</SheetTitle>
</SheetHeader>
<LanguagePicker :autofocus="false" class="min-h-0 flex-1" @select="selectLanguage" />
</SheetContent>
</Sheet>
</template>
+142
View File
@@ -0,0 +1,142 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { Check, Search } from '@lucide/vue'
import { LOCALE_LABELS, SUPPORTED_LOCALES, type Locale } from '@bookorbit/types'
import { Input } from '@/components/ui/input'
import { matchSupportedLocale, useLocaleStore } from '@/stores/locale'
const props = withDefaults(defineProps<{ autofocus?: boolean }>(), { autofocus: false })
const emit = defineEmits<{ select: [locale: Locale] }>()
const { t } = useI18n()
const localeStore = useLocaleStore()
const query = ref('')
const searchInput = ref<InstanceType<typeof Input> | null>(null)
// `zh` names the Simplified catalog, but Intl resolves the bare tag to plain "Chinese",
// which reads as the parent of both scripts next to "Traditional Chinese".
const ENGLISH_NAME_TAGS: Partial<Record<Locale, string>> = { zh: 'zh-Hans' }
const englishNames = computed(() => {
const display = new Intl.DisplayNames(['en'], { type: 'language' })
const names = {} as Record<Locale, string>
for (const locale of SUPPORTED_LOCALES) {
names[locale] = display.of(ENGLISH_NAME_TAGS[locale] ?? locale) ?? LOCALE_LABELS[locale]
}
return names
})
interface LanguageOption {
id: Locale
label: string
englishName: string
}
const allLanguages = computed<LanguageOption[]>(() => {
const collator = new Intl.Collator('en')
return SUPPORTED_LOCALES.map((id) => ({ id, label: LOCALE_LABELS[id], englishName: englishNames.value[id] })).sort((a, b) =>
collator.compare(a.label, b.label),
)
})
// The browser's own preference order is the best guess we have, and it is the same
// signal the store uses to pick a default before anyone has chosen one.
const suggested = computed<LanguageOption[]>(() => {
const ids: Locale[] = [localeStore.locale]
if (typeof navigator !== 'undefined') {
const candidates = Array.isArray(navigator.languages) && navigator.languages.length > 0 ? navigator.languages : [navigator.language]
for (const candidate of candidates) {
if (typeof candidate !== 'string') continue
const matched = matchSupportedLocale([candidate])
if (matched && !ids.includes(matched)) ids.push(matched)
}
}
return ids.map((id) => allLanguages.value.find((language) => language.id === id)).filter((language) => language !== undefined)
})
function matches(language: LanguageOption, needle: string): boolean {
return (
language.label.toLocaleLowerCase().includes(needle) ||
language.englishName.toLocaleLowerCase().includes(needle) ||
language.id.toLocaleLowerCase().startsWith(needle)
)
}
const trimmedQuery = computed(() => query.value.trim().toLocaleLowerCase())
const isSearching = computed(() => trimmedQuery.value.length > 0)
const results = computed(() => allLanguages.value.filter((language) => matches(language, trimmedQuery.value)))
const groups = computed<{ key: string; heading: string; languages: LanguageOption[] }[]>(() => {
if (isSearching.value) {
return results.value.length > 0 ? [{ key: 'results', heading: t('components.languagePicker.results'), languages: results.value }] : []
}
return [
{ key: 'suggested', heading: t('components.languagePicker.suggested'), languages: suggested.value },
{ key: 'all', heading: t('components.languagePicker.all'), languages: allLanguages.value },
]
})
watch(
() => props.autofocus,
async (autofocus) => {
if (!autofocus) return
await nextTick()
searchInput.value?.$el?.focus()
},
{ immediate: true },
)
function handleSelect(locale: Locale) {
emit('select', locale)
}
</script>
<template>
<div class="flex flex-col min-h-0">
<div class="relative px-1 pb-2 shrink-0">
<Search :size="15" class="absolute start-4 top-1/2 -translate-y-1/2 text-muted-foreground pointer-events-none" aria-hidden="true" />
<Input
ref="searchInput"
v-model="query"
type="search"
class="ps-9"
:placeholder="t('components.languagePicker.searchPlaceholder')"
:aria-label="t('components.languagePicker.searchPlaceholder')"
/>
</div>
<div class="min-h-0 flex-1 overflow-y-auto overscroll-contain">
<p v-if="groups.length === 0" class="px-3 py-6 text-center text-sm text-muted-foreground">
{{ t('components.languagePicker.empty', { query: query.trim() }) }}
</p>
<div v-for="group in groups" :key="group.key" class="pb-1">
<p class="px-3 pt-2 pb-1 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
{{ group.heading }}
</p>
<ul class="px-1">
<li v-for="language in group.languages" :key="`${group.key}-${language.id}`">
<button
type="button"
class="flex w-full items-center gap-2 rounded-sm px-2 py-2 text-start text-sm text-foreground hover:bg-accent focus-visible:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
:aria-current="localeStore.locale === language.id ? 'true' : undefined"
@click="handleSelect(language.id)"
>
<Check v-if="localeStore.locale === language.id" :size="14" class="shrink-0 text-primary" aria-hidden="true" />
<span v-else class="w-3.5 shrink-0" aria-hidden="true" />
<span :lang="language.id">{{ language.label }}</span>
<span class="ms-auto ps-3 text-xs text-muted-foreground">{{ language.englishName }}</span>
</button>
</li>
</ul>
</div>
</div>
</div>
</template>
@@ -83,6 +83,7 @@ vi.mock('@/stores/theme', () => ({
vi.mock('@/stores/locale', () => ({
useLocaleStore: () => ({ locale: 'en', setLocale: vi.fn<(...args: unknown[]) => Promise<void>>().mockResolvedValue(undefined) }),
matchSupportedLocale: () => null,
}))
vi.mock('@/components/ui/sidebar', () => ({
@@ -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<typeof mount>, 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'])
})
})
@@ -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,
)
"
@@ -0,0 +1,61 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { ChevronDown, Languages } from '@lucide/vue'
import { LOCALE_LABELS, type Locale } from '@bookorbit/types'
import { toast } from 'vue-sonner'
import LanguagePicker from '@/components/LanguagePicker.vue'
import { Button } from '@/components/ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { useLocaleStore } from '@/stores/locale'
const { t } = useI18n()
const localeStore = useLocaleStore()
const pickerOpen = ref(false)
const currentLabel = computed(() => LOCALE_LABELS[localeStore.locale])
async function selectLanguage(locale: Locale) {
pickerOpen.value = false
try {
await localeStore.setLocale(locale)
} catch {
toast.error(t('settings.appearance.language.loadError'))
}
}
</script>
<template>
<div class="space-y-6">
<div>
<p class="settings-group-label">
{{ t('settings.appearance.language.title') }}
</p>
<div class="settings-card">
<div class="settings-row">
<div>
<p class="settings-label">
{{ t('settings.appearance.language.label') }}
</p>
<p class="settings-hint">
{{ t('settings.appearance.language.description') }}
</p>
</div>
<Popover v-model:open="pickerOpen">
<PopoverTrigger as-child>
<Button variant="outline" class="self-start justify-between gap-2 min-w-48" data-testid="settings-language-trigger">
<Languages :size="15" class="text-muted-foreground" />
<span :lang="localeStore.locale">{{ currentLabel }}</span>
<ChevronDown :size="15" class="ms-auto text-muted-foreground" />
</Button>
</PopoverTrigger>
<PopoverContent align="end" class="w-80 p-1">
<LanguagePicker :autofocus="pickerOpen" class="max-h-[min(26rem,calc(100dvh-8rem))]" @select="selectLanguage" />
</PopoverContent>
</Popover>
</div>
</div>
</div>
</div>
</template>
@@ -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.
<AppearanceIconsSettings v-else-if="activeTab === 'icons'" />
<AppearanceLayoutSettings v-else-if="activeTab === 'layout'" />
<AppearanceBehaviorSettings v-else-if="activeTab === 'behavior'" />
<AppearanceLanguageSettings v-else-if="activeTab === 'language'" />
</template>
@@ -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', () => {
@@ -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]
+18
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
{}
+12 -3
View File
@@ -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",
+1
View File
@@ -0,0 +1 @@
{}
+1
View File
@@ -0,0 +1 @@
{}
+1
View File
@@ -0,0 +1 @@
{}
+1
View File
@@ -0,0 +1 @@
{}
+1
View File
@@ -0,0 +1 @@
{}
+1
View File
@@ -0,0 +1 @@
{}
+1
View File
@@ -0,0 +1 @@
{}
+1
View File
@@ -0,0 +1 @@
{}
+14 -6
View File
@@ -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 () => {
+13 -2
View File
@@ -16,9 +16,15 @@ function canonicalizeLocale(value: string): string | null {
export function matchSupportedLocale(candidates: readonly string[]): Locale | null {
const supportedByCanonical = new Map<string, Locale>()
const supportedByLanguageAndScript = new Map<string, Locale>()
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
}
+12
View File
@@ -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
+29 -20
View File
@@ -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.
+45 -1
View File
@@ -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<Locale, string> = {
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<Locale, "ltr" | "rtl"> = {
@@ -28,18 +63,27 @@ export const LOCALE_DIRECTIONS: Record<Locale, "ltr" | "rtl"> = {
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 {
+9
View File
@@ -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() {
+21 -13
View File
@@ -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 `<svg xmlns="http://www.w3.org/2000/svg" width="${WIDTH}" height="${layout.height}" viewBox="0 0 ${WIDTH} ${layout.height}" fill="none" role="img" aria-label="BookOrbit translation progress">
@@ -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; }
+17 -10
View File
@@ -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}Portuguese</);
assert.match(svg, /🇸🇮 {2}Slovenian/);
assert.match(svg, />97%</);
assert.match(svg, /4 target languages · 96% average/);
assert.match(svg, /4 languages · 96% average/);
assert.match(svg, /English/);
});
@@ -120,20 +127,20 @@ test("generateSvg renders the source language like any other completed row but k
assert.equal(source.percent, 100);
assert.doesNotMatch(svg, /bar-source/);
// English sits at 100%, so it takes the completed tier and fills the whole bar.
assert.match(svg, new RegExp(`width="${layout.barWidth}" height="8" rx="4" class="bar-done"`));
assert.match(svg, new RegExp(`width="${layout.barWidth}" height="6" rx="3" class="bar-done"`));
assert.match(svg, /96% average/);
});
test("generateSvg renders at its natural size so label type is never scaled down", () => {
const { height } = computeLayout(extractLanguages(sampleStats));
assert.match(generateSvg(sampleStats), new RegExp(`<svg [^>]*width="540" height="${height}" viewBox="0 0 540 ${height}"`));
assert.match(generateSvg(sampleStats), new RegExp(`<svg [^>]*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(/<rect x="(\d+)" y="\d+" width="(\d+)"[^>]*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/);
});
@@ -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();