diff --git a/cloudflare_workers/api/index.ts b/cloudflare_workers/api/index.ts index 52e6687ed2..30cdc4d285 100644 --- a/cloudflare_workers/api/index.ts +++ b/cloudflare_workers/api/index.ts @@ -41,6 +41,7 @@ import { app as ok } from '../../supabase/functions/_backend/public/ok.ts' import { app as organization } from '../../supabase/functions/_backend/public/organization/index.ts' import { app as replication } from '../../supabase/functions/_backend/public/replication.ts' import { app as statistics } from '../../supabase/functions/_backend/public/statistics/index.ts' +import { app as translations } from '../../supabase/functions/_backend/public/translations/index.ts' import { app as webhooks } from '../../supabase/functions/_backend/public/webhooks/index.ts' import { app as credit_usage_alerts } from '../../supabase/functions/_backend/triggers/credit_usage_alerts.ts' import { app as cron_clean_orphan_images } from '../../supabase/functions/_backend/triggers/cron_clean_orphan_images.ts' @@ -88,6 +89,7 @@ app.route('/app', appEndpoint) app.route('/build', build) app.route('/replication', replication) app.route('/check_cpu_usage', check_cpu_usage) +app.route('/translations', translations) // Private API const functionNamePrivate = 'private' diff --git a/src/constants/locales.ts b/src/constants/locales.ts new file mode 100644 index 0000000000..c8b34ccdf0 --- /dev/null +++ b/src/constants/locales.ts @@ -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) +} + +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 +} diff --git a/src/modules/i18n.ts b/src/modules/i18n.ts index 46fae89757..eae5a55a53 100644 --- a/src/modules/i18n.ts +++ b/src/modules/i18n.ts @@ -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 Promise<{ default: Record }>> - -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 + +export const availableLocales = supportedLocales +export { languages } -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 @@ function setI18nLanguage(lang: Locale) { return lang } +async function loadLocaleMessages(locale: SupportedLocale): Promise { + 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 { + 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) + } + catch (error) { + console.error('Failed to load locale messages', { locale, error }) + if (requestId !== latestLocaleRequestId) + return i18n.global.locale.value + return setI18nLanguage(fallbackLocale) + } } 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) } diff --git a/supabase/functions/_backend/public/translations/index.ts b/supabase/functions/_backend/public/translations/index.ts new file mode 100644 index 0000000000..f5a2ec8388 --- /dev/null +++ b/supabase/functions/_backend/public/translations/index.ts @@ -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)) +}) diff --git a/supabase/functions/_backend/public/translations/messages.ts b/supabase/functions/_backend/public/translations/messages.ts new file mode 100644 index 0000000000..b37c43e50d --- /dev/null +++ b/supabase/functions/_backend/public/translations/messages.ts @@ -0,0 +1,36 @@ +import type { SupportedLocale } from '../../../../../src/constants/locales.ts' + +type MessageDictionary = Record +interface MessageModule { + default: MessageDictionary +} + +const localeMessages = new Map() + +const messageLoaders: Record Promise> = { + '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 { + const cachedMessages = localeMessages.get(locale) + if (cachedMessages) + return cachedMessages + + const { default: messages } = await messageLoaders[locale]() + localeMessages.set(locale, messages) + return messages +} diff --git a/supabase/functions/translations/index.ts b/supabase/functions/translations/index.ts new file mode 100644 index 0000000000..4ade4a28e7 --- /dev/null +++ b/supabase/functions/translations/index.ts @@ -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) +createAllCatch(appGlobal, functionName) + +Deno.serve(appGlobal.fetch) diff --git a/tests/i18n-loader.unit.test.ts b/tests/i18n-loader.unit.test.ts new file mode 100644 index 0000000000..9bd1560082 --- /dev/null +++ b/tests/i18n-loader.unit.test.ts @@ -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() + + 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') + }) +}) diff --git a/tests/translations.test.ts b/tests/translations.test.ts new file mode 100644 index 0000000000..7b24ba574a --- /dev/null +++ b/tests/translations.test.ts @@ -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 + 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', + }) + }) +})