Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions cloudflare_workers/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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'
Expand Down
45 changes: 45 additions & 0 deletions src/constants/locales.ts
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

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Object.hasOwn()' instead of 'Object.prototype.hasOwnProperty.call()'.

See more on https://sonarcloud.io/project/issues?id=Cap-go_capgo&issues=AZ2HThCGkQ3mPQF7_wJW&open=AZ2HThCGkQ3mPQF7_wJW&pullRequest=1908
}

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
}
105 changes: 61 additions & 44 deletions src/modules/i18n.ts
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

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use `export…from` to re-export `availableLocales`.

See more on https://sonarcloud.io/project/issues?id=Cap-go_capgo&issues=AZ2G8mcI97SflOJfLxkl&open=AZ2G8mcI97SflOJfLxkl&pullRequest=1908
export { languages }

Check warning on line 21 in src/modules/i18n.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use `export…from` to re-export `languages`.

See more on https://sonarcloud.io/project/issues?id=Cap-go_capgo&issues=AZ2G8mcI97SflOJfLxkm&open=AZ2G8mcI97SflOJfLxkm&pullRequest=1908

const loadedLanguages: string[] = []
const loadedLanguages: SupportedLocale[] = [defaultLocale]
let latestLocaleRequestId = 0

function setI18nLanguage(lang: Locale) {
i18n.global.locale.value = lang as any
Expand All @@ -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)
}
catch (error) {
console.error('Failed to load locale messages', { locale, error })
if (requestId !== latestLocaleRequestId)
return i18n.global.locale.value
return setI18nLanguage(fallbackLocale)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid overwriting locale preference on fetch failure

When loading a non-English locale fails, this path calls setI18nLanguage(fallbackLocale), and setI18nLanguage persists that fallback to localStorage. On first load, fallbackLocale is typically en, so a transient /translations/:locale outage permanently replaces the user's saved language with English. This regression means affected users will stay on English even after the backend recovers unless they manually reselect their language.

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)

Check warning on line 87 in src/modules/i18n.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `globalThis` over `window`.

See more on https://sonarcloud.io/project/issues?id=Cap-go_capgo&issues=AZ2G8mcI97SflOJfLxkn&open=AZ2G8mcI97SflOJfLxkn&pullRequest=1908
loadLanguageAsync(lang)
}
26 changes: 26 additions & 0 deletions supabase/functions/_backend/public/translations/index.ts
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))
})
36 changes: 36 additions & 0 deletions supabase/functions/_backend/public/translations/messages.ts
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
}
11 changes: 11 additions & 0 deletions supabase/functions/translations/index.ts
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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Configure translations function as public

This new function is called by the frontend as an unauthenticated endpoint (/translations/:locale), but the commit does not add a matching [functions.translations] block in supabase/config.toml like other public functions. In Supabase Edge Functions, missing per-function config falls back to JWT verification being enabled, so environments that hit /functions/v1/translations/... directly will return 401 and non-English locales will never load. Please add the function config with verify_jwt = false (and the standard import_map) to keep behavior consistent with other public endpoints.

Useful? React with 👍 / 👎.

createAllCatch(appGlobal, functionName)

Deno.serve(appGlobal.fetch)
106 changes: 106 additions & 0 deletions tests/i18n-loader.unit.test.ts
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')
})
})
Loading
Loading