-
-
Notifications
You must be signed in to change notification settings - Fork 84
[codex] bundle only english locale in the frontend #1908
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| export const languages = { | ||
| 'de': 'Deutsch', | ||
| 'en': 'English', | ||
| 'es': 'Español', | ||
| 'id': 'Bahasa Indonesia', | ||
| 'it': 'Italiano', | ||
| 'fr': 'Français', | ||
| 'ja': '日本語', | ||
| 'ko': '한국어', | ||
| 'pl': 'Polski', | ||
| 'pt-br': 'Português (Brasil)', | ||
| 'ru': 'Русский', | ||
| 'tr': 'Türkçe', | ||
| 'vi': 'Tiếng Việt', | ||
| 'zh-cn': '简体中文', | ||
| 'hi': 'हिन्दी', | ||
| } as const | ||
|
|
||
| export type SupportedLocale = keyof typeof languages | ||
|
|
||
| export const defaultLocale: SupportedLocale = 'en' | ||
| export const supportedLocales = Object.keys(languages) as SupportedLocale[] | ||
|
|
||
| export function isSupportedLocale(locale: string): locale is SupportedLocale { | ||
| return locale in languages | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| export function resolveLocale(locale?: string | null): SupportedLocale | null { | ||
| if (!locale) | ||
| return null | ||
|
|
||
| const normalizedLocale = locale.toLowerCase() | ||
| if (isSupportedLocale(normalizedLocale)) | ||
| return normalizedLocale | ||
|
|
||
| const baseLocale = normalizedLocale.split('-')[0] | ||
| if (isSupportedLocale(baseLocale)) | ||
| return baseLocale | ||
|
|
||
| return null | ||
| } | ||
|
|
||
| export function normalizeLocale(locale?: string | null): SupportedLocale { | ||
| return resolveLocale(locale) ?? defaultLocale | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,43 +1,26 @@ | ||
| import type { Locale } from 'vue-i18n' | ||
| import type { SupportedLocale } from '../constants/locales' | ||
| import type { UserModule } from '~/types' | ||
| import { createI18n } from 'vue-i18n' | ||
| import { defaultApiHost } from '~/services/supabase' | ||
| import enMessages from '../../messages/en.json' | ||
| import { defaultLocale, languages, normalizeLocale, supportedLocales } from '../constants/locales' | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| // Import i18n resources | ||
| // https://vitejs.dev/guide/features.html#glob-import | ||
| // | ||
| // Don't need this? Try vitesse-lite: https://github.com/antfu/vitesse-lite | ||
| export const i18n = createI18n({ | ||
| legacy: false, | ||
| fallbackLocale: 'en', | ||
| locale: '', | ||
| messages: {}, | ||
| fallbackLocale: defaultLocale, | ||
| locale: defaultLocale, | ||
| messages: { | ||
| [defaultLocale]: enMessages, | ||
| }, | ||
| }) | ||
|
|
||
| const localesMap = Object.fromEntries( | ||
| Object.entries(import.meta.glob('../../messages/*.json')) | ||
| .map(([path, loadLocale]) => [/([\w-]*)\.json$/.exec(path)?.[1], loadLocale]), | ||
| ) as Record<Locale, () => Promise<{ default: Record<string, string> }>> | ||
| type MessageDictionary = Record<string, string> | ||
|
|
||
| export const availableLocales = Object.keys(localesMap) | ||
| export const languages = { | ||
| 'de': 'Deutsch', | ||
| 'en': 'English', | ||
| 'es': 'Español', | ||
| 'id': 'Bahasa Indonesia', | ||
| 'it': 'Italiano', | ||
| 'fr': 'Français', | ||
| 'ja': '日本語', | ||
| 'ko': '한국어', | ||
| 'pl': 'Polski', | ||
| 'pt-br': 'Português (Brasil)', | ||
| 'ru': 'Русский', | ||
| 'tr': 'Türkçe', | ||
| 'vi': 'Tiếng Việt', | ||
| 'zh-cn': '简体中文', | ||
| 'hi': 'हिन्दी', | ||
| } | ||
| export const availableLocales = supportedLocales | ||
|
Check warning on line 20 in src/modules/i18n.ts
|
||
| export { languages } | ||
|
Check warning on line 21 in src/modules/i18n.ts
|
||
|
|
||
| const loadedLanguages: string[] = [] | ||
| const loadedLanguages: SupportedLocale[] = [defaultLocale] | ||
|
|
||
| function setI18nLanguage(lang: Locale) { | ||
| i18n.global.locale.value = lang as any | ||
|
|
@@ -47,26 +30,49 @@ | |
| return lang | ||
| } | ||
|
|
||
| async function loadLocaleMessages(locale: SupportedLocale): Promise<MessageDictionary> { | ||
| if (locale === defaultLocale) | ||
| return enMessages | ||
|
|
||
| const response = await fetch(`${defaultApiHost}/translations/${locale}`, { | ||
| headers: { | ||
| Accept: 'application/json', | ||
| }, | ||
| }) | ||
|
|
||
| if (!response.ok) | ||
| throw new Error(`Failed to load locale "${locale}" (HTTP ${response.status})`) | ||
|
coderabbitai[bot] marked this conversation as resolved.
Outdated
|
||
|
|
||
| return await response.json() as MessageDictionary | ||
| } | ||
|
|
||
| export async function loadLanguageAsync(lang: string): Promise<Locale> { | ||
| const locale = normalizeLocale(lang) | ||
|
|
||
| // If the same language | ||
| if (i18n.global.locale.value === lang) | ||
| return setI18nLanguage(lang) | ||
| if (i18n.global.locale.value === locale) | ||
| return setI18nLanguage(locale) | ||
|
|
||
| // If the language was already loaded | ||
| if (loadedLanguages.includes(lang)) | ||
| return setI18nLanguage(lang) | ||
| if (loadedLanguages.includes(locale)) | ||
| return setI18nLanguage(locale) | ||
|
|
||
| const fallbackLocale = normalizeLocale(i18n.global.locale.value) | ||
|
|
||
| // If the language hasn't been loaded yet | ||
| const messages = await localesMap[lang]() | ||
| i18n.global.setLocaleMessage(lang, messages.default) | ||
| loadedLanguages.push(lang) | ||
| return setI18nLanguage(lang) | ||
| try { | ||
| const messages = await loadLocaleMessages(locale) | ||
| i18n.global.setLocaleMessage(locale, messages) | ||
| loadedLanguages.push(locale) | ||
| return setI18nLanguage(locale) | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| } | ||
| catch (error) { | ||
| console.error('Failed to load locale messages', { locale, error }) | ||
| return setI18nLanguage(fallbackLocale) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When loading a non-English locale fails, this path calls Useful? React with 👍 / 👎. |
||
| } | ||
| } | ||
|
|
||
| export const install: UserModule = ({ app }) => { | ||
| app.use(i18n) | ||
| let lang = localStorage.getItem('lang') ?? window.navigator.language.split('-')[0] | ||
| if (!(lang in languages)) | ||
| lang = 'en' | ||
| const lang = normalizeLocale(localStorage.getItem('lang') ?? window.navigator.language) | ||
| loadLanguageAsync(lang) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| import { resolveLocale } from '../../../../../src/constants/locales.ts' | ||
| import { honoFactory, quickError, useCors } from '../../utils/hono.ts' | ||
| import { getLocaleMessages } from './messages.ts' | ||
|
|
||
| export const app = honoFactory.createApp() | ||
|
|
||
| app.use('*', useCors) | ||
|
|
||
| app.get('/:locale', (c) => { | ||
| const locale = resolveLocale(c.req.param('locale')) | ||
| if (!locale) | ||
| return quickError(404, 'unsupported_locale', 'Unsupported locale') | ||
|
|
||
| c.header('Cache-Control', 'public, max-age=300, s-maxage=86400') | ||
| c.header('Content-Language', locale) | ||
| return c.json(getLocaleMessages(locale)) | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,40 @@ | ||
| import type { SupportedLocale } from '../../../../../src/constants/locales.ts' | ||
| import deMessages from '../../../../../messages/de.json' with { type: 'json' } | ||
| import enMessages from '../../../../../messages/en.json' with { type: 'json' } | ||
| import esMessages from '../../../../../messages/es.json' with { type: 'json' } | ||
| import frMessages from '../../../../../messages/fr.json' with { type: 'json' } | ||
| import hiMessages from '../../../../../messages/hi.json' with { type: 'json' } | ||
| import idMessages from '../../../../../messages/id.json' with { type: 'json' } | ||
| import itMessages from '../../../../../messages/it.json' with { type: 'json' } | ||
| import jaMessages from '../../../../../messages/ja.json' with { type: 'json' } | ||
| import koMessages from '../../../../../messages/ko.json' with { type: 'json' } | ||
| import plMessages from '../../../../../messages/pl.json' with { type: 'json' } | ||
| import ptBrMessages from '../../../../../messages/pt-br.json' with { type: 'json' } | ||
| import ruMessages from '../../../../../messages/ru.json' with { type: 'json' } | ||
| import trMessages from '../../../../../messages/tr.json' with { type: 'json' } | ||
| import viMessages from '../../../../../messages/vi.json' with { type: 'json' } | ||
| import zhCnMessages from '../../../../../messages/zh-cn.json' with { type: 'json' } | ||
|
|
||
| type MessageDictionary = Record<string, string> | ||
|
|
||
| const localeMessages: Record<SupportedLocale, MessageDictionary> = { | ||
| 'de': deMessages, | ||
| 'en': enMessages, | ||
| 'es': esMessages, | ||
| 'fr': frMessages, | ||
| 'hi': hiMessages, | ||
| 'id': idMessages, | ||
| 'it': itMessages, | ||
| 'ja': jaMessages, | ||
| 'ko': koMessages, | ||
| 'pl': plMessages, | ||
| 'pt-br': ptBrMessages, | ||
| 'ru': ruMessages, | ||
| 'tr': trMessages, | ||
| 'vi': viMessages, | ||
| 'zh-cn': zhCnMessages, | ||
| } | ||
|
|
||
| export function getLocaleMessages(locale: SupportedLocale): MessageDictionary { | ||
| return localeMessages[locale] | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| import { app } from '../_backend/public/translations/index.ts' | ||
| import { createAllCatch, createHono } from '../_backend/utils/hono.ts' | ||
| import { version } from '../_backend/utils/version.ts' | ||
|
|
||
| const functionName = 'translations' | ||
| const appGlobal = createHono(functionName, version) | ||
|
|
||
| appGlobal.route('/', app) | ||
|
Comment on lines
+5
to
+8
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This new function is called by the frontend as an unauthenticated endpoint ( Useful? React with 👍 / 👎. |
||
| createAllCatch(appGlobal, functionName) | ||
|
|
||
| Deno.serve(appGlobal.fetch) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,59 @@ | ||
| import { beforeEach, describe, expect, it, vi } from 'vitest' | ||
|
|
||
| vi.mock('~/services/supabase', () => ({ | ||
| defaultApiHost: 'https://api.capgo.test', | ||
| })) | ||
|
|
||
| function createStorageMock() { | ||
| const store = new Map<string, string>() | ||
|
|
||
| return { | ||
| getItem: vi.fn((key: string) => store.get(key) ?? null), | ||
| setItem: vi.fn((key: string, value: string) => { | ||
| store.set(key, value) | ||
| }), | ||
| removeItem: vi.fn((key: string) => { | ||
| store.delete(key) | ||
| }), | ||
| } | ||
| } | ||
|
|
||
| describe('i18n locale loader', () => { | ||
| beforeEach(() => { | ||
| vi.clearAllMocks() | ||
| vi.resetModules() | ||
| vi.unstubAllGlobals() | ||
|
|
||
| vi.stubGlobal('fetch', vi.fn()) | ||
| vi.stubGlobal('localStorage', createStorageMock()) | ||
| }) | ||
|
|
||
| it('keeps English in the frontend bundle without fetching it from the backend', async () => { | ||
| const { i18n, loadLanguageAsync } = await import('../src/modules/i18n.ts') | ||
|
|
||
| await loadLanguageAsync('en') | ||
|
|
||
| expect(fetch).not.toHaveBeenCalled() | ||
| expect(i18n.global.locale.value).toBe('en') | ||
| expect(i18n.global.t('accept-invitation')).toBe('Accept Invitation') | ||
| }) | ||
|
|
||
| it('falls back to the current locale when backend locale loading fails', async () => { | ||
| vi.mocked(fetch).mockResolvedValue({ | ||
| ok: false, | ||
| status: 500, | ||
| } as Response) | ||
|
|
||
| const { i18n, loadLanguageAsync } = await import('../src/modules/i18n.ts') | ||
|
|
||
| await loadLanguageAsync('fr') | ||
|
|
||
| expect(fetch).toHaveBeenCalledWith('https://api.capgo.test/translations/fr', { | ||
| headers: { | ||
| Accept: 'application/json', | ||
| }, | ||
| }) | ||
| expect(i18n.global.locale.value).toBe('en') | ||
| expect(i18n.global.t('accept-invitation')).toBe('Accept Invitation') | ||
| }) | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,41 @@ | ||
| import { describe, expect, it } from 'vitest' | ||
|
|
||
| describe('[GET] /translations/:locale', () => { | ||
| it.concurrent('returns the requested locale payload with cache headers', async () => { | ||
| const { app: translations } = await import('../supabase/functions/_backend/public/translations/index.ts') | ||
| const { createAllCatch, createHono } = await import('../supabase/functions/_backend/utils/hono.ts') | ||
| const { version } = await import('../supabase/functions/_backend/utils/version.ts') | ||
|
|
||
| const app = createHono('translations', version) | ||
| app.route('/', translations) | ||
| createAllCatch(app, 'translations') | ||
|
|
||
| const response = await app.fetch(new Request('http://localhost/fr')) | ||
|
|
||
| expect(response.status).toBe(200) | ||
| expect(response.headers.get('access-control-allow-origin')).toBe('*') | ||
| expect(response.headers.get('cache-control')).toBe('public, max-age=300, s-maxage=86400') | ||
| expect(response.headers.get('content-language')).toBe('fr') | ||
|
|
||
| const data = await response.json() as Record<string, string> | ||
| expect(data['accept-invitation']).toBe('Accepter l\'invitation') | ||
| }) | ||
|
|
||
| it.concurrent('rejects unsupported locales', async () => { | ||
| const { app: translations } = await import('../supabase/functions/_backend/public/translations/index.ts') | ||
| const { createAllCatch, createHono } = await import('../supabase/functions/_backend/utils/hono.ts') | ||
| const { version } = await import('../supabase/functions/_backend/utils/version.ts') | ||
|
|
||
| const app = createHono('translations', version) | ||
| app.route('/', translations) | ||
| createAllCatch(app, 'translations') | ||
|
|
||
| const response = await app.fetch(new Request('http://localhost/klingon')) | ||
|
|
||
| expect(response.status).toBe(404) | ||
| await expect(response.json()).resolves.toMatchObject({ | ||
| error: 'unsupported_locale', | ||
| message: 'Unsupported locale', | ||
| }) | ||
| }) | ||
| }) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
isSupportedLocaleuseslocale in languages, which also matches inherited keys likeconstructorand__proto__. Since/translations/:localedepends onresolveLocale, those values are currently accepted as supported locales and skip the 404 path, so the API can return a 200 response with an invalid/non-translation payload that may then be cached. Use an own-key check (for exampleObject.hasOwn(languages, locale)) to enforce the declared locale list only.Useful? React with 👍 / 👎.