You've already forked AstralRinth
forked from xxxOFFxxx/AstralRinth
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:
@@ -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')
|
||||
|
||||
23
apps/app-frontend/src/plugins/i18n.ts
Normal file
23
apps/app-frontend/src/plugins/i18n.ts
Normal 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)
|
||||
},
|
||||
}
|
||||
@@ -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,
|
||||
}))
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
112
apps/frontend/src/plugins/i18n.ts
Normal file
112
apps/frontend/src/plugins/i18n.ts
Normal 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)
|
||||
},
|
||||
})
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
() =>
|
||||
|
||||
@@ -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
|
||||
|
||||
24
packages/ui/src/providers/i18n.ts
Normal file
24
packages/ui/src/providers/i18n.ts
Normal 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
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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
751
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user