├── .nvmrc ├── .nuxtrc ├── pnpm-workspace.yaml ├── .npmrc ├── tsconfig.json ├── .github ├── og.png ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature-request.yml │ └── bug-report.yml ├── workflows │ ├── release.yml │ └── ci.yml └── PULL_REQUEST_TEMPLATE.md ├── playground ├── pages │ ├── [...id].vue │ ├── about │ │ └── index.vue │ ├── test │ │ ├── index.vue │ │ ├── routes.vue │ │ ├── i18n.vue │ │ └── composables.vue │ └── index.vue ├── package.json ├── composables │ └── test-result.ts ├── locales │ ├── en.json │ └── de.json ├── app.vue ├── nuxt.config.ts ├── middleware │ └── redirects.global.ts └── layouts │ └── default.vue ├── .vscode ├── extensions.json └── settings.json ├── .eslintrc ├── src ├── runtime │ ├── composables │ │ ├── useI18n.ts │ │ ├── useRouteLocale.ts │ │ ├── useLazyLocaleSwitch.ts │ │ └── useLocalizedPath.ts │ ├── plugin.ts │ └── utils.ts ├── types.ts ├── constants.ts ├── locales.ts ├── utils.ts ├── pages.ts ├── module.ts └── routes.ts ├── .editorconfig ├── netlify.toml ├── .gitignore ├── LICENSE ├── test ├── e2e.test.ts ├── unit │ ├── utils.test.ts │ ├── __snapshots__ │ │ └── routes.test.ts.snap │ └── routes.test.ts └── __snapshots__ │ └── e2e.test.ts.snap ├── package.json └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | 18 2 | -------------------------------------------------------------------------------- /.nuxtrc: -------------------------------------------------------------------------------- 1 | typescript.includeWorkspace=true 2 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - playground 3 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | shamefully-hoist=true 2 | strict-peer-dependencies=false 3 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./playground/.nuxt/tsconfig.json" 3 | } 4 | -------------------------------------------------------------------------------- /.github/og.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leanera/nuxt-i18n/HEAD/.github/og.png -------------------------------------------------------------------------------- /playground/pages/[...id].vue: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /playground/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "playground", 3 | "private": true 4 | } 5 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "dbaeumer.vscode-eslint" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@antfu", 3 | "rules": { 4 | "n/prefer-global/process": "off" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /playground/pages/about/index.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 8 | -------------------------------------------------------------------------------- /src/runtime/composables/useI18n.ts: -------------------------------------------------------------------------------- 1 | import type { UseI18n } from '@leanera/vue-i18n' 2 | import { useNuxtApp } from '#imports' 3 | 4 | export function useI18n() { 5 | return useNuxtApp().$i18n as UseI18n 6 | } 7 | -------------------------------------------------------------------------------- /playground/pages/test/index.vue: -------------------------------------------------------------------------------- 1 | 11 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.codeActionsOnSave": { 3 | "source.fixAll.eslint": true 4 | }, 5 | "i18n-ally.localesPaths": [ 6 | "playground/locales" 7 | ], 8 | "prettier.enable": false 9 | } 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: true 2 | contact_links: 3 | - name: 📚 @leanera/nuxt-i18n Documentation 4 | url: https://github.com/leanera/nuxt-i18n#readme 5 | about: Check the documentation for usage of @leanera/nuxt-i18n 6 | -------------------------------------------------------------------------------- /playground/composables/test-result.ts: -------------------------------------------------------------------------------- 1 | export function useTestResult(data: any) { 2 | useHead({ 3 | script: [ 4 | { 5 | children: JSON.stringify(data), 6 | type: 'text/test-result', 7 | }, 8 | ], 9 | }) 10 | } 11 | -------------------------------------------------------------------------------- /playground/pages/test/routes.vue: -------------------------------------------------------------------------------- 1 | 9 | -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | publish = "playground/dist" 3 | command = "pnpm run dev:build" 4 | 5 | [functions] 6 | directory = "playground/.netlify/functions-internal" 7 | 8 | [[redirects]] 9 | from = "/*" 10 | to = "/index.html" 11 | status = 200 12 | -------------------------------------------------------------------------------- /playground/locales/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "@leanera/nuxt-i18n", 3 | "hello": "Hello, {name}!", 4 | "language": "Language", 5 | "menu": { 6 | "home": "Home", 7 | "about": "About" 8 | }, 9 | "about": { 10 | "description": "This is the about page" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /playground/app.vue: -------------------------------------------------------------------------------- 1 | 11 | -------------------------------------------------------------------------------- /playground/pages/test/i18n.vue: -------------------------------------------------------------------------------- 1 | 13 | -------------------------------------------------------------------------------- /playground/locales/de.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "@leanera/nuxt-i18n", 3 | "hello": "Hallo, {name}!", 4 | "language": "Sprache", 5 | "menu": { 6 | "home": "Start", 7 | "about": "Über uns" 8 | }, 9 | "about": { 10 | "description": "Das ist die Über-uns-Seite" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import type { STRATEGIES } from './constants' 2 | 3 | export type Strategies = typeof STRATEGIES[keyof typeof STRATEGIES] 4 | 5 | export type CustomRoutePages = Record> 6 | 7 | export interface LocaleInfo { 8 | code: string 9 | path: string 10 | file: string 11 | } 12 | -------------------------------------------------------------------------------- /playground/pages/test/composables.vue: -------------------------------------------------------------------------------- 1 | 12 | -------------------------------------------------------------------------------- /playground/pages/index.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 16 | -------------------------------------------------------------------------------- /src/runtime/composables/useRouteLocale.ts: -------------------------------------------------------------------------------- 1 | import type { RouteLocationNormalized, RouteLocationNormalizedLoaded } from 'vue-router' 2 | import { getLocaleFromRoute } from '../utils' 3 | import { useRoute } from '#imports' 4 | 5 | export function useRouteLocale( 6 | route: string | RouteLocationNormalizedLoaded | RouteLocationNormalized = useRoute(), 7 | ) { 8 | return getLocaleFromRoute(route) 9 | } 10 | -------------------------------------------------------------------------------- /src/runtime/composables/useLazyLocaleSwitch.ts: -------------------------------------------------------------------------------- 1 | import { loadLocale } from '../utils' 2 | import { useNuxtApp } from '#imports' 3 | 4 | /** 5 | * Ensures to load the translation messages for the given locale 6 | * before switching to it 7 | */ 8 | export async function useLazyLocaleSwitch(locale: string) { 9 | const { locales, messages, setLocale } = useNuxtApp().$i18n 10 | if (locales.includes(locale)) 11 | await loadLocale(messages, locale) 12 | setLocale(locale) 13 | } 14 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const STRATEGIES = { 2 | PREFIX: 'prefix', 3 | PREFIX_EXCEPT_DEFAULT: 'prefix_except_default', 4 | PREFIX_AND_DEFAULT: 'prefix_and_default', 5 | NO_PREFIX: 'no_prefix', 6 | } as const 7 | 8 | export const DEFAULT_LOCALE = '' 9 | export const DEFAULT_STRATEGY = STRATEGIES.PREFIX_EXCEPT_DEFAULT 10 | export const DEFAULT_TRAILING_SLASH = false 11 | export const DEFAULT_ROUTES_NAME_SEPARATOR = '___' 12 | export const DEFAULT_LOCALE_ROUTE_NAME_SUFFIX = 'default' 13 | -------------------------------------------------------------------------------- /src/locales.ts: -------------------------------------------------------------------------------- 1 | import { parse } from 'pathe' 2 | import { resolveFiles } from '@nuxt/kit' 3 | import { logger } from './utils' 4 | import type { LocaleInfo } from './types' 5 | 6 | export async function resolveLocales(path: string): Promise { 7 | const files = await resolveFiles(path, '**/*{json,json5,yaml,yml}') 8 | const localeInfo = files.map((file) => { 9 | const parsed = parse(file) 10 | return { 11 | path: file, 12 | file: parsed.base, 13 | code: parsed.name, 14 | } 15 | }) 16 | 17 | logger.info('Resolved locale files:', localeInfo.map(i => `\`${i.file}\``).join(', ')) 18 | 19 | return localeInfo 20 | } 21 | -------------------------------------------------------------------------------- /playground/nuxt.config.ts: -------------------------------------------------------------------------------- 1 | export default defineNuxtConfig({ 2 | modules: ['../src/module.ts'], 3 | 4 | i18n: { 5 | defaultLocale: 'en', 6 | locales: ['en', 'de'], 7 | langImports: true, 8 | lazy: true, 9 | strategy: 'prefix', 10 | pages: { 11 | about: { 12 | de: '/ueber-uns', 13 | }, 14 | }, 15 | routeOverrides: { 16 | // Use `en` catch-all page as fallback for non-existing pages 17 | '/en/:id(.*)*': '/:id(.*)*', 18 | }, 19 | logs: true, 20 | }, 21 | 22 | experimental: { 23 | typescriptBundlerResolution: true, 24 | }, 25 | 26 | typescript: { 27 | shim: false, 28 | }, 29 | }) 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules 3 | 4 | # Logs 5 | *.log* 6 | 7 | # Temp directories 8 | .temp 9 | .tmp 10 | .cache 11 | 12 | # Yarn 13 | **/.yarn/cache 14 | **/.yarn/*state* 15 | 16 | # Generated dirs 17 | dist 18 | 19 | # Nuxt 20 | .nuxt 21 | .output 22 | .vercel_build_output 23 | .build-* 24 | .env 25 | .netlify 26 | 27 | # Env 28 | .env 29 | 30 | # Testing 31 | reports 32 | coverage 33 | *.lcov 34 | .nyc_output 35 | 36 | # VSCode 37 | # .vscode 38 | 39 | # Intellij idea 40 | *.iml 41 | .idea 42 | 43 | # OSX 44 | .DS_Store 45 | .AppleDouble 46 | .LSOverride 47 | .AppleDB 48 | .AppleDesktop 49 | Network Trash Folder 50 | Temporary Items 51 | .apdisk 52 | -------------------------------------------------------------------------------- /src/runtime/composables/useLocalizedPath.ts: -------------------------------------------------------------------------------- 1 | import { useRouteLocale } from '#imports' 2 | import { options } from '#build/i18n.options' 3 | 4 | export function useLocalizedPath(path: string, locale: string): string { 5 | const currentLocale = useRouteLocale() 6 | let to = path 7 | 8 | // Normalize target route path 9 | if (!to.startsWith(`/${currentLocale}`)) 10 | to = `/${currentLocale}${path}` 11 | 12 | return to.replace( 13 | new RegExp(`^/${currentLocale}`), 14 | (options.strategy !== 'prefix' && locale === options.defaultLocale) 15 | ? '' 16 | : `/${locale}`, 17 | ) 18 | } 19 | 20 | /** @deprecated Use `useLocalizedPath` instead */ 21 | export const useLocalePath = useLocalizedPath 22 | -------------------------------------------------------------------------------- /playground/middleware/redirects.global.ts: -------------------------------------------------------------------------------- 1 | export default defineNuxtRouteMiddleware((to) => { 2 | const { defaultLocale } = useI18n() 3 | 4 | if (to.path === '/') { 5 | const { short } = useServerOrClientLocale() 6 | return navigateTo(`/${short || defaultLocale}`, { redirectCode: 302 }) 7 | } 8 | }) 9 | 10 | function useServerOrClientLocale() { 11 | let long: string | undefined 12 | let short: string | undefined 13 | 14 | if (process.server) { 15 | const headers = useRequestHeaders() 16 | long = headers['accept-language'] 17 | short = long?.split(',')?.[0]?.slice(0, 2) 18 | } 19 | else if (process.client) { 20 | long = navigator.language 21 | short = long.slice(0, 2) 22 | } 23 | 24 | return { short, long } 25 | } 26 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | permissions: 4 | contents: write 5 | 6 | on: 7 | push: 8 | tags: 9 | - 'v*' 10 | 11 | jobs: 12 | release: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v3 16 | with: 17 | fetch-depth: 0 18 | 19 | - uses: actions/setup-node@v3 20 | with: 21 | node-version: 18 22 | registry-url: https://registry.npmjs.org/ 23 | 24 | - run: corepack enable 25 | 26 | - name: Install 27 | run: pnpm i 28 | 29 | - name: Publish to npm 30 | run: npm publish --access public 31 | env: 32 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 33 | 34 | - run: npx changelogithub 35 | env: 36 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022-2023 Johann Schopplich 4 | Copyright (c) 2022-2023 LeanERA GmbH 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /playground/layouts/default.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 50 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { useLogger } from '@nuxt/kit' 2 | import type { Strategies } from './types' 3 | 4 | export const logger = useLogger('@leanera/nuxt-i18n') 5 | 6 | export function adjustRoutePathForTrailingSlash( 7 | pagePath: string, 8 | trailingSlash: boolean, 9 | isChildWithRelativePath: boolean, 10 | ) { 11 | return pagePath.replace(/\/+$/, '') + (trailingSlash ? '/' : '') || (isChildWithRelativePath ? '' : '/') 12 | } 13 | 14 | export function getRouteName(routeName?: string | symbol | null) { 15 | return typeof routeName === 'string' 16 | ? routeName 17 | : typeof routeName === 'symbol' 18 | ? routeName.toString() 19 | : '(null)' 20 | } 21 | 22 | export function getLocaleRouteName( 23 | routeName: string | null, 24 | locale: string, 25 | { 26 | defaultLocale, 27 | strategy, 28 | routesNameSeparator, 29 | defaultLocaleRouteNameSuffix, 30 | }: { 31 | defaultLocale: string 32 | strategy: Strategies 33 | routesNameSeparator: string 34 | defaultLocaleRouteNameSuffix: string 35 | }, 36 | ) { 37 | let name = getRouteName(routeName) + (strategy === 'no_prefix' ? '' : routesNameSeparator + locale) 38 | 39 | if (locale === defaultLocale && strategy === 'prefix_and_default') 40 | name += routesNameSeparator + defaultLocaleRouteNameSuffix 41 | 42 | return name 43 | } 44 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | pull_request: 9 | branches: 10 | - main 11 | 12 | jobs: 13 | lint: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v3 17 | 18 | - uses: actions/setup-node@v3 19 | with: 20 | node-version: 18 21 | 22 | - run: corepack enable 23 | 24 | - name: Install 25 | run: pnpm i 26 | 27 | - name: Lint 28 | run: pnpm run lint 29 | 30 | typecheck: 31 | runs-on: ubuntu-latest 32 | steps: 33 | - uses: actions/checkout@v3 34 | 35 | - uses: actions/setup-node@v3 36 | with: 37 | node-version: 18 38 | 39 | - run: corepack enable 40 | 41 | - name: Install 42 | run: pnpm i 43 | 44 | - name: Typecheck 45 | run: pnpm run test:types 46 | 47 | test: 48 | runs-on: ${{ matrix.os }} 49 | 50 | strategy: 51 | matrix: 52 | node: [18] 53 | os: [ubuntu-latest] 54 | fail-fast: false 55 | 56 | steps: 57 | - uses: actions/checkout@v3 58 | 59 | - uses: actions/setup-node@v3 60 | with: 61 | node-version: ${{ matrix.node }} 62 | 63 | - run: corepack enable 64 | 65 | - name: Install 66 | run: pnpm i 67 | 68 | - name: Test 69 | run: pnpm run test 70 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 4 | 5 | ### 🔗 Linked issue 6 | 7 | 8 | 9 | ### ❓ Type of change 10 | 11 | 12 | 13 | - [ ] 📖 Documentation (updates to the documentation, readme or JSDoc annotations) 14 | - [ ] 🐞 Bug fix (a non-breaking change that fixes an issue) 15 | - [ ] 👌 Enhancement (improving an existing functionality like performance) 16 | - [ ] ✨ New feature (a non-breaking change that adds functionality) 17 | - [ ] 🧹 Chore (updates to the build process or auxiliary tools and libraries) 18 | - [ ] ⚠️ Breaking change (fix or feature that would cause existing functionality to change) 19 | 20 | ### 📚 Description 21 | 22 | 23 | 24 | 25 | 26 | ### 📝 Checklist 27 | 28 | 29 | 30 | 31 | 32 | - [ ] I have linked an issue or discussion. 33 | - [ ] I have updated the documentation accordingly. 34 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request.yml: -------------------------------------------------------------------------------- 1 | name: 🚀 Feature request 2 | description: Suggest a feature that will improve @leanera/nuxt-i18n 3 | labels: [pending triage] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: | 8 | Thank you for taking the time to fill out this feature request! 9 | 10 | Please check that there is not an existing issue covering the scope of the feature you have in mind. 11 | - type: textarea 12 | id: feature-description 13 | attributes: 14 | label: Describe the feature 15 | description: A clear and concise description of what you think would be a helpful addition to @leanera/nuxt-i18n, including the possible use cases and alternatives you have considered. 16 | placeholder: Feature description 17 | validations: 18 | required: true 19 | - type: checkboxes 20 | id: additional-info 21 | attributes: 22 | label: Additional information 23 | description: Additional information that helps us decide how to proceed. 24 | options: 25 | - label: Would you be willing to help implement this feature? 26 | - label: Can you think of other implementations of this feature? 27 | - type: checkboxes 28 | id: required-info 29 | attributes: 30 | label: Final checks 31 | description: Before submitting, please make sure you do the following 32 | options: 33 | - label: Check existing [issues](https://github.com/leanera/nuxt-i18n/issues). 34 | required: true 35 | -------------------------------------------------------------------------------- /src/pages.ts: -------------------------------------------------------------------------------- 1 | import { extendPages } from '@nuxt/kit' 2 | import type { Nuxt } from '@nuxt/schema' 3 | import type { ModuleOptions } from './module' 4 | import { localizeRoutes } from './routes' 5 | import type { ComputedRouteOptions, RouteOptionsResolver } from './routes' 6 | import { logger } from './utils' 7 | 8 | export function setupPages( 9 | options: Required, 10 | nuxt: Nuxt, 11 | ) { 12 | const includeUprefixedFallback = nuxt.options._generate 13 | 14 | const optionsResolver: RouteOptionsResolver = (route, localeCodes) => { 15 | const routeOptions: ComputedRouteOptions = { 16 | locales: localeCodes, 17 | paths: {}, 18 | } 19 | 20 | // Set custom localized route paths 21 | if (Object.keys(options.pages).length) { 22 | for (const locale of localeCodes) { 23 | const customPath = options.pages?.[route.name!]?.[locale] 24 | if (customPath) 25 | routeOptions.paths[locale] = customPath 26 | } 27 | } 28 | 29 | return routeOptions 30 | } 31 | 32 | extendPages((pages) => { 33 | const localizedPages = localizeRoutes(pages, { 34 | ...options, 35 | includeUprefixedFallback, 36 | optionsResolver, 37 | }) 38 | pages.splice(0, pages.length) 39 | pages.unshift(...localizedPages) 40 | 41 | for (const [key, value] of Object.entries(options.routeOverrides)) { 42 | const page = pages.find(({ path }) => path === key) 43 | if (page) 44 | page.path = value 45 | else 46 | logger.error(`Couldn't find page for route override \`${key}\``) 47 | } 48 | }) 49 | } 50 | -------------------------------------------------------------------------------- /test/e2e.test.ts: -------------------------------------------------------------------------------- 1 | import { fileURLToPath } from 'node:url' 2 | import { destr } from 'destr' 3 | import { describe, expect, it } from 'vitest' 4 | import { $fetch, setup } from '@nuxt/test-utils' 5 | 6 | describe('nuxt-i18n', async () => { 7 | await setup({ 8 | server: true, 9 | rootDir: fileURLToPath(new URL('../playground', import.meta.url)), 10 | }) 11 | 12 | it('renders message for default locale', async () => { 13 | const html = await $fetch('/en/test') 14 | const content = getTestResult(html) 15 | expect(content).toMatchSnapshot() 16 | }) 17 | 18 | it('renders message for "de"', async () => { 19 | const html = await $fetch('/de/test') 20 | const content = getTestResult(html) 21 | expect(content).toMatchSnapshot() 22 | }) 23 | 24 | it('builds routes tree', async () => { 25 | const html = await $fetch('/en/test/routes') 26 | const content = getTestResult(html) 27 | expect(content).toMatchSnapshot() 28 | }) 29 | 30 | it('returns composables data for default locale', async () => { 31 | const html = await $fetch('/en/test/composables') 32 | const content = getTestResult(html) 33 | expect(content).toMatchSnapshot() 34 | }) 35 | 36 | it('returns composables data for "de"', async () => { 37 | const html = await $fetch('/de/test/composables') 38 | const content = getTestResult(html) 39 | expect(content).toMatchSnapshot() 40 | }) 41 | 42 | it('contains i18n data', async () => { 43 | const html = await $fetch('/en/test/i18n') 44 | const content = getTestResult(html) 45 | expect(content).toMatchSnapshot() 46 | }) 47 | 48 | it('renders the error page', async () => { 49 | const html = await $fetch('/not-found') 50 | expect(html).toMatch(/Error<\/h1>/) 51 | }) 52 | }) 53 | 54 | function getTestResult(html: string) { 55 | const content = html.match(/(.*?)<\/script>/s)?.[1] 56 | return destr(content) 57 | } 58 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@leanera/nuxt-i18n", 3 | "type": "module", 4 | "version": "0.5.1", 5 | "packageManager": "pnpm@8.7.4", 6 | "description": "Nuxt 3 module for internationalization w/ locale auto-imports & localized routing", 7 | "author": "LeanERA GmbH ", 8 | "license": "MIT", 9 | "homepage": "https://github.com/leanera/nuxt-i18n#readme", 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/leanera/nuxt-i18n.git" 13 | }, 14 | "bugs": "https://github.com/leanera/nuxt-i18n/issues", 15 | "keywords": [ 16 | "nuxt", 17 | "nuxt3", 18 | "i18n" 19 | ], 20 | "exports": { 21 | ".": { 22 | "types": "./dist/types.d.ts", 23 | "import": "./dist/module.mjs", 24 | "require": "./dist/module.cjs" 25 | } 26 | }, 27 | "main": "./dist/module.cjs", 28 | "types": "./dist/types.d.ts", 29 | "files": [ 30 | "dist" 31 | ], 32 | "scripts": { 33 | "dev": "nuxi dev playground", 34 | "dev:build": "nuxi build playground", 35 | "dev:prepare": "nuxt-module-build --stub && nuxi prepare playground", 36 | "lint": "eslint .", 37 | "lint:fix": "eslint . --fix", 38 | "test": "vitest", 39 | "test:types": "tsc --noEmit", 40 | "release": "bumpp --commit --push --tag", 41 | "prepare": "nuxi prepare playground", 42 | "prepack": "nuxt-module-build" 43 | }, 44 | "dependencies": { 45 | "@leanera/vue-i18n": "^0.5.0", 46 | "@nuxt/kit": "^3.7.1", 47 | "knitwork": "^1.0.0", 48 | "pathe": "^1.1.1" 49 | }, 50 | "devDependencies": { 51 | "@antfu/eslint-config": "^0.41.0", 52 | "@leanera/nuxt-i18n": "workspace:*", 53 | "@nuxt/module-builder": "^0.5.1", 54 | "@nuxt/test-utils": "^3.7.1", 55 | "@types/node": "^20.5.9", 56 | "bumpp": "^9.2.0", 57 | "eslint": "^8.48.0", 58 | "nuxt": "^3.7.1", 59 | "typescript": "^5.2.2", 60 | "vitest": "^0.34.3", 61 | "vue-tsc": "^1.8.10" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.yml: -------------------------------------------------------------------------------- 1 | name: 🐞 Bug report 2 | description: Create a report to help us improve @leanera/nuxt-i18n 3 | labels: [pending triage] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: | 8 | Please use a template below to create a minimal reproduction 9 | 👉 https://stackblitz.com/github/nuxt/starter/tree/v3-stackblitz 10 | 👉 https://codesandbox.io/p/github/nuxt/starter/v3-codesandbox 11 | - type: textarea 12 | id: bug-env 13 | attributes: 14 | label: Environment 15 | description: You can use `npx nuxi info` to fill this section 16 | placeholder: Environment 17 | validations: 18 | required: true 19 | - type: textarea 20 | id: reproduction 21 | attributes: 22 | label: Reproduction 23 | description: Please provide a link to a repo that can reproduce the problem you ran into. A [**minimal reproduction**](https://nuxt.com/docs/community/reporting-bugs#create-a-minimal-reproduction) is required unless you are absolutely sure that the issue is obvious and the provided information is enough to understand the problem. 24 | placeholder: Reproduction 25 | validations: 26 | required: true 27 | - type: textarea 28 | id: bug-description 29 | attributes: 30 | label: Describe the bug 31 | description: A clear and concise description of what the bug is. If you intend to submit a PR for this issue, tell us in the description. Thanks! 32 | placeholder: Bug description 33 | validations: 34 | required: true 35 | - type: textarea 36 | id: additonal 37 | attributes: 38 | label: Additional context 39 | description: If applicable, add any other context about the problem here 40 | - type: textarea 41 | id: logs 42 | attributes: 43 | label: Logs 44 | description: | 45 | Optional if reproduction is provided. Please try not to insert an image but copy paste the log text. 46 | render: shell-script 47 | -------------------------------------------------------------------------------- /src/runtime/plugin.ts: -------------------------------------------------------------------------------- 1 | import { createI18n } from '@leanera/vue-i18n' 2 | import { getLocaleFromRoute, loadLocale } from './utils' 3 | import { addRouteMiddleware, defineNuxtPlugin, useRoute } from '#imports' 4 | import { localeMessages, options } from '#build/i18n.options' 5 | 6 | export default defineNuxtPlugin(async (nuxtApp) => { 7 | const { defaultLocale, lazy, locales, messages, strategy } = options 8 | const hasLocaleMessages = Object.keys(localeMessages).length > 0 9 | const currentLocale = getLocaleFromRoute(useRoute()) 10 | 11 | // Loads all locale messages if auto-import is enabled 12 | if (hasLocaleMessages) { 13 | // Import all locale messages for SSR or if `lazy` is disabled 14 | if (process.server || !lazy) { 15 | await Promise.all(locales.map(locale => loadLocale(messages, locale))) 16 | } 17 | // Import default locale message for client 18 | else { 19 | await loadLocale(messages, defaultLocale) 20 | 21 | // Import locale messages for the current route 22 | if (currentLocale && locales.includes(currentLocale)) 23 | await loadLocale(messages, currentLocale) 24 | } 25 | } 26 | 27 | const i18n = createI18n({ 28 | defaultLocale, 29 | locales, 30 | messages, 31 | ...(!process.dev && { logLevel: 'silent' }), 32 | }) 33 | 34 | nuxtApp.vueApp.use(i18n) 35 | 36 | // Set locale from the current route 37 | if (currentLocale && locales.includes(currentLocale)) 38 | i18n.setLocale(currentLocale) 39 | 40 | // Add route middleware to load locale messages for the target route 41 | if (process.client && hasLocaleMessages && lazy && strategy !== 'no_prefix') { 42 | addRouteMiddleware( 43 | 'i18n-set-locale', 44 | async (to) => { 45 | const targetLocale = getLocaleFromRoute(to) 46 | if (targetLocale && locales.includes(targetLocale)) { 47 | await loadLocale(i18n.messages, targetLocale) 48 | i18n.setLocale(targetLocale) 49 | } 50 | }, 51 | { global: true }, 52 | ) 53 | } 54 | 55 | return { 56 | provide: { 57 | i18n, 58 | }, 59 | } 60 | }) 61 | -------------------------------------------------------------------------------- /src/runtime/utils.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable antfu/top-level-function */ 2 | import type { 3 | RouteLocationNormalized, 4 | RouteLocationNormalizedLoaded, 5 | } from 'vue-router' 6 | import { 7 | DEFAULT_LOCALE_ROUTE_NAME_SUFFIX, 8 | DEFAULT_ROUTES_NAME_SEPARATOR, 9 | localeMessages, 10 | options, 11 | } from '#build/i18n.options' 12 | 13 | const CONSOLE_PREFIX = '[nuxt-i18n]' 14 | const loadedLocales = new Set() 15 | 16 | const isString = (val: unknown): val is string => typeof val === 'string' 17 | const isObject = (val: unknown): val is Record => 18 | val !== null && typeof val === 'object' 19 | 20 | const getLocalesRegex = (localeCodes: string[]) => 21 | new RegExp(`^/(${localeCodes.join('|')})(?:/|$)`, 'i') 22 | 23 | /** 24 | * Extract locale code from a given route: 25 | * - If route has a name, try to extract locale from it 26 | * - Otherwise, fall back to using the routes' path 27 | */ 28 | export function getLocaleFromRoute( 29 | route: string | RouteLocationNormalizedLoaded | RouteLocationNormalized, 30 | { 31 | localeCodes = options.locales, 32 | routesNameSeparator = DEFAULT_ROUTES_NAME_SEPARATOR, 33 | defaultLocaleRouteNameSuffix = DEFAULT_LOCALE_ROUTE_NAME_SUFFIX, 34 | } = {}, 35 | ): string { 36 | const localesPattern = `(${localeCodes.join('|')})` 37 | const defaultSuffixPattern = `(?:${routesNameSeparator}${defaultLocaleRouteNameSuffix})?` 38 | const regexpName = new RegExp( 39 | `${routesNameSeparator}${localesPattern}${defaultSuffixPattern}$`, 40 | 'i', 41 | ) 42 | const regexpPath = getLocalesRegex(localeCodes) 43 | 44 | // Extract from route name 45 | if (isObject(route)) { 46 | if (route.name) { 47 | const name = isString(route.name) ? route.name : route.name.toString() 48 | const matches = name.match(regexpName) 49 | if (matches && matches.length > 1) 50 | return matches[1] 51 | } 52 | else if (route.path) { 53 | // Extract from path 54 | const matches = route.path.match(regexpPath) 55 | if (matches && matches.length > 1) 56 | return matches[1] 57 | } 58 | } 59 | else if (isString(route)) { 60 | const matches = route.match(regexpPath) 61 | if (matches && matches.length > 1) 62 | return matches[1] 63 | } 64 | 65 | return '' 66 | } 67 | 68 | /** 69 | * Resolves an async locale message import 70 | */ 71 | export async function loadMessages(locale: string) { 72 | let messages: Record = {} 73 | const loader = localeMessages[locale] 74 | 75 | if (!loader) { 76 | console.warn(CONSOLE_PREFIX, `No locale messages found for locale "${locale}"`) 77 | return 78 | } 79 | 80 | try { 81 | messages = await loader().then((r: any) => r.default || r) 82 | } 83 | catch (e) { 84 | console.error(CONSOLE_PREFIX, 'Failed loading locale messages:', (e as any).message) 85 | } 86 | 87 | return messages 88 | } 89 | 90 | /** 91 | * Loads a locale message if not already loaded 92 | */ 93 | export async function loadLocale( 94 | messages: Record, 95 | locale: string, 96 | ) { 97 | if (loadedLocales.has(locale)) 98 | return 99 | 100 | const result = await loadMessages(locale) 101 | if (result) { 102 | messages[locale] = result 103 | loadedLocales.add(locale) 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /test/unit/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { assert, describe, it } from 'vitest' 2 | import { adjustRoutePathForTrailingSlash, getLocaleRouteName } from '../../src/utils' 3 | 4 | // Adapted from: https://github.com/intlify/routing/blob/166e3c533ee47b40bc08e3af001bef33dcf975ed/packages/vue-i18n-routing/src/__test__/resolve.test.ts 5 | // Credit: Kazuya Kawaguchi, @intlify 6 | // License: MIT 7 | describe('adjustRouteDefinitionForTrailingSlash', () => { 8 | describe('pagePath: /foo/bar', () => { 9 | describe('trailingSlash: faawklse, isChildWithRelativePath: true', () => { 10 | it('should be trailed with slash: /foo/bar/', () => { 11 | assert.equal(adjustRoutePathForTrailingSlash('/foo/bar', true, true), '/foo/bar/') 12 | }) 13 | }) 14 | 15 | describe('trailingSlash: false, isChildWithRelativePath: true', () => { 16 | it('should not be trailed with slash: /foo/bar/', () => { 17 | assert.equal(adjustRoutePathForTrailingSlash('/foo/bar', false, true), '/foo/bar') 18 | }) 19 | }) 20 | 21 | describe('trailingSlash: false, isChildWithRelativePath: false', () => { 22 | it('should be trailed with slash: /foo/bar/', () => { 23 | assert.equal(adjustRoutePathForTrailingSlash('/foo/bar', true, false), '/foo/bar/') 24 | }) 25 | }) 26 | 27 | describe('trailingSlash: false, isChildWithRelativePath: false', () => { 28 | it('should not be trailed with slash: /foo/bar/', () => { 29 | assert.equal(adjustRoutePathForTrailingSlash('/foo/bar', false, false), '/foo/bar') 30 | }) 31 | }) 32 | }) 33 | 34 | describe('pagePath: /', () => { 35 | describe('trailingSlash: false, isChildWithRelativePath: true', () => { 36 | it('should not be trailed with slash: empty', () => { 37 | assert.equal(adjustRoutePathForTrailingSlash('/', false, true), '') 38 | }) 39 | }) 40 | }) 41 | 42 | describe('pagePath: empty', () => { 43 | describe('trailingSlash: true, isChildWithRelativePath: true', () => { 44 | it('should not be trailed with slash: /', () => { 45 | assert.equal(adjustRoutePathForTrailingSlash('', true, true), '/') 46 | }) 47 | }) 48 | }) 49 | }) 50 | 51 | describe('getLocaleRouteName', () => { 52 | describe('strategy: prefix_and_default', () => { 53 | it('should be `route1___en___default`', () => { 54 | assert.equal( 55 | getLocaleRouteName('route1', 'en', { 56 | defaultLocale: 'en', 57 | strategy: 'prefix_and_default', 58 | routesNameSeparator: '___', 59 | defaultLocaleRouteNameSuffix: 'default', 60 | }), 61 | 'route1___en___default', 62 | ) 63 | }) 64 | }) 65 | 66 | describe('strategy: prefix_except_default', () => { 67 | it('should be `route1___en`', () => { 68 | assert.equal( 69 | getLocaleRouteName('route1', 'en', { 70 | defaultLocale: 'en', 71 | strategy: 'prefix_except_default', 72 | routesNameSeparator: '___', 73 | defaultLocaleRouteNameSuffix: 'default', 74 | }), 75 | 'route1___en', 76 | ) 77 | }) 78 | }) 79 | 80 | describe('strategy: no_prefix', () => { 81 | it('should be `route1`', () => { 82 | assert.equal( 83 | getLocaleRouteName('route1', 'en', { 84 | defaultLocale: 'en', 85 | strategy: 'no_prefix', 86 | routesNameSeparator: '___', 87 | defaultLocaleRouteNameSuffix: 'default', 88 | }), 89 | 'route1', 90 | ) 91 | }) 92 | }) 93 | 94 | describe('irregular', () => { 95 | describe('route name is null', () => { 96 | it('should be ` (null)___en___default`', () => { 97 | assert.equal( 98 | getLocaleRouteName(null, 'en', { 99 | defaultLocale: 'en', 100 | strategy: 'prefix_and_default', 101 | routesNameSeparator: '___', 102 | defaultLocaleRouteNameSuffix: 'default', 103 | }), 104 | '(null)___en___default', 105 | ) 106 | }) 107 | }) 108 | }) 109 | }) 110 | -------------------------------------------------------------------------------- /test/unit/__snapshots__/routes.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`localizeRoutes > Route optiosn resolver: routing disable > should be disabled routing 1`] = ` 4 | [ 5 | { 6 | "name": "home", 7 | "path": "/", 8 | }, 9 | { 10 | "name": "about", 11 | "path": "/about", 12 | }, 13 | ] 14 | `; 15 | 16 | exports[`localizeRoutes > basic > should be localized routing 1`] = ` 17 | [ 18 | { 19 | "name": "home___en", 20 | "path": "/en", 21 | }, 22 | { 23 | "name": "home___ja", 24 | "path": "/ja", 25 | }, 26 | { 27 | "name": "about___en", 28 | "path": "/en/about", 29 | }, 30 | { 31 | "name": "about___ja", 32 | "path": "/ja/about", 33 | }, 34 | ] 35 | `; 36 | 37 | exports[`localizeRoutes > has children > should be localized routing 1`] = ` 38 | [ 39 | { 40 | "children": [ 41 | { 42 | "name": "user-profile___en", 43 | "path": "profile", 44 | }, 45 | { 46 | "name": "user-posts___en", 47 | "path": "posts", 48 | }, 49 | ], 50 | "name": "user___en", 51 | "path": "/en/user/:id", 52 | }, 53 | { 54 | "children": [ 55 | { 56 | "name": "user-profile___ja", 57 | "path": "profile", 58 | }, 59 | { 60 | "name": "user-posts___ja", 61 | "path": "posts", 62 | }, 63 | ], 64 | "name": "user___ja", 65 | "path": "/ja/user/:id", 66 | }, 67 | ] 68 | `; 69 | 70 | exports[`localizeRoutes > route name separator > should be localized routing 1`] = ` 71 | [ 72 | { 73 | "name": "home__en", 74 | "path": "/en", 75 | }, 76 | { 77 | "name": "home__ja", 78 | "path": "/ja", 79 | }, 80 | { 81 | "name": "about__en", 82 | "path": "/en/about", 83 | }, 84 | { 85 | "name": "about__ja", 86 | "path": "/ja/about", 87 | }, 88 | ] 89 | `; 90 | 91 | exports[`localizeRoutes > strategy: "no_prefix" > should be localized routing 1`] = ` 92 | [ 93 | { 94 | "name": "home", 95 | "path": "/", 96 | }, 97 | { 98 | "name": "about", 99 | "path": "/about", 100 | }, 101 | ] 102 | `; 103 | 104 | exports[`localizeRoutes > strategy: "prefix" > should be localized routing 1`] = ` 105 | [ 106 | { 107 | "name": "home", 108 | "path": "/", 109 | }, 110 | { 111 | "name": "home___en", 112 | "path": "/en", 113 | }, 114 | { 115 | "name": "home___ja", 116 | "path": "/ja", 117 | }, 118 | { 119 | "name": "about", 120 | "path": "/about", 121 | }, 122 | { 123 | "name": "about___en", 124 | "path": "/en/about", 125 | }, 126 | { 127 | "name": "about___ja", 128 | "path": "/ja/about", 129 | }, 130 | ] 131 | `; 132 | 133 | exports[`localizeRoutes > strategy: "prefix_and_default" > should be localized routing 1`] = ` 134 | [ 135 | { 136 | "name": "home___en___default", 137 | "path": "/", 138 | }, 139 | { 140 | "name": "home___en", 141 | "path": "/en", 142 | }, 143 | { 144 | "name": "home___ja", 145 | "path": "/ja", 146 | }, 147 | { 148 | "name": "about___en___default", 149 | "path": "/about", 150 | }, 151 | { 152 | "name": "about___en", 153 | "path": "/en/about", 154 | }, 155 | { 156 | "name": "about___ja", 157 | "path": "/ja/about", 158 | }, 159 | ] 160 | `; 161 | 162 | exports[`localizeRoutes > strategy: "prefix_except_default" > should be localized routing 1`] = ` 163 | [ 164 | { 165 | "name": "home___en", 166 | "path": "/", 167 | }, 168 | { 169 | "name": "home___ja", 170 | "path": "/ja", 171 | }, 172 | { 173 | "name": "about___en", 174 | "path": "/about", 175 | }, 176 | { 177 | "name": "about___ja", 178 | "path": "/ja/about", 179 | }, 180 | ] 181 | `; 182 | 183 | exports[`localizeRoutes > trailing slash > should be localized routing 1`] = ` 184 | [ 185 | { 186 | "name": "home___en", 187 | "path": "/en/", 188 | }, 189 | { 190 | "name": "home___ja", 191 | "path": "/ja/", 192 | }, 193 | { 194 | "name": "about___en", 195 | "path": "/en/about/", 196 | }, 197 | { 198 | "name": "about___ja", 199 | "path": "/ja/about/", 200 | }, 201 | ] 202 | `; 203 | -------------------------------------------------------------------------------- /test/__snapshots__/e2e.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`nuxt-i18n > builds routes tree 1`] = ` 4 | [ 5 | { 6 | "children": [], 7 | "enterCallbacks": {}, 8 | "instances": {}, 9 | "leaveGuards": {}, 10 | "meta": {}, 11 | "name": "test-composables___en", 12 | "path": "/en/test/composables", 13 | "props": { 14 | "default": false, 15 | }, 16 | "updateGuards": {}, 17 | }, 18 | { 19 | "children": [], 20 | "enterCallbacks": {}, 21 | "instances": {}, 22 | "leaveGuards": {}, 23 | "meta": {}, 24 | "name": "test-composables___de", 25 | "path": "/de/test/composables", 26 | "props": { 27 | "default": false, 28 | }, 29 | "updateGuards": {}, 30 | }, 31 | { 32 | "children": [], 33 | "enterCallbacks": {}, 34 | "instances": {}, 35 | "leaveGuards": {}, 36 | "meta": {}, 37 | "name": "test-i18n___en", 38 | "path": "/en/test/i18n", 39 | "props": { 40 | "default": false, 41 | }, 42 | "updateGuards": {}, 43 | }, 44 | { 45 | "children": [], 46 | "enterCallbacks": {}, 47 | "instances": {}, 48 | "leaveGuards": {}, 49 | "meta": {}, 50 | "name": "test-i18n___de", 51 | "path": "/de/test/i18n", 52 | "props": { 53 | "default": false, 54 | }, 55 | "updateGuards": {}, 56 | }, 57 | { 58 | "children": [], 59 | "enterCallbacks": {}, 60 | "instances": {}, 61 | "leaveGuards": {}, 62 | "meta": {}, 63 | "name": "test-routes___en", 64 | "path": "/en/test/routes", 65 | "props": { 66 | "default": false, 67 | }, 68 | "updateGuards": {}, 69 | }, 70 | { 71 | "children": [], 72 | "enterCallbacks": {}, 73 | "instances": {}, 74 | "leaveGuards": {}, 75 | "meta": {}, 76 | "name": "test-routes___de", 77 | "path": "/de/test/routes", 78 | "props": { 79 | "default": false, 80 | }, 81 | "updateGuards": {}, 82 | }, 83 | { 84 | "children": [], 85 | "enterCallbacks": {}, 86 | "instances": {}, 87 | "leaveGuards": {}, 88 | "meta": {}, 89 | "name": "about___en", 90 | "path": "/en/about", 91 | "props": { 92 | "default": false, 93 | }, 94 | "updateGuards": {}, 95 | }, 96 | { 97 | "children": [], 98 | "enterCallbacks": {}, 99 | "instances": {}, 100 | "leaveGuards": {}, 101 | "meta": {}, 102 | "name": "about___de", 103 | "path": "/de/ueber-uns", 104 | "props": { 105 | "default": false, 106 | }, 107 | "updateGuards": {}, 108 | }, 109 | { 110 | "children": [], 111 | "enterCallbacks": {}, 112 | "instances": {}, 113 | "leaveGuards": {}, 114 | "meta": {}, 115 | "name": "test___en", 116 | "path": "/en/test", 117 | "props": { 118 | "default": false, 119 | }, 120 | "updateGuards": {}, 121 | }, 122 | { 123 | "children": [], 124 | "enterCallbacks": {}, 125 | "instances": {}, 126 | "leaveGuards": {}, 127 | "meta": {}, 128 | "name": "test___de", 129 | "path": "/de/test", 130 | "props": { 131 | "default": false, 132 | }, 133 | "updateGuards": {}, 134 | }, 135 | { 136 | "children": [], 137 | "enterCallbacks": {}, 138 | "instances": {}, 139 | "leaveGuards": {}, 140 | "meta": {}, 141 | "name": "index___en", 142 | "path": "/en", 143 | "props": { 144 | "default": false, 145 | }, 146 | "updateGuards": {}, 147 | }, 148 | { 149 | "children": [], 150 | "enterCallbacks": {}, 151 | "instances": {}, 152 | "leaveGuards": {}, 153 | "meta": {}, 154 | "name": "index___de", 155 | "path": "/de", 156 | "props": { 157 | "default": false, 158 | }, 159 | "updateGuards": {}, 160 | }, 161 | { 162 | "children": [], 163 | "enterCallbacks": {}, 164 | "instances": {}, 165 | "leaveGuards": {}, 166 | "meta": {}, 167 | "name": "id___de", 168 | "path": "/de/:id(.*)*", 169 | "props": { 170 | "default": false, 171 | }, 172 | "updateGuards": {}, 173 | }, 174 | { 175 | "children": [], 176 | "enterCallbacks": {}, 177 | "instances": {}, 178 | "leaveGuards": {}, 179 | "meta": {}, 180 | "name": "id___en", 181 | "path": "/:id(.*)*", 182 | "props": { 183 | "default": false, 184 | }, 185 | "updateGuards": {}, 186 | }, 187 | ] 188 | `; 189 | 190 | exports[`nuxt-i18n > contains i18n data 1`] = ` 191 | { 192 | "defaultLocale": "en", 193 | "locale": "en", 194 | "locales": [ 195 | "en", 196 | "de", 197 | ], 198 | "message": "Home", 199 | } 200 | `; 201 | 202 | exports[`nuxt-i18n > renders message for "de" 1`] = ` 203 | { 204 | "translations": [ 205 | "Start", 206 | "unknown", 207 | ], 208 | } 209 | `; 210 | 211 | exports[`nuxt-i18n > renders message for default locale 1`] = ` 212 | { 213 | "translations": [ 214 | "Home", 215 | "unknown", 216 | ], 217 | } 218 | `; 219 | 220 | exports[`nuxt-i18n > returns composables data for "de" 1`] = ` 221 | { 222 | "useLocalizedPath": [ 223 | "/en/test/composables", 224 | "/de/test/composables", 225 | ], 226 | "useRouteLocale": "de", 227 | } 228 | `; 229 | 230 | exports[`nuxt-i18n > returns composables data for default locale 1`] = ` 231 | { 232 | "useLocalizedPath": [ 233 | "/en/test/composables", 234 | "/de/test/composables", 235 | ], 236 | "useRouteLocale": "en", 237 | } 238 | `; 239 | -------------------------------------------------------------------------------- /src/module.ts: -------------------------------------------------------------------------------- 1 | import { resolve as pathResolve } from 'pathe' 2 | import { genDynamicImport, genImport, genSafeVariableName } from 'knitwork' 3 | import { addImportsDir, addPluginTemplate, addTemplate, createResolver, defineNuxtModule } from '@nuxt/kit' 4 | import type { LocaleMessages } from '@leanera/vue-i18n' 5 | import { DEFAULT_LOCALE, DEFAULT_LOCALE_ROUTE_NAME_SUFFIX, DEFAULT_ROUTES_NAME_SEPARATOR } from './constants' 6 | import { resolveLocales } from './locales' 7 | import { setupPages } from './pages' 8 | import { logger } from './utils' 9 | import type { CustomRoutePages, LocaleInfo, Strategies } from './types' 10 | 11 | export interface ModuleOptions { 12 | /** 13 | * List of locales supported by your app 14 | * 15 | * @remarks 16 | * Intended to be an array of string codes, e.g. `['en', 'fr']` 17 | * 18 | * @default [] 19 | */ 20 | locales?: string[] 21 | 22 | /** 23 | * The app's default locale 24 | * 25 | * @remarks 26 | * It's recommended to set this to some locale regardless of the chosen strategy, as it will be used as a fallback locale 27 | * 28 | * @default 'en' 29 | */ 30 | defaultLocale?: string 31 | 32 | /** 33 | * Directory where your locale files are stored 34 | * 35 | * @remarks 36 | * Expected to be a relative path from the project root 37 | * 38 | * @default 'locales' 39 | */ 40 | langDir?: string 41 | 42 | /** 43 | * Whether to enable locale auto-importing 44 | * 45 | * @remarks 46 | * When enabled, the module will automatically import all locale files from the `langDir` directory 47 | * 48 | * @default false 49 | */ 50 | langImports?: boolean 51 | 52 | /** 53 | * Whether to lazy-load locale messages in the client 54 | * 55 | * @remarks 56 | * If enabled, locale messages will be loaded on demand when the user navigates to a route with a different locale 57 | * 58 | * This has no effect if the `langImports` option is disabled 59 | * 60 | * Note: When `strategy` is set to `no_prefix`, use the `useLazyLocaleSwitch` composable to ensure the translation messages are loaded before switching locales 61 | * 62 | * @default false 63 | */ 64 | lazy?: boolean 65 | 66 | /** 67 | * The app's default messages 68 | * 69 | * @remarks 70 | * Can be omitted if auto-importing of locales is enabled 71 | * 72 | * @default {} 73 | */ 74 | messages?: LocaleMessages 75 | 76 | /** 77 | * Routes strategy 78 | * 79 | * @remarks 80 | * Can be set to one of the following: 81 | * 82 | * - `no_prefix`: routes won't have a locale prefix 83 | * - `prefix_except_default`: locale prefix added for every locale except default 84 | * - `prefix`: locale prefix added for every locale 85 | * - `prefix_and_default`: locale prefix added for every locale and default 86 | * 87 | * @default 'no_prefix' 88 | */ 89 | strategy?: Strategies 90 | 91 | /** 92 | * Customize the names of the paths for a specific locale 93 | * 94 | * @remarks 95 | * In some cases, you might want to translate URLs in addition to having them prefixed with the locale code 96 | * 97 | * @example 98 | * pages: { 99 | * about: { 100 | * en: '/about-us', // Accessible at `/en/about-us` 101 | * fr: '/a-propos', // Accessible at `/fr/a-propos` 102 | * es: '/sobre' // Accessible at `/es/sobre` 103 | * } 104 | * } 105 | * @default {} 106 | */ 107 | pages?: CustomRoutePages 108 | 109 | /** 110 | * Custom route overrides for the generated routes 111 | * 112 | * @example 113 | * routeOverrides: { 114 | * // Use `en` catch-all page as fallback for non-existing pages 115 | * '/en/:id(.*)*': '/:id(.*)*' 116 | * } 117 | * 118 | * @default {} 119 | */ 120 | routeOverrides?: Record 121 | 122 | /** 123 | * Print verbose debug information to the console during development mode 124 | * 125 | * @remarks 126 | * For example the list of localized routes (if enabled) 127 | * 128 | * @default false 129 | */ 130 | logs?: boolean 131 | } 132 | 133 | export default defineNuxtModule({ 134 | meta: { 135 | name: '@leanera/nuxt-i18n', 136 | configKey: 'i18n', 137 | compatibility: { 138 | nuxt: '^3', 139 | bridge: false, 140 | }, 141 | }, 142 | defaults: { 143 | defaultLocale: DEFAULT_LOCALE, 144 | locales: [], 145 | langDir: 'locales', 146 | langImports: false, 147 | messages: {}, 148 | strategy: 'no_prefix', 149 | pages: {}, 150 | routeOverrides: {}, 151 | lazy: false, 152 | logs: false, 153 | }, 154 | async setup(options, nuxt) { 155 | const { resolve } = createResolver(import.meta.url) 156 | const langPath = (options.langImports && options.langDir) ? pathResolve(nuxt.options.srcDir, options.langDir!) : undefined 157 | const localeInfo = langPath ? await resolveLocales(langPath) : [] 158 | 159 | if (!options.defaultLocale) { 160 | logger.warn('Missing default locale, falling back to `en`') 161 | options.defaultLocale = 'en' 162 | } 163 | 164 | if (!options.locales?.length) { 165 | logger.warn('Locales option is empty, falling back to using the default locale only') 166 | options.locales = [options.defaultLocale] 167 | } 168 | 169 | const syncLocaleFiles = new Set() 170 | const asyncLocaleFiles = new Set() 171 | 172 | if (langPath) { 173 | // Synchronously import locale messages for the default locale 174 | const localeObject = localeInfo.find(({ code }) => code === options.defaultLocale) 175 | if (localeObject) 176 | syncLocaleFiles.add(localeObject) 177 | 178 | // Import locale messages for the other locales 179 | for (const locale of localeInfo) { 180 | if (!syncLocaleFiles.has(locale) && !asyncLocaleFiles.has(locale)) 181 | (options.lazy ? asyncLocaleFiles : syncLocaleFiles).add(locale) 182 | } 183 | } 184 | 185 | // Transpile runtime 186 | nuxt.options.build.transpile.push(resolve('runtime')) 187 | 188 | // Setup localized pages 189 | if (options.strategy !== 'no_prefix') 190 | setupPages(options as Required, nuxt) 191 | 192 | // Add i18n plugin 193 | addPluginTemplate(resolve('runtime/plugin')) 194 | 195 | // Add i18n composables 196 | addImportsDir(resolve('runtime/composables')) 197 | 198 | // Load options template 199 | addTemplate({ 200 | filename: 'i18n.options.mjs', 201 | getContents() { 202 | return ` 203 | export const DEFAULT_LOCALE_ROUTE_NAME_SUFFIX = ${JSON.stringify(DEFAULT_LOCALE_ROUTE_NAME_SUFFIX)}; 204 | export const DEFAULT_ROUTES_NAME_SEPARATOR = ${JSON.stringify(DEFAULT_ROUTES_NAME_SEPARATOR)}; 205 | ${[...syncLocaleFiles] 206 | .map(({ code, path }) => genImport(path, genSafeVariableName(`locale_${code}`))) 207 | .join('\n')} 208 | export const options = ${JSON.stringify(options, null, 2)}; 209 | export const localeMessages = { 210 | ${[...syncLocaleFiles] 211 | .map(({ code }) => ` ${JSON.stringify(code)}: () => Promise.resolve(${genSafeVariableName(`locale_${code}`)}),`) 212 | .join('\n')} 213 | ${[...asyncLocaleFiles] 214 | .map(({ code, path }) => ` ${JSON.stringify(code)}: ${genDynamicImport(path)},`) 215 | .join('\n')} 216 | }; 217 | `.trimStart() 218 | }, 219 | }) 220 | 221 | addTemplate({ 222 | filename: 'i18n.options.d.ts', 223 | getContents() { 224 | return ` 225 | ${genImport(resolve('module'), ['ModuleOptions'])} 226 | export declare const DEFAULT_LOCALE_ROUTE_NAME_SUFFIX: ${JSON.stringify(DEFAULT_LOCALE_ROUTE_NAME_SUFFIX)}; 227 | export declare const DEFAULT_ROUTES_NAME_SEPARATOR: ${JSON.stringify(DEFAULT_ROUTES_NAME_SEPARATOR)}; 228 | export declare const options: Required; 229 | export declare const localeMessages: Record Promise>>; 230 | `.trimStart() 231 | }, 232 | }) 233 | }, 234 | }) 235 | -------------------------------------------------------------------------------- /test/unit/routes.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest' 2 | import type { NuxtPage } from '@nuxt/schema' 3 | import { DEFAULT_ROUTES_NAME_SEPARATOR } from '../../src/constants' 4 | import { localizeRoutes } from '../../src/routes' 5 | 6 | // Adapted from: https://github.com/intlify/routing/blob/166e3c533ee47b40bc08e3af001bef33dcf975ed/packages/vue-i18n-routing/src/__test__/resolve.test.ts 7 | // Credit: Kazuya Kawaguchi, @intlify 8 | // License: MIT 9 | describe('localizeRoutes', () => { 10 | describe('basic', () => { 11 | it('should be localized routing', () => { 12 | const routes: NuxtPage[] = [ 13 | { 14 | path: '/', 15 | name: 'home', 16 | }, 17 | { 18 | path: '/about', 19 | name: 'about', 20 | }, 21 | ] 22 | const localeCodes = ['en', 'ja'] 23 | const localizedRoutes = localizeRoutes(routes, { 24 | locales: localeCodes, 25 | }) 26 | 27 | expect(localizedRoutes).toMatchSnapshot() 28 | expect(localizedRoutes.length).to.equal(4) 29 | 30 | for (const locale of localeCodes) { 31 | for (const route of routes) { 32 | expect(localizedRoutes).to.deep.include({ 33 | path: `/${locale}${route.path === '/' ? '' : route.path}`, 34 | name: `${route.name}${DEFAULT_ROUTES_NAME_SEPARATOR}${locale}`, 35 | }) 36 | } 37 | } 38 | }) 39 | }) 40 | 41 | describe('has children', () => { 42 | it('should be localized routing', () => { 43 | const routes: NuxtPage[] = [ 44 | { 45 | path: '/user/:id', 46 | name: 'user', 47 | children: [ 48 | { 49 | path: 'profile', 50 | name: 'user-profile', 51 | }, 52 | { 53 | path: 'posts', 54 | name: 'user-posts', 55 | }, 56 | ], 57 | }, 58 | ] 59 | const children = routes[0].children! 60 | 61 | const localeCodes = ['en', 'ja'] 62 | const localizedRoutes = localizeRoutes(routes, { 63 | locales: localeCodes, 64 | }) 65 | 66 | expect(localizedRoutes).toMatchSnapshot() 67 | expect(localizedRoutes.length).to.equal(2) 68 | 69 | for (const locale of localeCodes) { 70 | for (const route of routes) { 71 | expect(localizedRoutes).to.deep.include({ 72 | path: `/${locale}${route.path === '/' ? '' : route.path}`, 73 | name: `${route.name}${DEFAULT_ROUTES_NAME_SEPARATOR}${locale}`, 74 | children: children.map(child => ({ 75 | path: child.path, 76 | name: `${child.name}${DEFAULT_ROUTES_NAME_SEPARATOR}${locale}`, 77 | })), 78 | }) 79 | } 80 | } 81 | }) 82 | }) 83 | 84 | describe('trailing slash', () => { 85 | it('should be localized routing', () => { 86 | const routes: NuxtPage[] = [ 87 | { 88 | path: '/', 89 | name: 'home', 90 | }, 91 | { 92 | path: '/about', 93 | name: 'about', 94 | }, 95 | ] 96 | const localeCodes = ['en', 'ja'] 97 | const localizedRoutes = localizeRoutes(routes, { 98 | locales: localeCodes, 99 | trailingSlash: true, 100 | }) 101 | 102 | expect(localizedRoutes).toMatchSnapshot() 103 | expect(localizedRoutes.length).to.equal(4) 104 | 105 | for (const locale of localeCodes) { 106 | for (const route of routes) { 107 | expect(localizedRoutes).to.deep.include({ 108 | path: `/${locale}${route.path === '/' ? '' : route.path}/`, 109 | name: `${route.name}${DEFAULT_ROUTES_NAME_SEPARATOR}${locale}`, 110 | }) 111 | } 112 | } 113 | }) 114 | }) 115 | 116 | describe('route name separator', () => { 117 | it('should be localized routing', () => { 118 | const routes: NuxtPage[] = [ 119 | { 120 | path: '/', 121 | name: 'home', 122 | }, 123 | { 124 | path: '/about', 125 | name: 'about', 126 | }, 127 | ] 128 | const localeCodes = ['en', 'ja'] 129 | const localizedRoutes = localizeRoutes(routes, { 130 | locales: localeCodes, 131 | routesNameSeparator: '__', 132 | }) 133 | 134 | expect(localizedRoutes).toMatchSnapshot() 135 | expect(localizedRoutes.length).to.equal(4) 136 | 137 | for (const locale of localeCodes) { 138 | for (const route of routes) { 139 | expect(localizedRoutes).to.deep.include({ 140 | path: `/${locale}${route.path === '/' ? '' : route.path}`, 141 | name: `${route.name}${'__'}${locale}`, 142 | }) 143 | } 144 | } 145 | }) 146 | }) 147 | 148 | describe('strategy: "prefix_and_default"', () => { 149 | it('should be localized routing', () => { 150 | const routes: NuxtPage[] = [ 151 | { 152 | path: '/', 153 | name: 'home', 154 | }, 155 | { 156 | path: '/about', 157 | name: 'about', 158 | }, 159 | ] 160 | const localeCodes = ['en', 'ja'] 161 | const localizedRoutes = localizeRoutes(routes, { 162 | defaultLocale: 'en', 163 | strategy: 'prefix_and_default', 164 | locales: localeCodes, 165 | }) 166 | 167 | expect(localizedRoutes).toMatchSnapshot() 168 | }) 169 | }) 170 | 171 | describe('strategy: "prefix_except_default"', () => { 172 | it('should be localized routing', () => { 173 | const routes: NuxtPage[] = [ 174 | { 175 | path: '/', 176 | name: 'home', 177 | }, 178 | { 179 | path: '/about', 180 | name: 'about', 181 | }, 182 | ] 183 | const localeCodes = ['en', 'ja'] 184 | const localizedRoutes = localizeRoutes(routes, { 185 | defaultLocale: 'en', 186 | strategy: 'prefix_except_default', 187 | locales: localeCodes, 188 | }) 189 | 190 | expect(localizedRoutes).toMatchSnapshot() 191 | }) 192 | }) 193 | 194 | describe('strategy: "prefix"', () => { 195 | it('should be localized routing', () => { 196 | const routes: NuxtPage[] = [ 197 | { 198 | path: '/', 199 | name: 'home', 200 | }, 201 | { 202 | path: '/about', 203 | name: 'about', 204 | }, 205 | ] 206 | const localeCodes = ['en', 'ja'] 207 | const localizedRoutes = localizeRoutes(routes, { 208 | defaultLocale: 'en', 209 | strategy: 'prefix', 210 | locales: localeCodes, 211 | includeUprefixedFallback: true, 212 | }) 213 | 214 | expect(localizedRoutes).toMatchSnapshot() 215 | }) 216 | }) 217 | 218 | describe('strategy: "no_prefix"', () => { 219 | it('should be localized routing', () => { 220 | const routes: NuxtPage[] = [ 221 | { 222 | path: '/', 223 | name: 'home', 224 | }, 225 | { 226 | path: '/about', 227 | name: 'about', 228 | }, 229 | ] 230 | const localeCodes = ['en', 'ja'] 231 | const localizedRoutes = localizeRoutes(routes, { 232 | defaultLocale: 'en', 233 | strategy: 'no_prefix', 234 | locales: localeCodes, 235 | }) 236 | 237 | expect(localizedRoutes).toMatchSnapshot() 238 | }) 239 | }) 240 | 241 | describe('Route optiosn resolver: routing disable', () => { 242 | it('should be disabled routing', () => { 243 | const routes: NuxtPage[] = [ 244 | { 245 | path: '/', 246 | name: 'home', 247 | }, 248 | { 249 | path: '/about', 250 | name: 'about', 251 | }, 252 | ] 253 | const localeCodes = ['en', 'ja'] 254 | const localizedRoutes = localizeRoutes(routes, { 255 | locales: localeCodes, 256 | optionsResolver: () => null, 257 | }) 258 | 259 | expect(localizedRoutes).toMatchSnapshot() 260 | }) 261 | }) 262 | }) 263 | -------------------------------------------------------------------------------- /src/routes.ts: -------------------------------------------------------------------------------- 1 | import type { NuxtPage } from '@nuxt/schema' 2 | import { 3 | DEFAULT_LOCALE, 4 | DEFAULT_LOCALE_ROUTE_NAME_SUFFIX, 5 | DEFAULT_ROUTES_NAME_SEPARATOR, 6 | DEFAULT_STRATEGY, 7 | DEFAULT_TRAILING_SLASH, 8 | } from './constants' 9 | import { adjustRoutePathForTrailingSlash } from './utils' 10 | import type { Strategies } from './types' 11 | 12 | export interface ComputedRouteOptions { 13 | locales: readonly string[] 14 | paths: Record 15 | } 16 | 17 | export type RouteOptionsResolver = ( 18 | route: NuxtPage, 19 | localeCodes: string[] 20 | ) => ComputedRouteOptions | null 21 | 22 | export interface LocalizeRoutesPrefixableOptions { 23 | currentLocale: string 24 | defaultLocale: string 25 | strategy: Strategies 26 | isChild: boolean 27 | path: string 28 | } 29 | 30 | export type LocalizeRoutesPrefixable = ( 31 | options: LocalizeRoutesPrefixableOptions 32 | ) => boolean 33 | 34 | export interface I18nRoutingLocalizationOptions { 35 | /** 36 | * The app's default locale 37 | * 38 | * @default '' 39 | */ 40 | defaultLocale?: string 41 | /** 42 | * List of locales supported by the app 43 | * 44 | * @default [] 45 | */ 46 | locales?: string[] 47 | /** 48 | * Routes strategy 49 | * 50 | * @remarks 51 | * Can be set to one of the following: 52 | * 53 | * - `no_prefix`: routes won't have a locale prefix 54 | * - `prefix_except_default`: locale prefix added for every locale except default 55 | * - `prefix`: locale prefix added for every locale 56 | * - `prefix_and_default`: locale prefix added for every locale and default 57 | * 58 | * @default 'prefix_except_default' 59 | */ 60 | strategy?: Strategies 61 | /** 62 | * Whether to use trailing slash 63 | * 64 | * @default false 65 | */ 66 | trailingSlash?: boolean 67 | /** 68 | * Internal separator used for generated route names for each locale 69 | * 70 | * @default '___' 71 | */ 72 | routesNameSeparator?: string 73 | /** 74 | * Internal suffix added to generated route names for default locale 75 | * 76 | * @default 'default' 77 | */ 78 | defaultLocaleRouteNameSuffix?: string 79 | /** 80 | * Whether to prefix the localize route path with the locale or not 81 | * 82 | * @default {@link DefaultLocalizeRoutesPrefixable} 83 | */ 84 | localizeRoutesPrefixable?: LocalizeRoutesPrefixable 85 | /** 86 | * Whether to include uprefixed fallback route 87 | * 88 | * @default false 89 | */ 90 | includeUprefixedFallback?: boolean 91 | /** 92 | * Resolver for route localizing options 93 | * 94 | * @default undefined 95 | */ 96 | optionsResolver?: RouteOptionsResolver 97 | } 98 | 99 | function prefixable(options: LocalizeRoutesPrefixableOptions): boolean { 100 | const { currentLocale, defaultLocale, strategy, isChild, path } = options 101 | 102 | const isDefaultLocale = currentLocale === defaultLocale 103 | const isChildWithRelativePath = isChild && !path.startsWith('/') 104 | 105 | // No need to add prefix if child's path is relative 106 | return ( 107 | !isChildWithRelativePath 108 | // Skip default locale if strategy is `prefix_except_default` 109 | && !(isDefaultLocale && strategy === 'prefix_except_default') 110 | ) 111 | } 112 | 113 | export const DefaultLocalizeRoutesPrefixable = prefixable 114 | 115 | /** 116 | * Localize all routes with given locales 117 | * 118 | * @remarks 119 | * Based on [@intlify/routing](https://github.com/intlify/routing), licensed under MIT 120 | */ 121 | export function localizeRoutes( 122 | routes: NuxtPage[], 123 | { 124 | defaultLocale = DEFAULT_LOCALE, 125 | strategy = DEFAULT_STRATEGY as Strategies, 126 | trailingSlash = DEFAULT_TRAILING_SLASH, 127 | routesNameSeparator = DEFAULT_ROUTES_NAME_SEPARATOR, 128 | defaultLocaleRouteNameSuffix = DEFAULT_LOCALE_ROUTE_NAME_SUFFIX, 129 | includeUprefixedFallback = false, 130 | optionsResolver = undefined, 131 | localizeRoutesPrefixable = DefaultLocalizeRoutesPrefixable, 132 | locales = [], 133 | }: I18nRoutingLocalizationOptions = {}, 134 | ): NuxtPage[] { 135 | if (strategy === 'no_prefix') 136 | return routes 137 | 138 | function makeLocalizedRoutes( 139 | route: NuxtPage, 140 | allowedLocaleCodes: string[], 141 | isChild = false, 142 | isExtraPageTree = false, 143 | ): NuxtPage[] { 144 | // Skip route localization 145 | if (route.redirect && !route.file) 146 | return [route] 147 | 148 | // Resolve with route (page) options 149 | let routeOptions: ComputedRouteOptions | null = null 150 | if (optionsResolver != null) { 151 | routeOptions = optionsResolver(route, allowedLocaleCodes) 152 | if (routeOptions == null) 153 | return [route] 154 | } 155 | 156 | // Component specific options 157 | const componentOptions: ComputedRouteOptions = { 158 | locales, 159 | paths: {}, 160 | } 161 | 162 | if (routeOptions != null) 163 | Object.assign(componentOptions, routeOptions) 164 | 165 | Object.assign(componentOptions, { locales: allowedLocaleCodes }) 166 | 167 | // Double check locales to remove any locales not found in `pageOptions` 168 | // This is there to prevent children routes being localized even though they are disabled in the configuration 169 | if ( 170 | componentOptions.locales.length > 0 171 | && routeOptions 172 | && routeOptions.locales != null 173 | && routeOptions.locales.length > 0 174 | ) { 175 | componentOptions.locales = componentOptions.locales.filter( 176 | locale => routeOptions!.locales.includes(locale), 177 | ) 178 | } 179 | 180 | return componentOptions.locales.reduce((_routes, locale) => { 181 | const { name } = route 182 | let { path } = route 183 | const localizedRoute = { ...route } 184 | 185 | // Make localized page name 186 | if (name) 187 | localizedRoute.name = `${name}${routesNameSeparator}${locale}` 188 | 189 | // Generate localized children routes 190 | if (route.children) { 191 | localizedRoute.children = route.children.reduce>( 192 | (children, child) => [ 193 | ...children, 194 | ...makeLocalizedRoutes(child, [locale], true, isExtraPageTree), 195 | ], 196 | [], 197 | ) 198 | } 199 | 200 | // Get custom path if any 201 | if (componentOptions.paths && componentOptions.paths[locale]) 202 | path = componentOptions.paths[locale] 203 | 204 | // For `prefix_and_default` strategy and default locale: 205 | // - if it's a parent page, add it with default locale suffix added (no suffix if page has children) 206 | // - if it's a child page of that extra parent page, append default suffix to it 207 | const isDefaultLocale = locale === defaultLocale 208 | if (isDefaultLocale && strategy === 'prefix_and_default') { 209 | if (!isChild) { 210 | const defaultRoute = { ...localizedRoute, path } 211 | 212 | if (name) 213 | defaultRoute.name = `${localizedRoute.name}${routesNameSeparator}${defaultLocaleRouteNameSuffix}` 214 | 215 | if (route.children) { 216 | // Recreate child routes with default suffix added 217 | defaultRoute.children = [] 218 | for (const childRoute of route.children) { 219 | // `isExtraPageTree` argument is true to indicate that this is extra route added for `prefix_and_default` strategy 220 | defaultRoute.children.push( 221 | ...makeLocalizedRoutes(childRoute, [locale], true, true), 222 | ) 223 | } 224 | } 225 | 226 | _routes.push(defaultRoute) 227 | } 228 | else if (isChild && isExtraPageTree && name) { 229 | localizedRoute.name += `${routesNameSeparator}${defaultLocaleRouteNameSuffix}` 230 | } 231 | } 232 | 233 | const isChildWithRelativePath = isChild && !path.startsWith('/') 234 | 235 | // Add route prefix 236 | const shouldAddPrefix = localizeRoutesPrefixable({ 237 | isChild, 238 | path, 239 | currentLocale: locale, 240 | defaultLocale, 241 | strategy, 242 | }) 243 | if (shouldAddPrefix) 244 | path = `/${locale}${path}` 245 | 246 | if (path) { 247 | path = adjustRoutePathForTrailingSlash( 248 | path, 249 | trailingSlash, 250 | isChildWithRelativePath, 251 | ) 252 | } 253 | 254 | if (shouldAddPrefix && isDefaultLocale && strategy === 'prefix' && includeUprefixedFallback) 255 | _routes.push({ ...route }) 256 | 257 | localizedRoute.path = path 258 | _routes.push(localizedRoute) 259 | 260 | return _routes 261 | }, []) 262 | } 263 | 264 | return routes.reduce( 265 | (localized, route) => [ 266 | ...localized, 267 | ...makeLocalizedRoutes(route, locales || []), 268 | ], 269 | [], 270 | ) 271 | } 272 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | > [!WARNING] 2 | > The development of this module will continue at the repository [@byjohann/nuxt-i18n](https://github.com/johannschopplich/nuxt-i18n) under the scope `@byjohann`. 3 | > Please migrate to the new package as this one will be deprecated soon. 4 | 5 | **** 6 | 7 | ![Nuxt i18n module](./.github/og.png) 8 | 9 | # @leanera/nuxt-i18n 10 | 11 | [![npm version](https://img.shields.io/npm/v/@leanera/nuxt-i18n?color=a1b858&label=)](https://www.npmjs.com/package/@leanera/nuxt-i18n) 12 | 13 | > [Nuxt 3](https://nuxt.com) module for internationalization with locale auto-imports & localized routing. 14 | 15 | This module's intention is not to provide a full-blown solution for internationalization like [@nuxtjs/i18n](https://i18n.nuxtjs.org/), but offer a lean, effective and lightweight set of tools to cover your needs without the bloat of a full-blown solution. 16 | 17 | ## Key Features 18 | 19 | - 🪡 Integration with [@leanera/vue-i18n](https://github.com/leanera/vue-i18n) 20 | - 🗜 Composable usage with [`useI18n`](#usei18n) 21 | - 🪢 [Auto-importable](#auto-importing--lazy-loading-translations) locale messages (JSON/YAML support) 22 | - 💇‍♀️ [Lazy-loading](#auto-importing--lazy-loading-translations) of translation messages 23 | - 🛣 [Automatic routes generation](#routing--strategies) and custom paths 24 | 25 | ## Setup 26 | 27 | ```bash 28 | # pnpm 29 | pnpm add -D @leanera/nuxt-i18n 30 | 31 | # npm 32 | npm i -D @leanera/nuxt-i18n 33 | ``` 34 | 35 | ## Basic Usage 36 | 37 | > [📖 Check out the playground](./playground/) 38 | 39 | Add `@leanera/nuxt-i18n` to your `nuxt.confg.ts`: 40 | 41 | ```ts 42 | export default defineNuxtConfig({ 43 | modules: ['@leanera/nuxt-i18n'], 44 | }) 45 | ``` 46 | 47 | For the most basic setup, add the `locales` and `defaultLocales` module options with a set of translation `messages`: 48 | 49 | ```ts 50 | export default defineNuxtConfig({ 51 | modules: ['@leanera/nuxt-i18n'], 52 | 53 | i18n: { 54 | locales: ['en', 'de'], 55 | defaultLocale: 'en', 56 | messages: { 57 | en: { welcome: 'Welcome' }, 58 | de: { welcome: 'Willkommen' } 59 | } 60 | } 61 | }) 62 | ``` 63 | 64 | Use the globally available `useI18n` composable in your component's `setup` hook: 65 | 66 | ```vue 67 | 70 | 71 | 75 | ``` 76 | 77 | ## Guide 78 | 79 | ### Routing & Strategies 80 | 81 | You can opt-in to override the Nuxt default routes with added locale prefixes to every URL by using one of the built-in routing strategies. By default, the generated routes stay untouched (`no_prefix` strategy). 82 | 83 | For example, if your app supports two languages: German and English as the default language, and you have the following pages in your project: 84 | 85 | ``` 86 | └── pages/ 87 | ├── about/ 88 | │ └── index.vue 89 | └── index.vue 90 | ``` 91 | 92 | This would result in the following routes being generated for the `prefix_except_default` strategy: 93 | 94 |
95 | 🎄 Routes Tree 96 | 97 | ```ts 98 | [ 99 | { 100 | path: '/', 101 | name: 'index___en', 102 | // ... 103 | }, 104 | { 105 | path: '/de/', 106 | name: 'index___de', 107 | // ... 108 | }, 109 | { 110 | path: '/about', 111 | name: 'about___en', 112 | // ... 113 | }, 114 | { 115 | path: '/de/about', 116 | name: 'about___de', 117 | // ... 118 | } 119 | ] 120 | ``` 121 | 122 |
123 | 124 | > ℹ️ Note: Routes for the English version don't have a prefix because it is the default language. 125 | 126 | #### Available Strategies 127 | 128 | There are 4 supported strategies in total that affect how the app's routes are generated. 129 | 130 |
131 | 132 | ##### `no_prefix` (default) 133 | 134 |
135 | 136 | With this strategy, routes stay as they are generated by Nuxt. No locale prefix will be added. The locale can be changed without changing the URL. 137 | 138 |
139 | 140 | ##### `prefix_except_default` 141 | 142 |
143 | 144 | Using this strategy, all of your routes will have a locale prefix added except for the default language. 145 | 146 |
147 | 148 | ##### `prefix` 149 | 150 |
151 | 152 | With this strategy, all routes will have a locale prefix. 153 | 154 |
155 | 156 | ##### `prefix_and_default` 157 | 158 |
159 | 160 | This strategy combines both previous strategies behaviours, meaning that you will get URLs with prefixes for every language, but URLs for the default language will also have a non-prefixed version. This could lead to duplicated content. You will have to handle, which URL is preferred when navigating in your app. 161 | 162 |
163 | 164 | #### Configuration 165 | 166 | A strategy may be set using the `strategy` module option. Make sure that you have a `defaultLocale` defined in any case. 167 | 168 | ```ts 169 | export default defineNuxtConfig({ 170 | i18n: { 171 | locales: ['en', 'de'], 172 | defaultLocale: 'en', 173 | strategy: 'prefix_except_default', 174 | }, 175 | }) 176 | ``` 177 | 178 | ### Custom Route Paths 179 | 180 | In some cases, you might want to translate URLs in addition to having them prefixed with the locale code. For example, you might want to have a route like `/about` in English and `/ueber-uns` in German. You can achieve this by defining a custom path for the route in the `nuxt.config.ts` file: 181 | 182 | ```ts 183 | export default defineNuxtConfig({ 184 | i18n: { 185 | locales: ['en', 'de', 'fr'], 186 | defaultLocale: 'en', 187 | pages: { 188 | about: { 189 | de: '/ueber-uns', 190 | fr: '/a-propos' 191 | } 192 | } 193 | } 194 | }) 195 | ``` 196 | 197 | > ℹ️ Each key within the pages object should correspond to the relative file-based path (excluding the `.vue` file extension) of the route within your `pages` directory. 198 | 199 | Customized route paths must start with a `/` and not include the locale prefix. 200 | 201 | ### Auto-Importing & Lazy-Loading Translations 202 | 203 | For apps that contain a lot of translated content, it is preferable not to bundle all the messages in the main bundle, but rather lazy-load only the language that the users selected. By defining a directory where translation files are located, locale messages can be dynamically imported when the app loads or when the user switches to another language. 204 | 205 | However, you can also benefit from the advantages of auto-import without enabling dynamic imports. 206 | 207 | How to enable file-based translations with or without lazy-loading: 208 | 209 | - Set the `langImports` option to `true`. 210 | - Enable dynamic imports by setting the `lazy` option to `true`. 211 | - Optionally, configure the `langDir` option to a directory that contains your translation files. Defaults to `locales`. 212 | - Make sure the `locales` option covers possible languages. 213 | 214 | > ℹ️ Translation files must be called the same as their locale. Currently, JSON, JSON5 and YAML are supported. 215 | 216 | Example files structure: 217 | 218 | ``` 219 | ├── locales/ 220 | │ ├── en.json 221 | │ ├── es.json5 222 | │ ├── fr.yaml 223 | └── nuxt.config.js 224 | ``` 225 | 226 | Configuration example: 227 | 228 | ```ts 229 | export default defineNuxtConfig({ 230 | i18n: { 231 | locales: ['en', 'es', 'fr'], 232 | defaultLocale: 'en', 233 | langImports: true, 234 | lazy: true 235 | } 236 | }) 237 | ``` 238 | 239 | > ℹ️ If you prefer to import file-based translations but don't want to dynamically import them, omit the `lazy` module option, as it defaults to `false`. 240 | 241 | > ⚠️ The global route middleware to lazy-load translations when switching locales won't run when the `no_prefix` strategy is chosen. Use the `useLazyLocaleSwitch` composable for changing the language, it will load the corresponding translations beforehand. 242 | 243 | ### Manual Translations 244 | 245 | Instead of auto-importing (with or without lazy-loading), you can manually import your translations and merge them into the global locale messages object: 246 | 247 | ```ts 248 | // Import from JSON or an ES module 249 | import en from './locales/en.json' 250 | import de from './locales/de.json' 251 | 252 | export default defineNuxtConfig({ 253 | i18n: { 254 | locales: ['en', 'de'], 255 | defaultLocale: 'en', 256 | messages: { 257 | en, 258 | de, 259 | }, 260 | }, 261 | }) 262 | ``` 263 | 264 | The locale messages defined above will be passed as the `messages` option when initializing `@leanera/vue-i18n` with `createI18n()`. 265 | 266 | ## API 267 | 268 | ### Module Options 269 | 270 | ```ts 271 | interface ModuleOptions { 272 | /** 273 | * List of locales supported by your app 274 | * 275 | * @remarks 276 | * Intended to be an array of string codes, e.g. `['en', 'fr']` 277 | * 278 | * @default [] 279 | */ 280 | locales?: string[] 281 | 282 | /** 283 | * The app's default locale 284 | * 285 | * @remarks 286 | * It's recommended to set this to some locale regardless of the chosen strategy, as it will be used as a fallback locale 287 | * 288 | * @default 'en' 289 | */ 290 | defaultLocale?: string 291 | 292 | /** 293 | * Directory where your locale files are stored 294 | * 295 | * @remarks 296 | * Expected to be a relative path from the project root 297 | * 298 | * @default 'locales' 299 | */ 300 | langDir?: string 301 | 302 | /** 303 | * Whether to enable locale auto-importing 304 | * 305 | * @remarks 306 | * When enabled, the module will automatically import all locale files from the `langDir` directory 307 | * 308 | * @default false 309 | */ 310 | langImports?: boolean 311 | 312 | /** 313 | * Whether to lazy-load locale messages in the client 314 | * 315 | * @remarks 316 | * If enabled, locale messages will be loaded on demand when the user navigates to a route with a different locale 317 | * 318 | * This has no effect if the `langImports` option is disabled 319 | * 320 | * Note: When `strategy` is set to `no_prefix`, use the `useLazyLocaleSwitch` composable to ensure the translation messages are loaded before switching locales 321 | * 322 | * @default false 323 | */ 324 | lazy?: boolean 325 | 326 | /** 327 | * The app's default messages 328 | * 329 | * @remarks 330 | * Can be omitted if auto-importing of locales is enabled 331 | * 332 | * @default {} 333 | */ 334 | messages?: LocaleMessages 335 | 336 | /** 337 | * Routes strategy 338 | * 339 | * @remarks 340 | * Can be set to one of the following: 341 | * 342 | * - `no_prefix`: routes won't have a locale prefix 343 | * - `prefix_except_default`: locale prefix added for every locale except default 344 | * - `prefix`: locale prefix added for every locale 345 | * - `prefix_and_default`: locale prefix added for every locale and default 346 | * 347 | * @default 'no_prefix' 348 | */ 349 | strategy?: Strategies 350 | 351 | /** 352 | * Customize the names of the paths for a specific locale 353 | * 354 | * @remarks 355 | * In some cases, you might want to translate URLs in addition to having them prefixed with the locale code 356 | * 357 | * @example 358 | * pages: { 359 | * about: { 360 | * en: '/about-us', // Accessible at `/en/about-us` 361 | * fr: '/a-propos', // Accessible at `/fr/a-propos` 362 | * es: '/sobre' // Accessible at `/es/sobre` 363 | * } 364 | * } 365 | * @default {} 366 | */ 367 | pages?: CustomRoutePages 368 | 369 | /** 370 | * Custom route overrides for the generated routes 371 | * 372 | * @example 373 | * routeOverrides: { 374 | * // Use `en` catch-all page as fallback for non-existing pages 375 | * '/en/:id(.*)*': '/:id(.*)*' 376 | * } 377 | * 378 | * @default {} 379 | */ 380 | routeOverrides?: Record 381 | 382 | /** 383 | * Print verbose debug information to the console during development mode 384 | * 385 | * @remarks 386 | * For example the list of localized routes (if enabled) 387 | * 388 | * @default false 389 | */ 390 | logs?: boolean 391 | } 392 | ``` 393 | 394 | ### Composables 395 | 396 | #### `useI18n` 397 | 398 | Gives access to the current i18n instance. 399 | 400 | ```ts 401 | function useI18n(): UseI18n 402 | 403 | interface UseI18n { 404 | defaultLocale: string 405 | locale: ComputedRef 406 | locales: readonly string[] 407 | messages: LocaleMessages 408 | t: (key: string, params?: Record) => string 409 | setLocale: (locale: string) => void 410 | getLocale: () => string 411 | } 412 | ``` 413 | 414 | #### `useRouteLocale` 415 | 416 | Returns the current locale based on the route name. Preferred for strategies other than `no_prefix`. 417 | 418 | **Type Declarations** 419 | 420 | ```ts 421 | function useRouteLocale(): string 422 | ``` 423 | 424 | #### `useLocalizedPath` 425 | 426 | Returns a translated path for a given route. Preferred when working with all routing strategies except `no_prefix`. 427 | 428 | **Type Declarations** 429 | 430 | ```ts 431 | function useLocalizedPath( 432 | path: string, 433 | locale: string, 434 | ): string 435 | ``` 436 | 437 | **Example** 438 | 439 | ```ts 440 | const to = useLocalizedPath(useRoute().fullPath, 'de') 441 | useRouter().push(to) 442 | ``` 443 | 444 | #### `useLazyLocaleSwitch` 445 | 446 | Ensures to load the translation messages for the given locale before switching to it. Mostly needed for the `no_prefix` strategy. 447 | 448 | **Type Declarations** 449 | 450 | ```ts 451 | function useLazyLocaleSwitch(locale: string): Promise 452 | ``` 453 | 454 | **Example** 455 | 456 | ```ts 457 | await useLazyLocaleSwitch('en') 458 | ``` 459 | 460 | ## 💻 Development 461 | 462 | 1. Clone this repository 463 | 2. Enable [Corepack](https://github.com/nodejs/corepack) using `corepack enable` 464 | 3. Install dependencies using `pnpm install` 465 | 4. Run `pnpm run dev:prepare` 466 | 5. Start development server using `pnpm run dev` 467 | 468 | ## Credits 469 | 470 | - [Kazuya Kawaguchi](https://github.com/kazupon) for his work on [@intlify](https://github.com/intlify)'s [vue-i18n-next](https://github.com/intlify/vue-i18n-next), the next v8 alpha of [nuxt-i18n](https://github.com/kazupon/nuxt-i18n) as well as the i18n routing library [vue-i18n-routing](https://github.com/intlify/routing) 471 | 472 | ## License 473 | 474 | [MIT](./LICENSE) License © 2022-2023 [Johann Schopplich](https://github.com/johannschopplich) & [LeanERA GmbH](https://github.com/leanera) 475 | --------------------------------------------------------------------------------