diff --git a/components/desktop/desktop-host.tsx b/components/desktop/desktop-host.tsx index f8295a3ad..29aa70dd6 100644 --- a/components/desktop/desktop-host.tsx +++ b/components/desktop/desktop-host.tsx @@ -118,7 +118,7 @@ export function DesktopHost() { // Menus and spellcheck follow the interface. useEffect(() => { const ui = uiLocale || document.documentElement.lang; - if (ui) setDesktopInterfaceLanguage(ui, shellLabels(tRef.current as unknown as (key: string) => string)); + if (ui) setDesktopInterfaceLanguage(ui, shellLabels(tRef.current as unknown as Parameters[0])); }, [uiLocale]); // What the user writes in, whichever composer asks for it too. diff --git a/lib/desktop/__tests__/shell-labels.test.ts b/lib/desktop/__tests__/shell-labels.test.ts new file mode 100644 index 000000000..563c194d4 --- /dev/null +++ b/lib/desktop/__tests__/shell-labels.test.ts @@ -0,0 +1,45 @@ +import { readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { createTranslator } from 'use-intl/core'; +import { shellLabels } from '../languages'; + +/** + * The real catalogs through the real formatter, which the vitest next-intl mock isn't. + * A string that needs a value and doesn't get one is an error here. In the app it's a + * logged error, and the desktop's menu would show the key instead of the label. + */ +const LOCALES_DIR = join(process.cwd(), 'locales'); +const LOCALES = readdirSync(LOCALES_DIR, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name); + +function labelsFor(locale: string): { labels: Record; errors: string[] } { + const messages = JSON.parse(readFileSync(join(LOCALES_DIR, locale, 'common.json'), 'utf8')); + const errors: string[] = []; + const t = createTranslator({ + locale, + messages, + onError: (error) => { errors.push(`${locale}: ${error.message.slice(0, 160)}`); }, + }); + return { labels: shellLabels(t as unknown as Parameters[0]), errors }; +} + +describe('the labels the desktop shell gets', () => { + it('finds every language', () => { + expect(LOCALES.length).toBeGreaterThanOrEqual(30); + }); + + it.each(LOCALES)('%s: every string formats, with no value missing', (locale) => { + const { labels, errors } = labelsFor(locale); + expect(errors).toEqual([]); + for (const [name, label] of Object.entries(labels)) { + expect(label, name).not.toMatch(/^[\w-]+(\.[\w-]+)+$/); + expect(label.trim(), name).not.toBe(''); + } + }); + + it.each(LOCALES)('%s: leaves the shell its placeholders to fill in', (locale) => { + const { labels } = labelsFor(locale); + expect(labels.contextAddToDictionary).toContain('{word}'); + expect(labels.unsentMany).toContain('{count}'); + }); +}); diff --git a/lib/desktop/languages.ts b/lib/desktop/languages.ts index 5c054dc28..f8c4da69b 100644 --- a/lib/desktop/languages.ts +++ b/lib/desktop/languages.ts @@ -33,8 +33,12 @@ export function noteComposerLanguage(key: string, language: string | null): void push(); } -/** The shell's text in the interface language, by its keys in src/main/strings.ts. */ -export function shellLabels(t: (key: string) => string): Record { +/** + * The shell's text in the interface language, by its keys in src/main/strings.ts. The + * shell fills in `{word}` and `{count}` itself when it shows them, so they go over as + * they are. Without a value next-intl gives back the key, and that would be the label. + */ +export function shellLabels(t: (key: string, values?: Record) => string): Record { return { menuFile: t('desktopUi.menu.file'), menuEdit: t('desktopUi.menu.edit'), @@ -99,7 +103,7 @@ export function shellLabels(t: (key: string) => string): Record contextCopy: t('desktopUi.menu.copy'), contextPaste: t('desktopUi.menu.paste'), contextSelectAll: t('desktopUi.menu.select_all'), - contextAddToDictionary: t('desktopUi.context.add_to_dictionary'), + contextAddToDictionary: t('desktopUi.context.add_to_dictionary', { word: '{word}' }), contextNoSuggestions: t('desktopUi.context.no_suggestions'), contextOpenLink: t('desktopUi.context.open_link'), contextCopyLink: t('desktopUi.context.copy_link'), @@ -111,7 +115,7 @@ export function shellLabels(t: (key: string) => string): Record closeAnyway: t('desktopUi.closing.close_anyway'), unsentTitle: t('desktopUi.closing.unsent_title'), unsentOne: t('desktopUi.closing.unsent_one'), - unsentMany: t('desktopUi.closing.unsent_many'), + unsentMany: t('desktopUi.closing.unsent_many', { count: '{count}' }), closeNow: t('desktopUi.closing.quit'), leaveTitle: t('desktopUi.closing.leave_title'), leaveDetail: t('desktopUi.closing.leave_detail'),