From 9f5a3f1e84b6cb6f0144eec1b0a3b248784a6da1 Mon Sep 17 00:00:00 2001 From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Thu, 29 Aug 2024 14:41:39 +0200 Subject: [PATCH] chore(web): enforce valid translation keys using typescript (#12106) --- web/src/app.d.ts | 28 +++++++++++++++++ .../i18n/__test__/format-message.spec.ts | 24 +++++++------- .../i18n/__test__/format-tag-b.svelte | 3 +- .../i18n/format-bold-message.svelte | 3 +- .../lib/components/i18n/format-message.svelte | 4 +-- web/src/lib/i18n.spec.ts | 31 ------------------- web/src/routes/+page.ts | 4 +-- 7 files changed, 48 insertions(+), 49 deletions(-) diff --git a/web/src/app.d.ts b/web/src/app.d.ts index 4fcb901892..b13a0c97d5 100644 --- a/web/src/app.d.ts +++ b/web/src/app.d.ts @@ -27,3 +27,31 @@ interface Element { // Make optional, because it's unavailable on iPhones. requestFullscreen?(options?: FullscreenOptions): Promise<void>; } + +import type en from '$lib/i18n/en.json'; +import 'svelte-i18n'; + +type NestedKeys<T, K = keyof T> = K extends keyof T & string + ? `${K}` | (T[K] extends object ? `${K}.${NestedKeys<T[K]>}` : never) + : never; + +declare module 'svelte-i18n' { + import type { InterpolationValues } from '$lib/components/i18n/format-message.svelte'; + import type { Readable } from 'svelte/store'; + + type Translations = NestedKeys<typeof en>; + + interface MessageObject { + id: Translations; + locale?: string; + format?: string; + default?: string; + values?: InterpolationValues; + } + + type MessageFormatter = (id: Translations | MessageObject, options?: Omit<MessageObject, 'id'>) => string; + + const format: Readable<MessageFormatter>; + const t: Readable<MessageFormatter>; + const _: Readable<MessageFormatter>; +} diff --git a/web/src/lib/components/i18n/__test__/format-message.spec.ts b/web/src/lib/components/i18n/__test__/format-message.spec.ts index 589d9024e7..52eb77c80b 100644 --- a/web/src/lib/components/i18n/__test__/format-message.spec.ts +++ b/web/src/lib/components/i18n/__test__/format-message.spec.ts @@ -2,7 +2,7 @@ import FormatTagB from '$lib/components/i18n/__test__/format-tag-b.svelte'; import FormatMessage from '$lib/components/i18n/format-message.svelte'; import '@testing-library/jest-dom'; import { render, screen } from '@testing-library/svelte'; -import { init, locale, register, waitLocale } from 'svelte-i18n'; +import { init, locale, register, waitLocale, type Translations } from 'svelte-i18n'; import { describe } from 'vitest'; describe('FormatMessage component', () => { @@ -25,7 +25,7 @@ describe('FormatMessage component', () => { it('formats a plain text message', () => { render(FormatMessage, { - key: 'hello', + key: 'hello' as Translations, values: { name: 'test' }, }); expect(screen.getByText('Hello test')).toBeInTheDocument(); @@ -33,20 +33,20 @@ describe('FormatMessage component', () => { it('throws an error when locale is empty', async () => { await locale.set(undefined); - expect(() => render(FormatMessage, { key: '' })).toThrowError(); + expect(() => render(FormatMessage, { key: '' as Translations })).toThrowError(); await locale.set('en'); }); it('shows raw message when value is empty', () => { render(FormatMessage, { - key: 'hello', + key: 'hello' as Translations, }); expect(screen.getByText('Hello {name}')).toBeInTheDocument(); }); it('shows message when slot is empty', () => { render(FormatMessage, { - key: 'html', + key: 'html' as Translations, values: { name: 'test' }, }); expect(screen.getByText('Hello test')).toBeInTheDocument(); @@ -54,7 +54,7 @@ describe('FormatMessage component', () => { it('renders a message with html', () => { const { container } = render(FormatTagB, { - key: 'html', + key: 'html' as Translations, values: { name: 'test' }, }); expect(container.innerHTML).toBe('Hello <strong>test</strong>'); @@ -62,7 +62,7 @@ describe('FormatMessage component', () => { it('renders a message with html and plural', () => { const { container } = render(FormatTagB, { - key: 'plural', + key: 'plural' as Translations, values: { count: 1 }, }); expect(container.innerHTML).toBe('You have <strong>1 item</strong>'); @@ -70,19 +70,19 @@ describe('FormatMessage component', () => { it('protects agains XSS injection', () => { render(FormatMessage, { - key: 'xss', + key: 'xss' as Translations, }); expect(screen.getByText('<image/src/onerror=prompt(8)>')).toBeInTheDocument(); }); it('displays the message key when not found', () => { - render(FormatMessage, { key: 'invalid.key' }); + render(FormatMessage, { key: 'invalid.key' as Translations }); expect(screen.getByText('invalid.key')).toBeInTheDocument(); }); it('supports html tags inside plurals', () => { const { container } = render(FormatTagB, { - key: 'plural_with_html', + key: 'plural_with_html' as Translations, values: { count: 10 }, }); expect(container.innerHTML).toBe('You have <strong>10</strong> items'); @@ -90,7 +90,7 @@ describe('FormatMessage component', () => { it('supports html tags inside select', () => { const { container } = render(FormatTagB, { - key: 'select_with_html', + key: 'select_with_html' as Translations, values: { status: true }, }); expect(container.innerHTML).toBe('Item is <strong>disabled</strong>'); @@ -98,7 +98,7 @@ describe('FormatMessage component', () => { it('supports html tags inside selectordinal', () => { const { container } = render(FormatTagB, { - key: 'ordinal_with_html', + key: 'ordinal_with_html' as Translations, values: { count: 4 }, }); expect(container.innerHTML).toBe('<strong>4th</strong> item'); diff --git a/web/src/lib/components/i18n/__test__/format-tag-b.svelte b/web/src/lib/components/i18n/__test__/format-tag-b.svelte index f06a54a1e0..122358c6b7 100644 --- a/web/src/lib/components/i18n/__test__/format-tag-b.svelte +++ b/web/src/lib/components/i18n/__test__/format-tag-b.svelte @@ -1,8 +1,9 @@ <script lang="ts"> + import type { Translations } from 'svelte-i18n'; import FormatMessage from '../format-message.svelte'; import type { ComponentProps } from 'svelte'; - export let key: string; + export let key: Translations; export let values: ComponentProps<FormatMessage>['values']; </script> diff --git a/web/src/lib/components/i18n/format-bold-message.svelte b/web/src/lib/components/i18n/format-bold-message.svelte index 6a449e8808..052b220edc 100644 --- a/web/src/lib/components/i18n/format-bold-message.svelte +++ b/web/src/lib/components/i18n/format-bold-message.svelte @@ -1,8 +1,9 @@ <script lang="ts"> import FormatMessage from '$lib/components/i18n/format-message.svelte'; import type { InterpolationValues } from '$lib/components/i18n/format-message.svelte'; + import type { Translations } from 'svelte-i18n'; - export let key: string; + export let key: Translations; export let values: InterpolationValues = {}; </script> diff --git a/web/src/lib/components/i18n/format-message.svelte b/web/src/lib/components/i18n/format-message.svelte index d6ff09ed1c..48c59478c6 100644 --- a/web/src/lib/components/i18n/format-message.svelte +++ b/web/src/lib/components/i18n/format-message.svelte @@ -11,14 +11,14 @@ type PluralElement, type SelectElement, } from '@formatjs/icu-messageformat-parser'; - import { locale as i18nLocale, json } from 'svelte-i18n'; + import { locale as i18nLocale, json, type Translations } from 'svelte-i18n'; type MessagePart = { message: string; tag?: string; }; - export let key: string; + export let key: Translations; export let values: InterpolationValues = {}; const getLocale = (locale?: string | null) => { diff --git a/web/src/lib/i18n.spec.ts b/web/src/lib/i18n.spec.ts index c9261dcec5..13d926e647 100644 --- a/web/src/lib/i18n.spec.ts +++ b/web/src/lib/i18n.spec.ts @@ -1,39 +1,8 @@ import { langs } from '$lib/constants'; -import messages from '$lib/i18n/en.json'; import { getClosestAvailableLocale } from '$lib/utils/i18n'; -import { exec as execCallback } from 'node:child_process'; import { readFileSync, readdirSync } from 'node:fs'; -import { promisify } from 'node:util'; - -type Messages = { [key: string]: string | Messages }; - -const exec = promisify(execCallback); - -function setEmptyMessages(messages: Messages) { - const copy = { ...messages }; - - for (const key in copy) { - const message = copy[key]; - if (typeof message === 'string') { - copy[key] = ''; - } else if (typeof message === 'object') { - setEmptyMessages(message); - } - } - - return copy; -} describe('i18n', () => { - test('no missing messages', async () => { - const { stdout } = await exec('npx svelte-i18n extract -c svelte.config.js "src/**/*"'); - const extractedMessages: Messages = JSON.parse(stdout); - const existingMessages = setEmptyMessages(messages); - - // Only translations directly using the store seem to get extracted - expect({ ...extractedMessages, ...existingMessages }).toEqual(existingMessages); - }); - describe('loaders', () => { const languageFiles = readdirSync('src/lib/i18n').sort(); for (const filename of languageFiles) { diff --git a/web/src/routes/+page.ts b/web/src/routes/+page.ts index bcc854cc3c..0f3a7377d2 100644 --- a/web/src/routes/+page.ts +++ b/web/src/routes/+page.ts @@ -12,7 +12,6 @@ export const ssr = false; export const csr = true; export const load = (async ({ fetch }) => { - let $t = (arg: string) => arg; try { await init(fetch); const authenticated = await loadUser(); @@ -26,7 +25,6 @@ export const load = (async ({ fetch }) => { redirect(302, AppRoute.AUTH_LOGIN); } - $t = await getFormatter(); // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (redirectError: any) { if (redirectError?.status === 302) { @@ -34,6 +32,8 @@ export const load = (async ({ fetch }) => { } } + const $t = await getFormatter(); + return { meta: { title: $t('welcome') + ' 🎉',