-
-
Notifications
You must be signed in to change notification settings - Fork 82
[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
base: main
Are you sure you want to change the base?
Changes from all commits
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 Object.prototype.hasOwnProperty.call(languages, locale) | ||
|
Check warning on line 25 in src/constants/locales.ts
|
||
| } | ||
|
|
||
| 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,27 @@ | ||
| 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' | ||
|
|
||
| // 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> }>> | ||
|
|
||
| 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': 'हिन्दी', | ||
| } | ||
| type MessageDictionary = Record<string, string> | ||
|
|
||
| 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] | ||
| let latestLocaleRequestId = 0 | ||
|
|
||
| function setI18nLanguage(lang: Locale) { | ||
| i18n.global.locale.value = lang as any | ||
|
|
@@ -47,26 +31,59 @@ | |
| return lang | ||
| } | ||
|
|
||
| async function loadLocaleMessages(locale: SupportedLocale): Promise<MessageDictionary> { | ||
| if (locale === defaultLocale) | ||
| return enMessages | ||
|
|
||
| const translationBaseUrl = defaultApiHost?.trim() | ||
| if (!translationBaseUrl) | ||
| throw new Error('VITE_API_HOST is not configured') | ||
|
|
||
| const response = await fetch(`${translationBaseUrl.replace(/\/$/, '')}/translations/${locale}`, { | ||
| headers: { | ||
| Accept: 'application/json', | ||
| }, | ||
| }) | ||
|
|
||
| if (!response.ok) | ||
| throw new Error(`Failed to load locale "${locale}" (HTTP ${response.status})`) | ||
|
|
||
| return await response.json() as MessageDictionary | ||
| } | ||
|
|
||
| export async function loadLanguageAsync(lang: string): Promise<Locale> { | ||
| const locale = normalizeLocale(lang) | ||
| const requestId = ++latestLocaleRequestId | ||
|
|
||
| // If the same language | ||
| if (i18n.global.locale.value === lang) | ||
| return setI18nLanguage(lang) | ||
| if (i18n.global.locale.value === locale) | ||
| return requestId === latestLocaleRequestId ? setI18nLanguage(locale) : i18n.global.locale.value | ||
|
|
||
| // If the language was already loaded | ||
| if (loadedLanguages.includes(lang)) | ||
| return setI18nLanguage(lang) | ||
|
|
||
| // 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) | ||
| if (loadedLanguages.includes(locale)) | ||
| return requestId === latestLocaleRequestId ? setI18nLanguage(locale) : i18n.global.locale.value | ||
|
|
||
| const fallbackLocale = normalizeLocale(i18n.global.locale.value) | ||
|
|
||
| try { | ||
| const messages = await loadLocaleMessages(locale) | ||
| i18n.global.setLocaleMessage(locale, messages) | ||
| if (!loadedLanguages.includes(locale)) | ||
| loadedLanguages.push(locale) | ||
| if (requestId !== latestLocaleRequestId) | ||
| return i18n.global.locale.value | ||
| return setI18nLanguage(locale) | ||
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
| catch (error) { | ||
| console.error('Failed to load locale messages', { locale, error }) | ||
| if (requestId !== latestLocaleRequestId) | ||
| return i18n.global.locale.value | ||
| 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,26 @@ | ||
| import { resolveLocale } from '../../../../../src/constants/locales.ts' | ||
| import { honoFactory, quickError, useCors } from '../../utils/hono.ts' | ||
| import { cloudlog } from '../../utils/logging.ts' | ||
| import { getLocaleMessages } from './messages.ts' | ||
|
|
||
| export const app = honoFactory.createApp() | ||
|
|
||
| app.use('*', useCors) | ||
|
|
||
| app.get('/:locale', async (c) => { | ||
| const requestId = c.get('requestId') | ||
| const requestedLocale = c.req.param('locale') | ||
| const locale = resolveLocale(requestedLocale) | ||
|
|
||
| cloudlog({ requestId, message: 'translations request', requestedLocale, locale }) | ||
|
|
||
| if (!locale) { | ||
| cloudlog({ requestId, message: 'translations unsupported locale', requestedLocale }) | ||
| return quickError(404, 'unsupported_locale', 'Unsupported locale') | ||
| } | ||
|
|
||
| c.header('Cache-Control', 'public, max-age=300, s-maxage=86400') | ||
| c.header('Content-Language', locale) | ||
| cloudlog({ requestId, message: 'translations response', locale }) | ||
| return c.json(await getLocaleMessages(locale)) | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,36 @@ | ||
| import type { SupportedLocale } from '../../../../../src/constants/locales.ts' | ||
|
|
||
| type MessageDictionary = Record<string, string> | ||
| interface MessageModule { | ||
| default: MessageDictionary | ||
| } | ||
|
|
||
| const localeMessages = new Map<SupportedLocale, MessageDictionary>() | ||
|
|
||
| const messageLoaders: Record<SupportedLocale, () => Promise<MessageModule>> = { | ||
| 'de': () => import('../../../../../messages/de.json', { with: { type: 'json' } }), | ||
| 'en': () => import('../../../../../messages/en.json', { with: { type: 'json' } }), | ||
| 'es': () => import('../../../../../messages/es.json', { with: { type: 'json' } }), | ||
| 'fr': () => import('../../../../../messages/fr.json', { with: { type: 'json' } }), | ||
| 'hi': () => import('../../../../../messages/hi.json', { with: { type: 'json' } }), | ||
| 'id': () => import('../../../../../messages/id.json', { with: { type: 'json' } }), | ||
| 'it': () => import('../../../../../messages/it.json', { with: { type: 'json' } }), | ||
| 'ja': () => import('../../../../../messages/ja.json', { with: { type: 'json' } }), | ||
| 'ko': () => import('../../../../../messages/ko.json', { with: { type: 'json' } }), | ||
| 'pl': () => import('../../../../../messages/pl.json', { with: { type: 'json' } }), | ||
| 'pt-br': () => import('../../../../../messages/pt-br.json', { with: { type: 'json' } }), | ||
| 'ru': () => import('../../../../../messages/ru.json', { with: { type: 'json' } }), | ||
| 'tr': () => import('../../../../../messages/tr.json', { with: { type: 'json' } }), | ||
| 'vi': () => import('../../../../../messages/vi.json', { with: { type: 'json' } }), | ||
| 'zh-cn': () => import('../../../../../messages/zh-cn.json', { with: { type: 'json' } }), | ||
| } | ||
|
|
||
| export async function getLocaleMessages(locale: SupportedLocale): Promise<MessageDictionary> { | ||
| const cachedMessages = localeMessages.get(locale) | ||
| if (cachedMessages) | ||
| return cachedMessages | ||
|
|
||
| const { default: messages } = await messageLoaders[locale]() | ||
| localeMessages.set(locale, messages) | ||
| return messages | ||
| } |
| 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,106 @@ | ||
| 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') | ||
| }) | ||
|
|
||
| it('keeps the latest locale selection when async fetches resolve out of order', async () => { | ||
| let resolveFrench: ((value: Response) => void) | undefined | ||
| let resolveGerman: ((value: Response) => void) | undefined | ||
|
|
||
| vi.mocked(fetch).mockImplementation((url) => { | ||
| const requestUrl = String(url) | ||
|
|
||
| if (requestUrl.endsWith('/translations/fr')) { | ||
| return new Promise((resolve) => { | ||
| resolveFrench = resolve | ||
| }) | ||
| } | ||
|
|
||
| if (requestUrl.endsWith('/translations/de')) { | ||
| return new Promise((resolve) => { | ||
| resolveGerman = resolve | ||
| }) | ||
| } | ||
|
|
||
| throw new Error(`Unexpected locale request: ${requestUrl}`) | ||
| }) | ||
|
|
||
| const { i18n, loadLanguageAsync } = await import('../src/modules/i18n.ts') | ||
|
|
||
| const frenchLoad = loadLanguageAsync('fr') | ||
| const germanLoad = loadLanguageAsync('de') | ||
|
|
||
| resolveGerman?.({ | ||
| ok: true, | ||
| json: vi.fn().mockResolvedValue({ | ||
| 'accept-invitation': 'Einladung annehmen', | ||
| }), | ||
| } as unknown as Response) | ||
| await germanLoad | ||
|
|
||
| resolveFrench?.({ | ||
| ok: true, | ||
| json: vi.fn().mockResolvedValue({ | ||
| 'accept-invitation': 'Accepter l\'invitation', | ||
| }), | ||
| } as unknown as Response) | ||
| await frenchLoad | ||
|
|
||
| expect(i18n.global.locale.value).toBe('de') | ||
| expect(i18n.global.t('accept-invitation')).toBe('Einladung annehmen') | ||
| }) | ||
| }) |
Uh oh!
There was an error while loading. Please reload this page.