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') + ' 🎉',