feat: remove nuxt i18n for in house i18n for web (#5131)

* feat: remove nuxt i18n for in house

* cleanup: remove old nuxt/i18n patch

* prepr

---------

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
This commit is contained in:
Calum H.
2026-01-15 23:49:38 +00:00
committed by GitHub
parent 4497131206
commit a903e46be9
16 changed files with 275 additions and 787 deletions

View File

@@ -8,7 +8,7 @@ import { createPinia } from 'pinia'
import { createApp } from 'vue'
import App from '@/App.vue'
import i18n from '@/i18n.config'
import i18nPlugin from '@/plugins/i18n'
import router from '@/routes'
const vueScan = new VueScanPlugin({
@@ -43,6 +43,6 @@ app.use(FloatingVue, {
},
},
})
app.use(i18n)
app.use(i18nPlugin)
app.mount('#app')

View File

@@ -0,0 +1,23 @@
import { I18N_INJECTION_KEY, type I18nContext } from '@modrinth/ui'
import type { App } from 'vue'
import i18n from '@/i18n.config'
export default {
install(app: App) {
// Install vue-i18n as before
app.use(i18n)
// Wrap it in our I18nContext interface
const context: I18nContext = {
locale: i18n.global.locale,
t: (key, values) => i18n.global.t(key, values ?? {}) as string,
setLocale: (newLocale) => {
i18n.global.locale.value = newLocale
},
}
// Provide the context at app-level
app.provide(I18N_INJECTION_KEY, context)
},
}

View File

@@ -1,10 +0,0 @@
import { createMessageCompiler } from '@modrinth/ui'
export default defineI18nConfig(() => ({
legacy: false,
locale: 'en-US',
fallbackLocale: 'en-US',
messageCompiler: createMessageCompiler(),
missingWarn: false,
fallbackWarn: false,
}))

View File

@@ -1,17 +0,0 @@
import { type CrowdinMessages, transformCrowdinMessages } from '@modrinth/ui'
// eager:false - only loads the locale requested
const localeModules = import.meta.glob<{ default: CrowdinMessages }>(
'../src/locales/*/index.json',
{ eager: false },
)
export default defineI18nLocale(async (locale) => {
const loader = localeModules[`../src/locales/${locale}/index.json`]
if (!loader) {
console.warn(`Locale ${locale} not found`)
return {}
}
const messages = await loader()
return transformCrowdinMessages(messages.default)
})

View File

@@ -1,5 +1,4 @@
import { GenericModrinthClient, type Labrinth } from '@modrinth/api-client'
import { LOCALES } from '@modrinth/ui/src/composables/i18n.ts'
import serverSidedVue from '@vitejs/plugin-vue'
import fs from 'fs/promises'
import { defineNuxtConfig } from 'nuxt/config'
@@ -223,7 +222,6 @@ export default defineNuxtConfig({
},
},
modules: [
'@nuxtjs/i18n',
'@pinia/nuxt',
'floating-vue/nuxt',
// Sentry causes rollup-plugin-inject errors in dev, only enable in production
@@ -243,25 +241,6 @@ export default defineNuxtConfig({
},
},
},
i18n: {
defaultLocale: 'en-US',
lazy: true,
langDir: '.',
locales: LOCALES.map((locale) => ({
...locale,
file: 'locale-loader.ts',
})),
strategy: 'no_prefix',
detectBrowserLanguage: {
useCookie: true,
cookieKey: 'locale',
fallbackLocale: 'en-US',
},
vueI18n: './i18n.config.ts',
bundle: {
optimizeTranslationDirective: false,
},
},
nitro: {
rollupConfig: {
// @ts-expect-error because of rolldown-vite - completely fine though

View File

@@ -18,7 +18,6 @@
"devDependencies": {
"@formatjs/cli": "^6.2.12",
"@modrinth/tooling-config": "workspace:*",
"@nuxtjs/i18n": "^9.0.0",
"@types/dompurify": "^3.0.5",
"@types/iso-3166-2": "^1.0.4",
"@types/js-yaml": "^4.0.9",
@@ -66,6 +65,7 @@
"iso-3166-2": "1.0.0",
"js-yaml": "^4.1.0",
"jszip": "^3.10.1",
"lru-cache": "^11.2.4",
"markdown-it": "14.1.0",
"pathe": "^1.1.2",
"pinia": "^3.0.0",

View File

@@ -2,6 +2,7 @@
import {
Admonition,
commonSettingsMessages,
injectI18n,
IntlFormatted,
LanguageSelector,
languageSelectorMessages,
@@ -10,8 +11,7 @@ import {
} from '@modrinth/ui'
const { formatMessage } = useVIntl()
const { $i18n } = useNuxtApp()
const { locale, setLocale } = $i18n
const { locale, setLocale } = injectI18n()
const platform = formatMessage(languageSelectorMessages.platformSite)

View File

@@ -0,0 +1,112 @@
import {
type CrowdinMessages,
I18N_INJECTION_KEY,
type I18nContext,
LOCALES,
transformCrowdinMessages,
} from '@modrinth/ui'
import IntlMessageFormat from 'intl-messageformat'
import { LRUCache } from 'lru-cache'
const DEFAULT_LOCALE = 'en-US'
const localeModules = import.meta.glob<{ default: CrowdinMessages }>('../locales/*/index.json', {
eager: false,
})
const messageCache = new LRUCache<string, Record<string, string>>({ max: 10 })
const formatterCache = new LRUCache<string, IntlMessageFormat>({ max: 1000 })
const loadingPromises = new Map<string, Promise<void>>() // Dedupe concurrent loads
async function loadLocale(code: string): Promise<void> {
if (messageCache.has(code)) return
// Dedupe concurrent requests for the same locale
const existing = loadingPromises.get(code)
if (existing) return existing
const promise = (async () => {
const loader = localeModules[`../locales/${code}/index.json`]
if (!loader) return
const raw = await loader()
messageCache.set(code, transformCrowdinMessages(raw.default))
})()
loadingPromises.set(code, promise)
try {
await promise
} finally {
loadingPromises.delete(code)
}
}
function parseAcceptLanguage(header: string): string | null {
try {
for (const lang of header
.split(',')
.map((l) => l.split(';')[0]?.trim())
.filter(Boolean)) {
const exact = LOCALES.find((loc) => loc.code === lang)
if (exact) return exact.code
const prefix = LOCALES.find((loc) => loc.code.startsWith(lang.split('-')[0] + '-'))
if (prefix) return prefix.code
}
} catch {
// Malformed header, ignore
}
return null
}
export default defineNuxtPlugin({
name: 'i18n',
enforce: 'pre',
async setup(nuxtApp) {
// ONLY locale needs request-scoping (what language this request uses)
const locale = useState<string>('i18n-locale', () => DEFAULT_LOCALE)
function t(key: string, values?: Record<string, unknown>): string {
const msg = messageCache.get(locale.value)?.[key] ?? messageCache.get(DEFAULT_LOCALE)?.[key]
if (!msg) return key
if (!values || Object.keys(values).length === 0) return msg
const cacheKey = `${locale.value}:${msg}`
let formatter = formatterCache.get(cacheKey)
if (!formatter) {
formatter = new IntlMessageFormat(msg, locale.value)
formatterCache.set(cacheKey, formatter)
}
try {
return formatter.format(values) as string
} catch {
return msg
}
}
async function setLocale(newLocale: string): Promise<void> {
if (!LOCALES.some((l) => l.code === newLocale)) return
await loadLocale(newLocale)
locale.value = newLocale
useCookie('locale', { maxAge: 31536000, path: '/' }).value = newLocale
}
// Detect initial locale (cookie > Accept-Language > default)
const cookieLocale = useCookie('locale').value
let detectedLocale = DEFAULT_LOCALE
if (cookieLocale && LOCALES.some((l) => l.code === cookieLocale)) {
detectedLocale = cookieLocale
} else if (import.meta.server) {
const acceptLang = useRequestHeaders(['accept-language'])['accept-language']
if (acceptLang) {
detectedLocale = parseAcceptLanguage(acceptLang) ?? DEFAULT_LOCALE
}
}
// Load locales (hits cache after first request)
await loadLocale(DEFAULT_LOCALE)
if (detectedLocale !== DEFAULT_LOCALE) await loadLocale(detectedLocale)
locale.value = detectedLocale
const context: I18nContext = { locale, t, setLocale }
nuxtApp.vueApp.provide(I18N_INJECTION_KEY, context)
},
})

View File

@@ -39,8 +39,7 @@
"packageManager": "pnpm@9.15.0",
"pnpm": {
"patchedDependencies": {
"readable-stream@2.3.8": "patches/readable-stream@2.3.8.patch",
"@nuxtjs/i18n@9.5.6": "patches/@nuxtjs__i18n@9.5.6.patch"
"readable-stream@2.3.8": "patches/readable-stream@2.3.8.patch"
},
"peerDependencyRules": {
"allowedVersions": {

View File

@@ -2,7 +2,8 @@
import IntlMessageFormat, { type FormatXMLElementFn, type PrimitiveType } from 'intl-messageformat'
import { computed, useSlots, type VNode } from 'vue'
import { getSafeI18n, type MessageDescriptor } from '../../composables/i18n'
import type { MessageDescriptor } from '../../composables/i18n'
import { injectI18n } from '../../providers/i18n'
const props = defineProps<{
messageId: MessageDescriptor
@@ -10,7 +11,7 @@ const props = defineProps<{
}>()
const slots = useSlots()
const { t, locale } = getSafeI18n()
const { t, locale } = injectI18n()
const formattedParts = computed(() => {
const key = props.messageId.id

View File

@@ -1,6 +1,6 @@
import { computed, type ComputedRef } from 'vue'
import { getSafeI18n } from './i18n'
import { injectI18n } from '../providers/i18n'
export type Formatter = (value: Date | number, options?: FormatOptions) => string
@@ -11,7 +11,7 @@ export interface FormatOptions {
const formatters = new Map<string, ComputedRef<Intl.RelativeTimeFormat>>()
export function useRelativeTime(): Formatter {
const { locale } = getSafeI18n()
const { locale } = injectI18n()
const formatterRef = computed(
() =>

View File

@@ -1,35 +1,8 @@
import IntlMessageFormat from 'intl-messageformat'
import type { Ref } from 'vue'
import type { CompileError, Composer, MessageCompiler, MessageContext } from 'vue-i18n'
import { useI18n } from 'vue-i18n'
import type { CompileError, MessageCompiler, MessageContext } from 'vue-i18n'
declare const useNuxtApp: (() => { $i18n?: Pick<Composer, 't' | 'locale'> }) | undefined
/**
* Get i18n instance, preferring Nuxt's $i18n to avoid vue-i18n's
* getCurrentInstance() issues on edge runtimes with concurrent SSR requests.
*/
export function getSafeI18n(): Pick<Composer, 't' | 'locale'> {
// Try Nuxt's $i18n first (avoids Error 27 on Cloudflare Workers)
if (typeof useNuxtApp === 'function') {
try {
const nuxtApp = useNuxtApp()
const $i18n = nuxtApp.$i18n
if ($i18n) {
return { t: $i18n.t, locale: $i18n.locale }
}
console.warn('[getSafeI18n] useNuxtApp() succeeded but $i18n is falsy:', $i18n)
} catch (e) {
console.warn('[getSafeI18n] useNuxtApp() threw:', e)
}
} else {
console.debug('[getSafeI18n] useNuxtApp not available, using vue-i18n fallback')
}
console.log('FALLBACK TO useI18n!!!')
// Fallback to vue-i18n's useI18n (used in Tauri app or if Nuxt context unavailable)
return useI18n()
}
import { injectI18n } from '../providers/i18n'
export interface MessageDescriptor {
id: string
@@ -55,7 +28,6 @@ export interface LocaleDefinition {
code: string
name: string
dir?: 'ltr' | 'rtl'
// For @nuxtjs/i18n v9 compatibility
iso?: string
file?: string
}
@@ -199,10 +171,10 @@ export interface VIntlFormatters {
/**
* Composable that provides formatMessage() with the same API as @vintl/vintl.
* Uses vue-i18n's useI18n() under the hood.
* Uses the injected I18nContext from the provider.
*/
export function useVIntl(): VIntlFormatters & { locale: Ref<string> } {
const { t, locale } = getSafeI18n()
const { t, locale } = injectI18n()
function formatMessage(descriptor: MessageDescriptor, values?: Record<string, unknown>): string {
const key = descriptor.id

View File

@@ -0,0 +1,24 @@
import type { InjectionKey, Ref } from 'vue'
import { inject, provide } from 'vue'
// This doesn't use the architecture outlined in index.ts as it needs some custom checks + use the symbol
export interface I18nContext {
locale: Ref<string>
t: (key: string, values?: Record<string, unknown>) => string
setLocale: (locale: string) => Promise<void> | void
}
export const I18N_INJECTION_KEY: InjectionKey<I18nContext> = Symbol('i18n')
export function injectI18n(): I18nContext {
const context = inject(I18N_INJECTION_KEY)
if (!context) {
throw new Error('Injection `Symbol(i18n)` not found. Ensure the i18n plugin is installed.')
}
return context
}
export function provideI18n(context: I18nContext): I18nContext {
provide(I18N_INJECTION_KEY, context)
return context
}

View File

@@ -79,6 +79,7 @@ export function createContext<ContextValue>(
}
export * from './api-client'
export * from './i18n'
export * from './page-context'
export * from './project-page'
export * from './server-context'

View File

@@ -1,45 +0,0 @@
diff --git a/dist/runtime/composables/index.js b/dist/runtime/composables/index.js
index ca736eae7a45ab3752c3f408c971c06bccd4f91b..ec9d34c497249576b6eb00adf3c727960778bae4 100644
--- a/dist/runtime/composables/index.js
+++ b/dist/runtime/composables/index.js
@@ -4,7 +4,24 @@ import { runtimeDetectBrowserLanguage, wrapComposable } from "../internal.js";
import { localeCodes } from "#build/i18n.options.mjs";
import { _useLocaleHead, _useSetI18nParams } from "../routing/head.js";
import { getRouteBaseName, localePath, localeRoute, switchLocalePath } from "../routing/routing.js";
-export * from "vue-i18n";
+// Re-export everything from vue-i18n EXCEPT useI18n
+// useI18n is replaced with a safe version that uses Nuxt's async-context-aware $i18n
+// to avoid Error 27 (NOT_INSTALLED) on Cloudflare Workers with concurrent SSR requests
+export { DatetimeFormat, I18nD, I18nInjectionKey, I18nN, I18nT, NumberFormat, Translation, VERSION, createI18n, vTDirective } from "vue-i18n";
+
+/**
+ * Safe useI18n replacement that uses Nuxt's $i18n instead of vue-i18n's useI18n.
+ *
+ * vue-i18n's useI18n() calls getCurrentInstance() which returns a module-level
+ * variable shared across concurrent Cloudflare Workers requests. This causes
+ * Error 27 when the instance from a different request's Vue app is returned.
+ *
+ * useNuxtApp() uses AsyncLocalStorage and is request-scoped, avoiding this issue.
+ */
+export function useI18n(options) {
+ const nuxtApp = useNuxtApp();
+ return nuxtApp.$i18n;
+}
export * from "./shared.js";
export function useSetI18nParams(seo) {
return wrapComposable(_useSetI18nParams)(seo);
diff --git a/dist/runtime/plugins/i18n.js b/dist/runtime/plugins/i18n.js
index 7a71fcfda18c0770be2c4b7a0b3c2b875bbb832e..cd008b4126400a909bcc66897a1344cb5659e8a6 100644
--- a/dist/runtime/plugins/i18n.js
+++ b/dist/runtime/plugins/i18n.js
@@ -157,7 +157,9 @@ export default defineNuxtPlugin({
}
});
nuxt.vueApp.use(i18n);
- Object.defineProperty(nuxt, "$i18n", { get: () => getI18nTarget(i18n) });
+ if (!Object.prototype.hasOwnProperty.call(nuxt, '$i18n')) {
+ Object.defineProperty(nuxt, "$i18n", { get: () => getI18nTarget(i18n), configurable: true });
+ }
return {
provide: {
/**

751
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff