Skip to content

Multi-language support ​

Setting up ​

  1. Install the Internationalization plugin for Vue
bash
pnpm install vue-i18n
pnpm install @intlify/unplugin-vue-i18n
  1. Add plugin to ./vite.config.ts
ts
import VueI18nPlugin from '@intlify/unplugin-vue-i18n/vite'
import path from 'node:path'

// ...
export default defineConfig({
  plugins: [
    // ... other plugins
    VueI18nPlugin({
      include: path.resolve(__dirname, './src/i18n/**')
    }),
  ],
})
  1. Create some initial localization files.

@/i18n/en.json - / EN file ​

json
{
    "home": "Home"
}

@/i18n/fr.json - / FR file ​

json
{
    "home": "Accueil"
}

@/i18n/sandbox/en.json - /sandbox EN file ​

json
{
    "sandbox.title": "Sandbox",
    "sandbox.description": "A sandbox for testing i18n features in a Vue application."
}

@/i18n/sandbox/fr.json - /sandbox FR file ​

json
{
    "sandbox.title": "Sandbox",
    "sandbox.description": "Un bac à sable pour tester les fonctionnalités i18n dans une application Vue."
}

Translation File Structure:

src/i18n/
├── en.json          # Base English translations
├── fr.json          # Base French translations
└── page-a/          # Page-specific translations
    ├── en.json
    └── fr.json

NOTE

Translation structure can be only one level deep, so everything under moduleA has to be included in either base or module translation files.

  1. Create i18n configuration @/plugins/i18n.ts
ts
import { createI18n } from 'vue-i18n'
import messages from '@intlify/unplugin-vue-i18n/messages'

const i18n = createI18n({
  legacy: false,
  globalInjection: true,
  locale: 'en',
  fallbackLocale: 'en',
  fallbackWarn: false,
  messages,
})

export default i18n
  1. Initialize i18n in main.ts
ts
// ...
import i18n from './plugins/i18n'
// ...
app.use(i18n)
// ...
  1. Test

Modify @/App.vue

vue
<template>
  <router-link to="/">Home</router-link>
  |
  <router-link to="/about">About</router-link>
  |
  <router-link to="/sandbox">Sandbox</router-link>
  <br />
  <br />
  <v-radio-group inline v-model="locale" :label="t('choose.language')">
    <v-radio label="en" value="en"></v-radio>
    <v-radio label="fr" value="fr"></v-radio>
  </v-radio-group>
  <br />
  <br />
  <router-view />
</template>

<script setup lang="ts">
import { useI18n } from 'vue-i18n'
const { locale, t } = useI18n()
</script>

And @/pages/sandbox/index.vue

vue
<template>
  <v-card :style="cardBackground">
    <v-card-title><v-icon icon="$mdiHome" />{{ t('sandbox.title') }}</v-card-title>
    <v-card-text>{{ t('sandbox.description') }}</v-card-text>
    <v-card-actions>
      <v-btn @click="console.log('Primary!')" color="primary">Primary</v-btn>
      <v-btn @click="console.log('Secondary!')" color="secondary">Secondary</v-btn>
      <v-btn @click="theme.toggle()">Toggle theme</v-btn>
    </v-card-actions>
  </v-card>
</template>

<script setup lang="ts">
import { useTheme } from 'vuetify'
import { useCardBackground } from '@/composables/ui'

import { useI18n } from 'vue-i18n'
const { t } = useI18n()

const theme = useTheme()
const cardBackground = useCardBackground('#0000ff')
</script>

Automation (Optional) ​

NOTE

This feature is experimental

Translation automation will involve following steps:

  1. Gathering of missing translations - using i18n Missing Handler

  2. Passing and caching missing translations to vite middleware handler.

  3. Processing cached missed translations in timed intervals and adding to translation files.

  4. Files can be then AI translated.

Implementation ​

  1. Create Vite Dev plugin for processing translations

@/plugins/i18n-dev.ts ​

source
ts
import { promises as fs, existsSync } from 'node:fs'
import path from 'node:path'
import type { Plugin, ViteDevServer } from 'vite'

const i18nLocales = ['en', 'fr', 'de']
const i18nRefreshInterval = 300000 // 300 seconds by default

type I18nCache = {
  module?: string
  locale: string
  key: string
  value: string
}

// Determine the correct i18n path for a module
// If module exists in src/modules/{module}/, use src/modules/{module}/i18n/
// Otherwise use the legacy src/i18n/{module}/ path
function getI18nPath(module: string | undefined, locale: string): string {
  if (module) {
    const modulePath = path.resolve(process.cwd(), 'src', 'modules', module)
    if (existsSync(modulePath)) {
      return path.resolve(modulePath, 'i18n', `${locale}.json`)
    }
  }
  // Legacy path or root translations
  return path.resolve(process.cwd(), 'src', 'i18n', module || '', `${locale}.json`)
}

export function i18nDevPlugin(): Plugin {
  const i18nCache: I18nCache[] = []
  let timer: NodeJS.Timeout | null = null

  async function dumpI18nData() {
    if (i18nCache.length === 0) return

    try {
      const modules = [...[''], ...Array.from(new Set(i18nCache.map((item) => item.module)))]
      const locales = i18nLocales

      const fileGroups = new Map<string, I18nCache[]>()
      for (const module of modules) {
        for (const locale of locales) {
          const filePath = getI18nPath(module, locale)
          if (!fileGroups.has(filePath)) {
            fileGroups.set(filePath, [])
          }
          const items = i18nCache.filter((item) => item.module === module && item.locale === locale)
          for (const item of items) {
            fileGroups.get(filePath)!.push(item)
          }
        }
      }

      for (const [filePath, items] of fileGroups.entries()) {
        const fileExists = await fs
          .stat(filePath)
          .then(() => true)
          .catch(() => false)

        if (!fileExists) {
          await fs.mkdir(path.dirname(filePath), { recursive: true })
          await fs.writeFile(filePath, '{}')
        }

        const data = await fs.readFile(filePath, 'utf-8')
        const jsonData = JSON.parse(data || '{}')
        let hasChanges = false

        for (const item of items) {
          if (item.key && !jsonData[item.key]) {
            jsonData[item.key] = item.value
            hasChanges = true
          }
        }

        if (hasChanges) {
          await fs.writeFile(filePath, JSON.stringify(jsonData, null, 2))
          const displayPath = path.relative(process.cwd(), filePath)
          console.log(`🌐 [i18n] Updated file: ${displayPath} with ${items.length} translations`)
        }
      }

      i18nCache.length = 0
    } catch (error) {
      console.error('❌ Error processing i18n dump:', error)
    }
  }

  return {
    name: 'i18n-dev-plugin',
    configureServer(server: ViteDevServer) {
      server.middlewares.use('/i18n-add', (req, res) => {
        if (req.method === 'POST') {
          let body = ''

          req.on('data', (chunk) => {
            body += chunk
          })

          req.on('end', () => {
            try {
              const data = JSON.parse(body)
              const referer = req.headers.referer || 'unknown'
              const module = referer.split('/')[3] || ''

              const item = i18nCache.find(
                (item) =>
                  item.module === module &&
                  item.key === data.data.key &&
                  item.locale === data.data.locale,
              )
              if (!item) {
                i18nCache.push({
                  module,
                  locale: data.data.locale,
                  key: data.data.key,
                  value: data.data.key
                    .split('.')
                    .join(' ')
                    .replace(/^./, (c: string) => c.toUpperCase()),
                })
              }
              res.writeHead(200, { 'Content-Type': 'application/json' })
              res.end(JSON.stringify({ status: 'ok', received: data }))
            } catch (err) {
              console.error('❌ Failed to parse JSON:', err)
              res.writeHead(400, { 'Content-Type': 'application/json' })
              res.end(JSON.stringify({ error: 'Invalid JSON' }))
            }
          })
        } else {
          res.writeHead(405, { 'Content-Type': 'application/json' })
          res.end(JSON.stringify({ error: 'Method Not Allowed' }))
        }
      })

      server.middlewares.use('/i18n-status', (req, res) => {
        res.writeHead(200, { 'Content-Type': 'application/json' })
        res.end(JSON.stringify({ status: 'ok', cache: i18nCache }))
      })

      timer = setInterval(() => {
        dumpI18nData().catch((error) => {
          console.error('❌ Error in timed i18n dump:', error)
        })
      }, i18nRefreshInterval)

      server.httpServer?.on('close', () => {
        if (timer) {
          clearInterval(timer)
          timer = null
        }
      })
    },
  }
}
  1. Add plugin to ./vite.config.ts amd enable middleware
ts
//
import { i18nDevPlugin}  from './src/plugins/i18n-dev'
//
  return {
    middlewareMode: true,
    plugins: [
//
      i18nDevPlugin(),
//
    ],
  }
//
  1. Add missing translated handler to @/plugins/i18n.ts
ts
/// <reference types="vite/client" />
import { createI18n } from 'vue-i18n'
import messages from '@intlify/unplugin-vue-i18n/messages'

const i18n = createI18n({
  legacy: false,
  globalInjection: true,
  locale: 'en',
  fallbackLocale: 'en',
  fallbackWarn: false,
  messages,
  missing: (locale: string, key: string) => {
    if (!import.meta.env.DEV) return
    if (typeof window !== 'undefined' && window.location.href) {
      fetch('/i18n-add', {
        method: 'POST',
        body: JSON.stringify({
          data: { locale, key },
        }),
        headers: {
          'Content-Type': 'application/json',
        },
      }).catch(() => {
        // Silently fail if fetch fails (e.g., in test environment)
      })
    }
  },
})

export default i18n
  1. Test.

Add in any *.vue file

vue
  <p>{{ t('welcome.message') }}</p>

And When navigating through pages in development mode, each 30 seconds there should be update of files in ./i18n with missing translations.

log
2:44:51 PM [vite] (client) page reload src/i18n/sandbox/en.json
🌐 [i18n] Updated file: src\i18n\sandbox\en.json with 1 translations
2:44:51 PM [vite] (client) page reload src/i18n/sandbox/fr.json
🌐 [i18n] Updated file: src\i18n\sandbox\fr.json with 1 translations