├── .browserslistrc ├── src ├── lang │ ├── localized-urls │ │ ├── en.json │ │ ├── index.js │ │ └── cs.json │ ├── translations │ │ ├── index.js │ │ ├── en.json │ │ └── cs.json │ └── dateTimeFormats.js ├── assets │ ├── cs.png │ ├── en.png │ └── logo.png ├── views │ ├── PosInfo.vue │ ├── PosManager.vue │ ├── User.vue │ ├── Home.vue │ └── About.vue ├── main.js ├── components │ └── HelloWorld.vue ├── plugin │ ├── components │ │ ├── LocalizedLink.vue │ │ └── LanguageSwitcher.vue │ └── index.js ├── router │ └── index.js └── App.vue ├── jest.config.js ├── lang-router.gif ├── public ├── favicon.ico └── index.html ├── tests ├── setup │ ├── Test.vue │ ├── localized-urls.js │ ├── routes.js │ ├── i18nOptions.js │ └── translations.js └── unit │ └── LangRouter.spec.js ├── .stylelintrc.js ├── rollup.banner.txt ├── babel.config.js ├── .gitignore ├── helpers └── spread-polyfill.js ├── vue.config.js ├── .eslintrc.js ├── LICENSE ├── rollup.config.js ├── package.json ├── dist ├── lang-router.min.js ├── lang-router.esm.js ├── lang-router.js └── lang-router.umd.js └── README.md /.browserslistrc: -------------------------------------------------------------------------------- 1 | > 1% 2 | last 2 versions 3 | not dead 4 | -------------------------------------------------------------------------------- /src/lang/localized-urls/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "about": "about" 3 | } -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: '@vue/cli-plugin-unit-jest', 3 | }; 4 | -------------------------------------------------------------------------------- /lang-router.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adbrosaci/vue-lang-router/HEAD/lang-router.gif -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adbrosaci/vue-lang-router/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /src/assets/cs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adbrosaci/vue-lang-router/HEAD/src/assets/cs.png -------------------------------------------------------------------------------- /src/assets/en.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adbrosaci/vue-lang-router/HEAD/src/assets/en.png -------------------------------------------------------------------------------- /src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adbrosaci/vue-lang-router/HEAD/src/assets/logo.png -------------------------------------------------------------------------------- /tests/setup/Test.vue: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /src/lang/localized-urls/index.js: -------------------------------------------------------------------------------- 1 | import en from './en.json'; 2 | import cs from './cs.json'; 3 | 4 | export default { en, cs }; 5 | -------------------------------------------------------------------------------- /.stylelintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [ './node_modules/@radek-altof/lint-config/stylelint.json' ], 3 | rules: {}, 4 | }; 5 | -------------------------------------------------------------------------------- /rollup.banner.txt: -------------------------------------------------------------------------------- 1 | vue-lang-router v<%= pkg.version %> 2 | (c) 2021 <%= pkg.author.name %> 3 | Released under the <%= pkg.license %> License. -------------------------------------------------------------------------------- /src/views/PosInfo.vue: -------------------------------------------------------------------------------- 1 | 7 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: [ 3 | 'babel-plugin-rewire', 4 | ], 5 | presets: [ 6 | '@vue/cli-plugin-babel/preset', 7 | ], 8 | }; 9 | -------------------------------------------------------------------------------- /src/lang/localized-urls/cs.json: -------------------------------------------------------------------------------- 1 | { 2 | "about": "o-nas", 3 | "user": "uzivatel", 4 | "manager": "manazer", 5 | "pos": "pobocka", 6 | "replace-no-no": "Johnny" 7 | } -------------------------------------------------------------------------------- /src/views/PosManager.vue: -------------------------------------------------------------------------------- 1 | 9 | -------------------------------------------------------------------------------- /src/lang/translations/index.js: -------------------------------------------------------------------------------- 1 | import en from './en.json'; 2 | 3 | export default { 4 | en: { 5 | name: 'English', 6 | messages: en, 7 | }, 8 | cs: { 9 | name: 'Česky', 10 | load: () => { return import('./cs.json'); }, 11 | }, 12 | }; 13 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import App from './App.vue'; 3 | import router from './router'; 4 | import { i18n } from './plugin'; 5 | 6 | Vue.config.productionTip = false; 7 | 8 | new Vue({ 9 | router, 10 | i18n, 11 | render: h => h(App), 12 | }).$mount('#app'); 13 | -------------------------------------------------------------------------------- /tests/setup/localized-urls.js: -------------------------------------------------------------------------------- 1 | export default { 2 | en: { 3 | about: 'about-replaced', 4 | }, 5 | cs: { 6 | about: 'o-nas', 7 | user: 'uzivatel', 8 | info: 'detail', 9 | }, 10 | ru: { 11 | info: 'informatsiya', 12 | slug: 'should-not-be-replaced', 13 | }, 14 | }; 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | 4 | # local env files 5 | .env.local 6 | .env.*.local 7 | 8 | # Log files 9 | npm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | 13 | # Editor directories and files 14 | .idea 15 | .vscode 16 | *.suo 17 | *.ntvs* 18 | *.njsproj 19 | *.sln 20 | *.sw? 21 | -------------------------------------------------------------------------------- /src/lang/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "nav": { 3 | "home": "Home", 4 | "about": "About", 5 | "user": "User" 6 | }, 7 | "hello": "Hello, sunshine!", 8 | "content": "This is content", 9 | "user-slug": "User in URL is: ", 10 | "pos": { 11 | "info": "Branch info", 12 | "manager": "Branch manager" 13 | } 14 | } -------------------------------------------------------------------------------- /src/lang/translations/cs.json: -------------------------------------------------------------------------------- 1 | { 2 | "nav": { 3 | "home": "Domů", 4 | "about": "O nás", 5 | "user": "Uživatel" 6 | }, 7 | "hello": "Čus bejku!", 8 | "content": "Tohle je obsah", 9 | "user-slug": "Uživatel v URL je: ", 10 | "pos": { 11 | "info": "Informace o pobočce", 12 | "manager": "Manažer pobočky" 13 | } 14 | } -------------------------------------------------------------------------------- /src/views/User.vue: -------------------------------------------------------------------------------- 1 | 12 | -------------------------------------------------------------------------------- /src/views/Home.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 19 | -------------------------------------------------------------------------------- /src/components/HelloWorld.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 16 | 17 | 18 | 23 | -------------------------------------------------------------------------------- /tests/setup/routes.js: -------------------------------------------------------------------------------- 1 | import Test from './Test.vue'; 2 | 3 | export default [ 4 | { 5 | path: '/about', 6 | name: 'About page', 7 | component: () => import('./Test.vue'), 8 | }, 9 | { 10 | path: '/user/:slug', 11 | name: 'User page', 12 | component: { 13 | template: '', 14 | }, 15 | children: [ 16 | { 17 | path: 'info', 18 | name: 'User info', 19 | component: Test, 20 | }, 21 | ], 22 | }, 23 | ]; 24 | -------------------------------------------------------------------------------- /helpers/spread-polyfill.js: -------------------------------------------------------------------------------- 1 | function mergeObjects() { 2 | let targetObject = {}; 3 | 4 | for (let i = 0; i < arguments.length; i++) { 5 | let sourceObject = arguments[i]; 6 | 7 | for (let key in sourceObject) { 8 | if (Object.prototype.hasOwnProperty.call(sourceObject, key)) { 9 | targetObject[key] = sourceObject[key]; 10 | } 11 | } 12 | } 13 | 14 | return targetObject; 15 | } 16 | 17 | const _spread = Object.assign || mergeObjects; 18 | export default _spread; -------------------------------------------------------------------------------- /tests/setup/i18nOptions.js: -------------------------------------------------------------------------------- 1 | export const dateTimeFormats = { 2 | en: { 3 | long: { 4 | year: 'numeric', 5 | month: 'numeric', 6 | day: 'numeric', 7 | }, 8 | }, 9 | cs: { 10 | short: { 11 | weekday: 'long', 12 | hour: 'numeric', 13 | minute: 'numeric', 14 | }, 15 | }, 16 | }; 17 | 18 | export const pluralizationRules = { 19 | en: () => 1, 20 | cs: choice => { 21 | if (choice === 0) { return 0; } 22 | else { return 1; } 23 | }, 24 | ru: () => 2, 25 | }; 26 | -------------------------------------------------------------------------------- /tests/setup/translations.js: -------------------------------------------------------------------------------- 1 | export default { 2 | en: { 3 | name: 'English', 4 | load: () => Promise.resolve({ 5 | test: { 6 | content: 'This is an about page', 7 | }, 8 | }), 9 | }, 10 | cs: { 11 | name: 'Česky', 12 | load: () => Promise.resolve({ 13 | test: { 14 | content: 'Tohle je stránka O nás', 15 | }, 16 | }), 17 | }, 18 | ru: { 19 | name: 'русский', 20 | load: () => Promise.resolve({ 21 | test: { 22 | content: 'Это страница О нас', 23 | }, 24 | }), 25 | }, 26 | }; 27 | -------------------------------------------------------------------------------- /src/lang/dateTimeFormats.js: -------------------------------------------------------------------------------- 1 | export default { 2 | en: { 3 | short: { 4 | year: 'numeric', 5 | month: 'numeric', 6 | day: 'numeric', 7 | }, 8 | long: { 9 | year: 'numeric', 10 | month: 'long', 11 | day: 'numeric', 12 | weekday: 'short', 13 | hour: 'numeric', 14 | minute: 'numeric', 15 | }, 16 | }, 17 | cs: { 18 | short: { 19 | weekday: 'long', 20 | hour: 'numeric', 21 | minute: 'numeric', 22 | }, 23 | long: { 24 | year: 'numeric', 25 | month: 'long', 26 | day: 'numeric', 27 | }, 28 | }, 29 | }; 30 | -------------------------------------------------------------------------------- /vue.config.js: -------------------------------------------------------------------------------- 1 | const StyleLintPlugin = require('stylelint-webpack-plugin'); 2 | 3 | module.exports = { 4 | configureWebpack: { 5 | plugins: [ 6 | new StyleLintPlugin({ 7 | files: [ 'src/**/*.{vue,htm,html,css,sss,less,scss,sass}' ], 8 | fix: true, 9 | }), 10 | ], 11 | }, 12 | runtimeCompiler: true, 13 | chainWebpack: config => { 14 | config.module.rule('eslint').use('eslint-loader').options({ 15 | fix: true, 16 | }); 17 | config.module.rule('i18n').resourceQuery(/blockType=i18n/).type('javascript/auto').use('i18n').loader('@kazupon/vue-i18n-loader').end(); 18 | }, 19 | }; 20 | -------------------------------------------------------------------------------- /src/views/About.vue: -------------------------------------------------------------------------------- 1 | 2 | { 3 | "cs": { 4 | "heading": "Tohle je stránka O nás." 5 | }, 6 | "en": { 7 | "heading": "This is an About page." 8 | } 9 | } 10 | 11 | 12 | 18 | 19 | 34 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | node: true, 5 | }, 6 | extends: [ 7 | 'plugin:vue/essential', 8 | './node_modules/@radek-altof/lint-config/eslint.json', 9 | ], 10 | parserOptions: { 11 | parser: 'babel-eslint', 12 | }, 13 | rules: { 14 | 'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off', 15 | 'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off', 16 | 'vue/html-indent': [ 'error', 'tab' ], 17 | }, 18 | overrides: [ 19 | { 20 | files: [ 21 | '**/__tests__/*.{j,t}s?(x)', 22 | '**/tests/unit/**/*.spec.{j,t}s?(x)', 23 | ], 24 | env: { 25 | jest: true, 26 | }, 27 | }, 28 | ], 29 | }; 30 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | <%= htmlWebpackPlugin.options.title %> 9 | 10 | 11 | 14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/plugin/components/LocalizedLink.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Radek Altof 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/plugin/components/LanguageSwitcher.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 54 | -------------------------------------------------------------------------------- /src/router/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import LangRouter from '../plugin'; 3 | import Home from '../views/Home.vue'; 4 | 5 | import translations from '../lang/translations'; 6 | import localizedURLs from '../lang/localized-urls'; 7 | import dateTimeFormats from '../lang/dateTimeFormats'; 8 | 9 | 10 | Vue.use(LangRouter, { 11 | defaultLanguage: 'en', 12 | translations, 13 | localizedURLs, 14 | i18nOptions: { 15 | dateTimeFormats, 16 | }, 17 | }); 18 | 19 | 20 | const routes = [ 21 | { 22 | path: '/', 23 | name: 'Home page', 24 | component: Home, 25 | alias: '/home', 26 | }, 27 | { 28 | path: '/about', 29 | name: 'About page', 30 | // route level code-splitting 31 | // this generates a separate chunk (about.[hash].js) for this route 32 | // which is lazy-loaded when the route is visited. 33 | component: () => import(/* webpackChunkName: "about" */ '../views/About.vue'), 34 | }, 35 | { 36 | path: '/user/:slug', 37 | name: 'User page', 38 | component: () => import('../views/User.vue'), 39 | }, 40 | { 41 | path: '/pos/:id', 42 | name: 'Pos page', 43 | component: { 44 | template: '', 45 | }, 46 | children: [ 47 | { 48 | path: 'info', 49 | name: 'Pos info', 50 | component: () => import('../views/PosInfo.vue'), 51 | }, 52 | { 53 | path: 'manager/:name', 54 | name: 'Pos manager', 55 | component: () => import('../views/PosManager.vue'), 56 | }, 57 | ], 58 | }, 59 | ]; 60 | 61 | 62 | // Initiate router 63 | const router = new LangRouter({ 64 | routes, 65 | mode: 'history', 66 | }); 67 | 68 | export default router; 69 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import commonjs from '@rollup/plugin-commonjs'; // Convert CommonJS modules to ES6 2 | import vue from 'rollup-plugin-vue'; // Handle .vue SFC files 3 | import buble from '@rollup/plugin-buble'; // Transpile/polyfill with reasonable browser support 4 | import banner from 'rollup-plugin-banner'; // Add header to compiled JS files 5 | import { terser } from 'rollup-plugin-terser'; // Minification 6 | import cleanup from 'rollup-plugin-cleanup'; // Code cleanup 7 | import inject from '@rollup/plugin-inject'; // Import injection 8 | 9 | const path = require('path'); 10 | 11 | const globals = { 12 | vue: 'Vue', 13 | 'vue-i18n': 'VueI18n', 14 | 'vue-router': 'VueRouter', 15 | }; 16 | 17 | export default { 18 | input: 'src/plugin/index.js', 19 | external: [ 20 | 'vue', 21 | 'vue-i18n', 22 | 'vue-router', 23 | ], 24 | output: [ 25 | { 26 | file: 'dist/lang-router.umd.js', 27 | format: 'umd', 28 | name: 'LangRouter', 29 | exports: 'named', 30 | globals, 31 | }, 32 | { 33 | file: 'dist/lang-router.esm.js', 34 | format: 'es', 35 | }, 36 | { 37 | file: 'dist/lang-router.js', 38 | format: 'iife', 39 | name: 'LangRouter', 40 | exports: 'named', 41 | globals, 42 | }, 43 | { 44 | file: 'dist/lang-router.min.js', 45 | format: 'iife', 46 | name: 'LangRouter', 47 | exports: 'named', 48 | globals, 49 | plugins: [ terser() ], 50 | }, 51 | ], 52 | plugins: [ 53 | commonjs(), 54 | vue({ 55 | css: true, // Dynamically inject css as a 105 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vue-lang-router", 3 | "version": "1.3.1", 4 | "description": "Vue.js language routing with (optional) URL localization", 5 | "author": { 6 | "name": "Radek Altof", 7 | "email": "radek.altof@gmail.com" 8 | }, 9 | "scripts": { 10 | "serve": "vue-cli-service serve", 11 | "build": "rollup --config", 12 | "test:unit": "vue-cli-service test:unit", 13 | "test": "npm run lint && npm run test:unit", 14 | "lint": "vue-cli-service lint" 15 | }, 16 | "main": "dist/lang-router.umd.js", 17 | "module": "dist/lang-router.esm.js", 18 | "unpkg": "dist/lang-router.js", 19 | "files": [ 20 | "dist/*.js" 21 | ], 22 | "dependencies": { 23 | "core-js": "^3.17.2", 24 | "vue": "^2.6.14", 25 | "vue-i18n": "^8.25.0", 26 | "vue-router": "^3.5.2" 27 | }, 28 | "devDependencies": { 29 | "@kazupon/vue-i18n-loader": "^0.5.0", 30 | "@radek-altof/lint-config": "^1.1.1", 31 | "@rollup/plugin-buble": "^0.21.3", 32 | "@rollup/plugin-commonjs": "^20.0.0", 33 | "@rollup/plugin-inject": "^4.0.2", 34 | "@vue/cli-plugin-babel": "~4.5.13", 35 | "@vue/cli-plugin-eslint": "~4.5.13", 36 | "@vue/cli-plugin-router": "~4.5.13", 37 | "@vue/cli-plugin-unit-jest": "^4.5.13", 38 | "@vue/cli-service": "~4.5.13", 39 | "@vue/test-utils": "^1.2.2", 40 | "babel-eslint": "^10.1.0", 41 | "babel-plugin-rewire": "^1.2.0", 42 | "eslint": "^6.8.0", 43 | "eslint-plugin-vue": "^6.2.2", 44 | "git-branch-is": "^4.0.0", 45 | "node-sass": "^4.14.1", 46 | "rollup": "^2.56.3", 47 | "rollup-plugin-banner": "^0.2.1", 48 | "rollup-plugin-cleanup": "^3.2.1", 49 | "rollup-plugin-terser": "^7.0.2", 50 | "rollup-plugin-vue": "^5.1.9", 51 | "sass-loader": "^8.0.2", 52 | "stylelint": "^13.13.1", 53 | "stylelint-webpack-plugin": "^2.2.2", 54 | "vue-template-compiler": "^2.6.14" 55 | }, 56 | "bugs": { 57 | "url": "https://github.com/adbrosaci/vue-lang-router/issues" 58 | }, 59 | "homepage": "https://github.com/adbrosaci/vue-lang-router#readme", 60 | "jsdelivr": "dist/lang-router.js", 61 | "keywords": [ 62 | "vue", 63 | "router", 64 | "routing", 65 | "lang", 66 | "languages", 67 | "i18n", 68 | "localization", 69 | "plugin" 70 | ], 71 | "license": "MIT", 72 | "repository": { 73 | "type": "git", 74 | "url": "git+https://github.com/adbrosaci/vue-lang-router.git" 75 | }, 76 | "gitHooks": { 77 | "pre-commit": "if git-branch-is master -q; then npm run test && npm run build; fi" 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /tests/unit/LangRouter.spec.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import VueRouter from 'vue-router'; 3 | import LangRouter from '@/plugin'; 4 | 5 | import routes from '../setup/routes'; 6 | import translations from '../setup/translations'; 7 | import localizedURLs from '../setup/localized-urls'; 8 | import { dateTimeFormats, pluralizationRules } from '../setup/i18nOptions'; 9 | 10 | 11 | // Setup 12 | 13 | const defaultLanguage = 'en'; 14 | 15 | Vue.use(LangRouter, { 16 | defaultLanguage, 17 | translations, 18 | localizedURLs, 19 | i18nOptions: { 20 | dateTimeFormats, 21 | pluralizationRules, 22 | }, 23 | }); 24 | 25 | const router = new LangRouter({ 26 | routes, 27 | mode: 'history', 28 | }); 29 | 30 | 31 | // Tests 32 | 33 | describe('General', () => { 34 | 35 | test('translations are passed in correctly', () => { 36 | expect(LangRouter.__get__('translations')).toEqual(translations); 37 | }); 38 | 39 | test('localizedURLs are passed in correctly', () => { 40 | expect(LangRouter.__get__('localizedURLs')).toEqual(localizedURLs); 41 | }); 42 | 43 | test('defaultLanguage is passed in correctly', () => { 44 | expect(LangRouter.__get__('defaultLanguage')).toEqual(defaultLanguage); 45 | }); 46 | 47 | test('i18nOptions are passed in correctly', () => { 48 | expect(LangRouter.__get__('i18n').dateTimeFormats).toEqual(dateTimeFormats); 49 | expect(LangRouter.__get__('i18n').pluralizationRules).toEqual(pluralizationRules); 50 | }); 51 | }); 52 | 53 | 54 | describe('LangRouter', () => { 55 | 56 | test('is an instance of VueRouter', () => { 57 | expect(router).toBeInstanceOf(VueRouter); 58 | }); 59 | 60 | test('generates correct router aliases', () => { 61 | expect(routes[0].alias).toEqual([ 62 | '/en/about-replaced', 63 | '/cs/o-nas', 64 | '/ru/about', 65 | ]); 66 | expect(routes[1].alias).toEqual([ 67 | '/en/user/:slug', 68 | '/cs/uzivatel/:slug', 69 | '/ru/user/:slug', 70 | ]); 71 | expect(routes[1].children[0].alias).toEqual([ 72 | 'detail', 73 | 'informatsiya', 74 | ]); 75 | }); 76 | 77 | test('gets preffered language from browser', () => { 78 | Object.defineProperties(window.navigator, { 79 | browserLanguage: { 80 | value: 'ru', 81 | }, 82 | language: { 83 | value: undefined, 84 | }, 85 | languages: { 86 | value: null, 87 | configurable: true, 88 | }, 89 | }); 90 | expect(LangRouter.__get__('getPrefferedLanguage')()).toBe('ru'); 91 | 92 | Object.defineProperty(window.navigator, 'languages', { 93 | value: [ 94 | 'cs-CZ', 95 | 'en-US', 96 | ], 97 | configurable: true, 98 | }); 99 | expect(LangRouter.__get__('getPrefferedLanguage')()).toBe('cs'); 100 | }); 101 | 102 | test('localizes path correctly', () => { 103 | const vueMock = { 104 | $router: router, 105 | }; 106 | const localizePath = (path, lang) => { 107 | return LangRouter.__get__('localizePath').call(vueMock, path, lang); 108 | }; 109 | 110 | expect(localizePath('/about', 'ru')).toBe('/ru/about'); 111 | expect(localizePath('/cs/o-nas', 'en')).toBe('/en/about-replaced'); 112 | expect(localizePath('/cs/o-nas', 'cs')).toBe('/cs/o-nas'); 113 | expect(localizePath('/en/about-replaced', 'en')).toBe('/en/about-replaced'); 114 | 115 | expect(localizePath('/user/2/info', 'ru')).toBe('/ru/user/2/informatsiya'); 116 | expect(localizePath('/user/john-smith', 'en')).toBe('/user/john-smith'); 117 | expect(localizePath('/user/john-smith', 'cs')).toBe('/cs/uzivatel/john-smith'); 118 | expect(localizePath('/ru/user/6/informatsiya', 'cs')).toBe('/cs/uzivatel/6/detail'); 119 | 120 | const originalConsoleError = console.error; 121 | console.error = jest.fn(); 122 | 123 | expect(localizePath('/cs/undefined-path/detail', 'en')).toBe('/en/undefined-path/info'); 124 | expect(localizePath('/cs/undefined-path/detail', 'ru')).toBe('/ru/undefined-path/informatsiya'); 125 | expect(console.error).toHaveBeenCalledTimes(2); 126 | 127 | console.error = originalConsoleError; 128 | 129 | expect(localizePath('/ru/user/should-not-be-replaced/informatsiya', 'cs')).toBe('/cs/uzivatel/should-not-be-replaced/detail'); 130 | }); 131 | 132 | test('localizes path with query correctly', () => { 133 | const vueMock = { 134 | $router: router, 135 | }; 136 | const localizePath = (path, lang) => { 137 | return LangRouter.__get__('localizePath').call(vueMock, path, lang); 138 | }; 139 | 140 | expect(localizePath('/about/?q=test', 'ru')).toBe('/ru/about/?q=test'); 141 | expect(localizePath('/cs/o-nas?q=test', 'en')).toBe('/en/about-replaced?q=test'); 142 | 143 | expect(localizePath('/about/#hash', 'ru')).toBe('/ru/about/#hash'); 144 | expect(localizePath('/cs/o-nas#hash', 'en')).toBe('/en/about-replaced#hash'); 145 | 146 | expect(localizePath('/about/?q=test#hash', 'ru')).toBe('/ru/about/?q=test#hash'); 147 | expect(localizePath('/cs/o-nas?q=test#hash', 'en')).toBe('/en/about-replaced?q=test#hash'); 148 | }); 149 | }); 150 | -------------------------------------------------------------------------------- /dist/lang-router.min.js: -------------------------------------------------------------------------------- 1 | /** 2 | * vue-lang-router v1.3.1 3 | * (c) 2021 Radek Altof 4 | * Released under the MIT License. 5 | */ 6 | 7 | var LangRouter=function(t,e,n){"use strict";function r(t){return t&&"object"==typeof t&&"default"in t?t:{default:t}}var a=r(e),o=r(n);var i=Object.assign||function(){for(var t=arguments,e={},n=0;n Note: This example uses Czech and English translations. 67 | 68 | Example file structure: 69 | 70 | ```sh 71 | src 72 | └── lang 73 | └── translations 74 | ├── cs.json 75 | ├── en.json 76 | └── index.js 77 | ``` 78 | 79 | Create `JSON` files for all desired languages and reference them in `index.js`. Translations are loaded on demand. 80 | 81 | ```javascript 82 | /* src/lang/translations/en.json */ 83 | 84 | { 85 | "hello": "Hello!", 86 | "about": { 87 | "example": "This is an About page." 88 | } 89 | } 90 | ``` 91 | 92 | ```javascript 93 | /* src/lang/translations/cs.json */ 94 | 95 | { 96 | "hello": "Ahoj!", 97 | "about": { 98 | "example": "Tohle je stránka O nás." 99 | } 100 | } 101 | ``` 102 | 103 | ```javascript 104 | /* src/lang/translations/index.js */ 105 | 106 | export default { 107 | en: { 108 | name: 'English', 109 | load: () => { return import('./en.json'); }, 110 | }, 111 | cs: { 112 | name: 'Česky', 113 | load: () => { return import('./cs.json'); }, 114 | }, 115 | }; 116 | ``` 117 | 118 | In case you do not want to load translations asynchronously, you can provide `messages` object with translations instead of `load` function. For example: 119 | 120 | ```javascript 121 | export default { 122 | en: { 123 | name: 'English', 124 | messages: { 125 | hello: 'Hello!', 126 | }, 127 | }, 128 | }; 129 | ``` 130 | 131 | #### 3. Create localized URLs in your app. 132 | 133 | > Note: Localized URLs are optional. 134 | 135 | Example file structure: 136 | 137 | ```sh 138 | src 139 | └── lang 140 | └── localized-urls 141 | ├── cs.json 142 | └── index.js 143 | ``` 144 | 145 | Create `JSON` files for all desired languages and `import` them in `index.js`. Localized URLs need to be imported before router instantiation. 146 | 147 | ```javascript 148 | /* src/lang/localized-urls/cs.json */ 149 | 150 | { 151 | "about": "o-nas" 152 | } 153 | ``` 154 | 155 | ```javascript 156 | /* src/lang/localized-urls/index.js */ 157 | 158 | import cs from './cs.json'; 159 | 160 | export default { cs }; 161 | ``` 162 | 163 | Language router will parse any given path and attempt to localize its segments based on this configuration. If no match is found, the original path segment is retained. 164 | 165 | 166 | #### 4. Modify your router file 167 | 168 | - Import `LangRouter` and use it instead of `VueRouter`. 169 | - Import translations and localized URLs and pass them to `LangRouter` plugin. 170 | - Create new router instance. All the options are the same as in `VueRouter`. 171 | 172 | ```javascript 173 | /* src/router/index.js */ 174 | 175 | import Vue from 'vue'; 176 | import LangRouter from 'vue-lang-router'; 177 | 178 | import translations from '../lang/translations'; 179 | import localizedURLs from '../lang/localized-urls'; 180 | 181 | Vue.use(LangRouter, { 182 | defaultLanguage: 'en', 183 | translations, 184 | localizedURLs, 185 | }); 186 | 187 | const routes = [ /* Your routes here */ ]; 188 | 189 | const router = new LangRouter({ 190 | routes, 191 | mode: 'history', 192 | }); 193 | ``` 194 | 195 | #### 5. Modify your main file 196 | 197 | Import `i18n` and use it in your `Vue` instance. 198 | 199 | ```javascript 200 | import Vue from 'vue'; 201 | import App from './App.vue'; 202 | import router from './router'; 203 | import { i18n } from 'vue-lang-router'; 204 | 205 | new Vue({ 206 | router, 207 | i18n, 208 | render: h => h(App), 209 | }).$mount('#app'); 210 | ``` 211 | 212 | 213 | ## How to use 214 | 215 | ### Using translations 216 | 217 | To use any translated string, use `$t('stringName')` in your code. For more information check out [`Vue I18n`](http://kazupon.github.io/vue-i18n/). 218 | 219 | 220 | ### Using links 221 | 222 | Use `` component. It localizes given router path based on current language. It's a simple, yet powerful component, which takes a hassle out of generating proper links on your site. 223 | 224 | It accepts the same options as [``](https://router.vuejs.org/api/#router-link-props). 225 | 226 | ```html 227 | John Smith 228 | ``` 229 | 230 | The above code will generate a link with `href` value depending on various factors. Here are a few examples: 231 | 232 | ```javascript 233 | /* 234 | Default language: "en" 235 | Current language: "en" 236 | Localized URLs "en": {} 237 | Current URL: / 238 | */ 239 | 240 | href="/user/john-smith" 241 | 242 | 243 | /* 244 | Default language: "en" 245 | Current language: "en" 246 | Localized URLs "en": { "user": "u" } 247 | Current URL: /en/example 248 | */ 249 | 250 | href="/en/u/john-smith" 251 | 252 | 253 | /* 254 | Default language: "en" 255 | Current language: "cs" 256 | Localized URLs "cs": { "user": "uzivatel" } 257 | Current URL: / 258 | */ 259 | 260 | href="/cs/uzivatel/john-smith" 261 | ``` 262 | 263 | 264 | 265 | ### Switching language 266 | 267 | Use `` component for this. The component will loop over all available languages and generate `links` array with properties, which you can use to create your own menu. 268 | 269 | The wrapper element will have `router-language-switcher` class. 270 | 271 | #### Examples: 272 | 273 | ```html 274 | 275 | 276 | 277 | {{ link.langName }} 278 | 279 | 280 | ``` 281 | 282 | ```html 283 | 284 |
  • 285 | {{ link.langIndex }} 286 |
  • 287 |
    288 | ``` 289 | 290 | #### Properties: 291 | 292 | - **url** : The localized path to the current/specified page in iterated language. 293 | - **langIndex** : The index of the iterated language provided in `translations`. 294 | - **langName** : The name of the iterated language provided in `translations`. 295 | - **activeClass** : Returns the active class when iterated language equals current language, otherwise empty. 296 | 297 | 298 | #### Accepted attributes: 299 | 300 | - **tag** : Use this attribute to specify which tag should be used as a wrapper element. The default is `div`. 301 | - **url** : Provides a specific path to generate translation links for. If omitted, current path is used. 302 | - **active-class** : Defines the name of class to provide when language of the link equals current language. The default is `router-active-language`. 303 | 304 | 305 | ### Vue I18n customization 306 | 307 | In case you'd like to pass in custom `vue-i18n` options, you can do so using `i18nOptions` property, for example: 308 | 309 | ```javascript 310 | /* src/router/index.js */ 311 | 312 | Vue.use(LangRouter, { 313 | defaultLanguage: 'en', 314 | translations, 315 | localizedURLs, 316 | i18nOptions: { 317 | dateTimeFormats: { ... }, 318 | pluralizationRules: { ... }, 319 | } 320 | }); 321 | ``` 322 | -------------------------------------------------------------------------------- /src/plugin/index.js: -------------------------------------------------------------------------------- 1 | import VueI18n from 'vue-i18n'; 2 | import VueRouter from 'vue-router'; 3 | 4 | import LocalizedLink from './components/LocalizedLink.vue'; 5 | import LanguageSwitcher from './components/LanguageSwitcher.vue'; 6 | 7 | 8 | // Define vars 9 | let defaultLanguage, translations, localizedURLs, i18n; 10 | 11 | 12 | // Array of loaded translations 13 | const loadedTranslations = []; 14 | 15 | 16 | // Error logging 17 | function err (msg, error) { 18 | console.error('LangRouter: ' + msg); 19 | if (typeof error !== 'undefined') { console.error(error); } 20 | } 21 | 22 | 23 | // LangRouter class adds localized URL functionality to Vue Router 24 | export default class LangRouter { 25 | 26 | // Called when instantiated 27 | constructor (options) { 28 | 29 | // If any language is missing from localized URLs, add it as an empty object 30 | // All aliases need to be created for language switching purposes 31 | for (let lang in translations) { 32 | if (translations.hasOwnProperty(lang) && !localizedURLs[lang]) { 33 | localizedURLs[lang] = {}; 34 | } 35 | } 36 | 37 | // Cycle through all the available languages and add aliases to routes 38 | for (let lang in localizedURLs) { 39 | if (localizedURLs.hasOwnProperty(lang)) { 40 | addAliasesToRoutes(options.routes, lang); 41 | } 42 | } 43 | 44 | // Create Vue Router instance 45 | const router = new VueRouter(options); 46 | 47 | // Add language switching logic 48 | router.beforeEach(switchLanguage); 49 | 50 | // Return Vue Router instance 51 | return router; 52 | } 53 | } 54 | 55 | 56 | // Install method of the LangRouter plugin 57 | LangRouter.install = function (Vue, options) { 58 | 59 | // Check if plugin is installed already 60 | if (LangRouter.installed) { 61 | err('Already installed.'); 62 | return; 63 | } 64 | LangRouter.installed = true; 65 | 66 | // Get the options 67 | if (!options) { 68 | err('Options missing.'); 69 | } 70 | defaultLanguage = options.defaultLanguage; 71 | translations = options.translations; 72 | localizedURLs = options.localizedURLs || {}; 73 | 74 | // Check if variables look okay 75 | let isArr; 76 | if ((isArr = Array.isArray(translations)) || typeof translations !== 'object') { 77 | err('options.translations should be an object, received ' + (isArr ? 'array' : typeof translations) + ' instead.'); 78 | } 79 | if ((isArr = Array.isArray(localizedURLs)) || typeof localizedURLs !== 'object') { 80 | err('options.localizedURLs should be an object, received ' + (isArr ? 'array' : typeof localizedURLs) + ' instead.'); 81 | } 82 | if (typeof defaultLanguage !== 'string') { 83 | err('options.defaultLanguage should be a string, received ' + typeof defaultLanguage + ' instead.'); 84 | } 85 | 86 | // Register plugins 87 | Vue.use(VueI18n); 88 | Vue.use(VueRouter); 89 | 90 | // Check if any translations are already present, if yes, pass them to VueI18n 91 | let messages = {}; 92 | 93 | for (let lang in translations) { 94 | if (translations.hasOwnProperty(lang)) { 95 | let langMessages = translations[lang].messages; 96 | 97 | if (typeof langMessages === 'object' && !Array.isArray(langMessages)) { 98 | messages[lang] = translations[lang].messages; 99 | loadedTranslations.push(lang); 100 | } 101 | } 102 | } 103 | 104 | // Init internalization plugin 105 | i18n = new VueI18n({ 106 | locale: defaultLanguage, 107 | fallbackLocale: defaultLanguage, 108 | messages, 109 | ...options.i18nOptions, 110 | }); 111 | 112 | // Add translations to use in 113 | Vue.prototype._langRouter = { translations }; 114 | 115 | // Add $localizePath method to return localized path 116 | Vue.prototype.$localizePath = localizePath; 117 | 118 | // Register components 119 | Vue.component('localized-link', LocalizedLink); 120 | Vue.component('language-switcher', LanguageSwitcher); 121 | }; 122 | 123 | 124 | // Switching to a loaded language 125 | function setLanguage (lang) { 126 | i18n.locale = lang; 127 | document.querySelector('html').setAttribute('lang', lang); 128 | localStorage.setItem('VueAppLanguage', lang); 129 | return lang; 130 | } 131 | 132 | 133 | // Loading translations asynchronously 134 | // Returns promise 135 | function loadLanguage (lang) { 136 | 137 | // If the translation is already loaded 138 | if (loadedTranslations.includes(lang)) { 139 | return Promise.resolve(setLanguage(lang)); 140 | } 141 | 142 | // If the translation hasn't been loaded 143 | // Check if the load function exists 144 | if (!translations[lang] || typeof translations[lang].load !== 'function') { 145 | err('Unable to load translations for "' + lang + '", "load" function is missing!'); 146 | } 147 | 148 | // Load the translation 149 | return translations[lang].load().then(function (messages) { 150 | i18n.setLocaleMessage(lang, messages.default || messages); 151 | loadedTranslations.push(lang); 152 | return setLanguage(lang); 153 | }).catch(function (error) { 154 | err('Failed to load "' + lang + '" translation.', error); 155 | }); 156 | } 157 | 158 | 159 | // Adding aliases to routes 160 | function addAliasesToRoutes (routes, lang, child) { 161 | 162 | // Iterate over each route 163 | routes.forEach(function (route) { 164 | 165 | // Translate the path 166 | let alias = translatePath(route.path, lang); 167 | 168 | // Add language prefix to alias (only if route is at the top level) 169 | if (!child) { alias = '/' + lang + (alias.charAt(0) != '/' ? '/' : '') + alias; } 170 | 171 | // Make sure alias array exists & add any pre-existing value to it 172 | if (route.alias) { 173 | if (!Array.isArray(route.alias)) { route.alias = [ route.alias ]; } 174 | } 175 | else { route.alias = []; } 176 | 177 | // Push alias into alias array 178 | if (route.path != alias && route.alias.indexOf(alias) == -1) { route.alias.push(alias); } 179 | 180 | // If the route has children, iterate over those too 181 | if (route.children) { addAliasesToRoutes(route.children, lang, true); } 182 | 183 | }); 184 | } 185 | 186 | 187 | // Router - language switching 188 | function switchLanguage (to, from, next) { 189 | let lang = to.path.split('/')[1]; 190 | 191 | // If language isn't available in the URL 192 | if (!translations[lang]) { 193 | 194 | // Set the language to saved one if available 195 | const savedLang = localStorage.getItem('VueAppLanguage'); 196 | if (savedLang && translations[savedLang]) { lang = savedLang; } 197 | else { 198 | 199 | // Set the language to preferred one if available 200 | const preferredLang = getPrefferedLanguage(); 201 | if (preferredLang && translations[preferredLang]) { lang = preferredLang; } 202 | 203 | // Otherwise set default language 204 | else { lang = defaultLanguage; } 205 | } 206 | 207 | // If the language isn't default one, translate path and redirect to it 208 | if (lang != defaultLanguage) { 209 | 210 | // Translate path 211 | let translatedPath = translatePath(to.path, lang); 212 | 213 | // Add language prefix to the path 214 | translatedPath = '/' + lang + (translatedPath.charAt(0) != '/' ? '/' : '') + translatedPath; 215 | 216 | return next({ path: translatedPath, query: to.query, hash: to.hash }); 217 | } 218 | } 219 | 220 | // Load requested language 221 | loadLanguage(lang).then(function () { 222 | return next(); 223 | }); 224 | } 225 | 226 | 227 | // Path translation 228 | function translatePath (path, langTo, langFrom, matchedPath) { 229 | 230 | // Split the path into chunks 231 | let pathChunks = path.split('/'); 232 | 233 | // If the path is in some language already 234 | if (langFrom && localizedURLs[langFrom]) { 235 | 236 | // If the path language & the desired language are equal, do not translate 237 | if (langTo == langFrom) { return path; } 238 | 239 | // Create reversed map of localized URLs in given language 240 | const map = localizedURLs[langFrom]; 241 | const reversedMap = {}; 242 | Object.keys(map).forEach(function (key) { 243 | reversedMap[map[key]] = key; 244 | }); 245 | 246 | // Split the matched path into chunks 247 | let matchedPathChunks = matchedPath.split('/'); 248 | 249 | // Translate the path back to original path names 250 | for (let i = 0; i < pathChunks.length; i++) { 251 | let pathChunk = pathChunks[i]; 252 | 253 | // If the original path chunk is a variable, do not translate it 254 | if (matchedPathChunks[i].charAt(0) == ':') { continue; } 255 | 256 | // If there is an alias, use it, otherwise use given path 257 | pathChunks[i] = reversedMap[pathChunk] || pathChunk; 258 | } 259 | } 260 | 261 | // Translate all the non-variable chunks of the path 262 | for (let i = 0; i < pathChunks.length; i++) { 263 | let pathChunk = pathChunks[i]; 264 | 265 | // If the path chunk is a variable, do not translate it 266 | if (pathChunk.charAt(0) == ':') { continue; } 267 | 268 | // If there is an alias, use it, otherwise use given path 269 | pathChunks[i] = localizedURLs[langTo][pathChunk] || pathChunk; 270 | } 271 | 272 | // Join path chunks and return 273 | return pathChunks.join('/'); 274 | } 275 | 276 | 277 | // Retrieving preferred language from browser 278 | function getPrefferedLanguage () { 279 | 280 | // Extraction of language shortcut from language string 281 | function extractLanguage (s) { 282 | return s.split('-')[0].toLowerCase(); 283 | } 284 | 285 | // Use navigator.languages if available 286 | if (navigator.languages && navigator.languages.length) { return extractLanguage(navigator.languages[0] || ''); } 287 | 288 | // Otherwise use whatever is available 289 | return extractLanguage(navigator.language || navigator.browserLanguage || navigator.userLanguage || ''); 290 | } 291 | 292 | 293 | // Path localization 294 | function localizePath (fullPath, lang) { 295 | // If the desired language is not defined or it doesn't exist, use current one 296 | if (!lang || !localizedURLs[lang]) { lang = i18n.locale; } 297 | 298 | // Separate path & query 299 | let path = fullPath; 300 | let query = ''; 301 | 302 | if (fullPath.includes('?')) { 303 | path = fullPath.split('?')[0]; 304 | query = '?' + fullPath.split('?')[1]; 305 | } 306 | else if (fullPath.includes('#')) { 307 | path = fullPath.split('#')[0]; 308 | query = '#' + fullPath.split('#')[1]; 309 | } 310 | 311 | // Split path into chunks 312 | const pathChunks = path.split('/'); 313 | 314 | // Get the path language, if there is any 315 | let pathLang = (localizedURLs[pathChunks[1]] ? pathChunks[1] : false); 316 | 317 | // If the language is default language 318 | // & current path doesn't contain a language 319 | // & path to translate doesn't contain a language 320 | // = no need to localize 321 | const currentPathLang = this.$router.currentRoute.path.split('/')[1]; 322 | if (lang == defaultLanguage && !localizedURLs[currentPathLang] && !pathLang) { return fullPath; } 323 | 324 | // If the path is in some language already 325 | let resolvedPath = false; 326 | if (pathLang) { 327 | // Get the original path 328 | const resolvedRoute = this.$router.resolve(path); 329 | 330 | if (resolvedRoute.route.matched.length != 0) { 331 | resolvedPath = resolvedRoute.route.matched[resolvedRoute.route.matched.length - 1].path; 332 | resolvedPath = (resolvedPath.charAt(0) == '/' ? resolvedPath : '/' + resolvedPath); 333 | } 334 | else { 335 | err('Router could not resolve path "' + path + '". URL localization may not work as expected.'); 336 | } 337 | 338 | // Remove the language from path 339 | pathChunks.splice(1, 1); 340 | path = pathChunks.join('/'); 341 | } 342 | 343 | // Translate path 344 | let translatedPath = translatePath(path, lang, pathLang, (resolvedPath || path)); 345 | 346 | // Add language prefix to the path 347 | translatedPath = '/' + lang + (translatedPath.charAt(0) != '/' ? '/' : '') + translatedPath; 348 | 349 | return translatedPath + query; 350 | } 351 | 352 | 353 | // Automatic plugin installation if in browser 354 | if (typeof window !== 'undefined' && window.Vue) { 355 | window.Vue.use(LangRouter); 356 | } 357 | 358 | 359 | // Export what's needed 360 | export { i18n }; 361 | -------------------------------------------------------------------------------- /dist/lang-router.esm.js: -------------------------------------------------------------------------------- 1 | /** 2 | * vue-lang-router v1.3.1 3 | * (c) 2021 Radek Altof 4 | * Released under the MIT License. 5 | */ 6 | 7 | import VueI18n from 'vue-i18n'; 8 | import VueRouter from 'vue-router'; 9 | 10 | function mergeObjects() { 11 | var arguments$1 = arguments; 12 | var targetObject = {}; 13 | for (var i = 0; i < arguments.length; i++) { 14 | var sourceObject = arguments$1[i]; 15 | for (var key in sourceObject) { 16 | if (Object.prototype.hasOwnProperty.call(sourceObject, key)) { 17 | targetObject[key] = sourceObject[key]; 18 | } 19 | } 20 | } 21 | return targetObject; 22 | } 23 | var _spread = Object.assign || mergeObjects; 24 | 25 | var script$1 = { 26 | name: 'LocalizedLink', 27 | props: [ 'to' ], 28 | methods: { 29 | localizedTo: function localizedTo () { 30 | if (typeof this.to === 'string') { 31 | return this.$localizePath(this.to); 32 | } 33 | else if (typeof this.to === 'object' && typeof this.to.path === 'string') { 34 | var o = JSON.parse(JSON.stringify(this.to)); 35 | o.path = this.$localizePath(o.path); 36 | return o; 37 | } 38 | else { 39 | return this.to; 40 | } 41 | }, 42 | }, 43 | }; 44 | 45 | function normalizeComponent(template, style, script, scopeId, isFunctionalTemplate, moduleIdentifier , shadowMode, createInjector, createInjectorSSR, createInjectorShadow) { 46 | if (typeof shadowMode !== 'boolean') { 47 | createInjectorSSR = createInjector; 48 | createInjector = shadowMode; 49 | shadowMode = false; 50 | } 51 | var options = typeof script === 'function' ? script.options : script; 52 | if (template && template.render) { 53 | options.render = template.render; 54 | options.staticRenderFns = template.staticRenderFns; 55 | options._compiled = true; 56 | if (isFunctionalTemplate) { 57 | options.functional = true; 58 | } 59 | } 60 | if (scopeId) { 61 | options._scopeId = scopeId; 62 | } 63 | var hook; 64 | if (moduleIdentifier) { 65 | hook = function (context) { 66 | context = 67 | context || 68 | (this.$vnode && this.$vnode.ssrContext) || 69 | (this.parent && this.parent.$vnode && this.parent.$vnode.ssrContext); 70 | if (!context && typeof __VUE_SSR_CONTEXT__ !== 'undefined') { 71 | context = __VUE_SSR_CONTEXT__; 72 | } 73 | if (style) { 74 | style.call(this, createInjectorSSR(context)); 75 | } 76 | if (context && context._registeredComponents) { 77 | context._registeredComponents.add(moduleIdentifier); 78 | } 79 | }; 80 | options._ssrRegister = hook; 81 | } 82 | else if (style) { 83 | hook = shadowMode 84 | ? function (context) { 85 | style.call(this, createInjectorShadow(context, this.$root.$options.shadowRoot)); 86 | } 87 | : function (context) { 88 | style.call(this, createInjector(context)); 89 | }; 90 | } 91 | if (hook) { 92 | if (options.functional) { 93 | var originalRender = options.render; 94 | options.render = function renderWithStyleInjection(h, context) { 95 | hook.call(context); 96 | return originalRender(h, context); 97 | }; 98 | } 99 | else { 100 | var existing = options.beforeCreate; 101 | options.beforeCreate = existing ? [].concat(existing, hook) : [hook]; 102 | } 103 | } 104 | return script; 105 | } 106 | 107 | /* script */ 108 | var __vue_script__$1 = script$1; 109 | 110 | /* template */ 111 | var __vue_render__$1 = function () {var _vm=this;var _h=_vm.$createElement;var _c=_vm._self._c||_h;return _c('router-link',_vm._b({attrs:{"to":_vm.localizedTo()}},'router-link',_vm.$attrs,false),[_vm._t("default")],2)}; 112 | var __vue_staticRenderFns__$1 = []; 113 | 114 | /* style */ 115 | var __vue_inject_styles__$1 = undefined; 116 | /* scoped */ 117 | var __vue_scope_id__$1 = undefined; 118 | /* module identifier */ 119 | var __vue_module_identifier__$1 = undefined; 120 | /* functional template */ 121 | var __vue_is_functional_template__$1 = false; 122 | /* style inject */ 123 | 124 | /* style inject SSR */ 125 | 126 | /* style inject shadow dom */ 127 | 128 | 129 | 130 | var __vue_component__$1 = /*#__PURE__*/normalizeComponent( 131 | { render: __vue_render__$1, staticRenderFns: __vue_staticRenderFns__$1 }, 132 | __vue_inject_styles__$1, 133 | __vue_script__$1, 134 | __vue_scope_id__$1, 135 | __vue_is_functional_template__$1, 136 | __vue_module_identifier__$1, 137 | false, 138 | undefined, 139 | undefined, 140 | undefined 141 | ); 142 | 143 | var script = { 144 | name: 'LanguageSwitcher', 145 | data: function data () { 146 | return { 147 | currentUrl: this.url || this.$router.currentRoute.fullPath, 148 | links: [], 149 | }; 150 | }, 151 | props: [ 'tag', 'active-class', 'url' ], 152 | methods: { 153 | getTag: function getTag () { 154 | if (this.tag) { return this.tag; } 155 | else { return 'div'; } 156 | }, 157 | generateLinks: function generateLinks () { 158 | var links = []; 159 | var activeClass = this.activeClass || 'router-active-language'; 160 | var tr = this._langRouter.translations; 161 | for (var lang in tr) { 162 | if (tr.hasOwnProperty(lang)) { 163 | links.push({ 164 | activeClass: (lang == this.$i18n.locale ? activeClass : ''), 165 | langIndex: lang, 166 | langName: tr[lang].name || lang, 167 | url: this.$localizePath(this.currentUrl, lang), 168 | }); 169 | } 170 | } 171 | this.links = links; 172 | }, 173 | }, 174 | watch: { 175 | $route: function $route (to) { 176 | this.currentUrl = this.url || to.fullPath; 177 | this.generateLinks(); 178 | }, 179 | }, 180 | mounted: function mounted () { 181 | this.generateLinks(); 182 | }, 183 | }; 184 | 185 | /* script */ 186 | var __vue_script__ = script; 187 | 188 | /* template */ 189 | var __vue_render__ = function () {var _vm=this;var _h=_vm.$createElement;var _c=_vm._self._c||_h;return _c(_vm.getTag(),{tag:"component",staticClass:"router-language-switcher"},[_vm._t("default",null,{"links":_vm.links})],2)}; 190 | var __vue_staticRenderFns__ = []; 191 | 192 | /* style */ 193 | var __vue_inject_styles__ = undefined; 194 | /* scoped */ 195 | var __vue_scope_id__ = undefined; 196 | /* module identifier */ 197 | var __vue_module_identifier__ = undefined; 198 | /* functional template */ 199 | var __vue_is_functional_template__ = false; 200 | /* style inject */ 201 | 202 | /* style inject SSR */ 203 | 204 | /* style inject shadow dom */ 205 | 206 | 207 | 208 | var __vue_component__ = /*#__PURE__*/normalizeComponent( 209 | { render: __vue_render__, staticRenderFns: __vue_staticRenderFns__ }, 210 | __vue_inject_styles__, 211 | __vue_script__, 212 | __vue_scope_id__, 213 | __vue_is_functional_template__, 214 | __vue_module_identifier__, 215 | false, 216 | undefined, 217 | undefined, 218 | undefined 219 | ); 220 | 221 | var defaultLanguage, translations, localizedURLs, i18n; 222 | var loadedTranslations = []; 223 | function err (msg, error) { 224 | console.error('LangRouter: ' + msg); 225 | if (typeof error !== 'undefined') { console.error(error); } 226 | } 227 | var LangRouter = function LangRouter (options) { 228 | for (var lang in translations) { 229 | if (translations.hasOwnProperty(lang) && !localizedURLs[lang]) { 230 | localizedURLs[lang] = {}; 231 | } 232 | } 233 | for (var lang$1 in localizedURLs) { 234 | if (localizedURLs.hasOwnProperty(lang$1)) { 235 | addAliasesToRoutes(options.routes, lang$1); 236 | } 237 | } 238 | var router = new VueRouter(options); 239 | router.beforeEach(switchLanguage); 240 | return router; 241 | }; 242 | LangRouter.install = function (Vue, options) { 243 | if (LangRouter.installed) { 244 | err('Already installed.'); 245 | return; 246 | } 247 | LangRouter.installed = true; 248 | if (!options) { 249 | err('Options missing.'); 250 | } 251 | defaultLanguage = options.defaultLanguage; 252 | translations = options.translations; 253 | localizedURLs = options.localizedURLs || {}; 254 | var isArr; 255 | if ((isArr = Array.isArray(translations)) || typeof translations !== 'object') { 256 | err('options.translations should be an object, received ' + (isArr ? 'array' : typeof translations) + ' instead.'); 257 | } 258 | if ((isArr = Array.isArray(localizedURLs)) || typeof localizedURLs !== 'object') { 259 | err('options.localizedURLs should be an object, received ' + (isArr ? 'array' : typeof localizedURLs) + ' instead.'); 260 | } 261 | if (typeof defaultLanguage !== 'string') { 262 | err('options.defaultLanguage should be a string, received ' + typeof defaultLanguage + ' instead.'); 263 | } 264 | Vue.use(VueI18n); 265 | Vue.use(VueRouter); 266 | var messages = {}; 267 | for (var lang in translations) { 268 | if (translations.hasOwnProperty(lang)) { 269 | var langMessages = translations[lang].messages; 270 | if (typeof langMessages === 'object' && !Array.isArray(langMessages)) { 271 | messages[lang] = translations[lang].messages; 272 | loadedTranslations.push(lang); 273 | } 274 | } 275 | } 276 | i18n = new VueI18n(_spread({}, {locale: defaultLanguage, 277 | fallbackLocale: defaultLanguage, 278 | messages: messages}, 279 | options.i18nOptions)); 280 | Vue.prototype._langRouter = { translations: translations }; 281 | Vue.prototype.$localizePath = localizePath; 282 | Vue.component('localized-link', __vue_component__$1); 283 | Vue.component('language-switcher', __vue_component__); 284 | }; 285 | function setLanguage (lang) { 286 | i18n.locale = lang; 287 | document.querySelector('html').setAttribute('lang', lang); 288 | localStorage.setItem('VueAppLanguage', lang); 289 | return lang; 290 | } 291 | function loadLanguage (lang) { 292 | if (loadedTranslations.includes(lang)) { 293 | return Promise.resolve(setLanguage(lang)); 294 | } 295 | if (!translations[lang] || typeof translations[lang].load !== 'function') { 296 | err('Unable to load translations for "' + lang + '", "load" function is missing!'); 297 | } 298 | return translations[lang].load().then(function (messages) { 299 | i18n.setLocaleMessage(lang, messages.default || messages); 300 | loadedTranslations.push(lang); 301 | return setLanguage(lang); 302 | }).catch(function (error) { 303 | err('Failed to load "' + lang + '" translation.', error); 304 | }); 305 | } 306 | function addAliasesToRoutes (routes, lang, child) { 307 | routes.forEach(function (route) { 308 | var alias = translatePath(route.path, lang); 309 | if (!child) { alias = '/' + lang + (alias.charAt(0) != '/' ? '/' : '') + alias; } 310 | if (route.alias) { 311 | if (!Array.isArray(route.alias)) { route.alias = [ route.alias ]; } 312 | } 313 | else { route.alias = []; } 314 | if (route.path != alias && route.alias.indexOf(alias) == -1) { route.alias.push(alias); } 315 | if (route.children) { addAliasesToRoutes(route.children, lang, true); } 316 | }); 317 | } 318 | function switchLanguage (to, from, next) { 319 | var lang = to.path.split('/')[1]; 320 | if (!translations[lang]) { 321 | var savedLang = localStorage.getItem('VueAppLanguage'); 322 | if (savedLang && translations[savedLang]) { lang = savedLang; } 323 | else { 324 | var preferredLang = getPrefferedLanguage(); 325 | if (preferredLang && translations[preferredLang]) { lang = preferredLang; } 326 | else { lang = defaultLanguage; } 327 | } 328 | if (lang != defaultLanguage) { 329 | var translatedPath = translatePath(to.path, lang); 330 | translatedPath = '/' + lang + (translatedPath.charAt(0) != '/' ? '/' : '') + translatedPath; 331 | return next({ path: translatedPath, query: to.query, hash: to.hash }); 332 | } 333 | } 334 | loadLanguage(lang).then(function () { 335 | return next(); 336 | }); 337 | } 338 | function translatePath (path, langTo, langFrom, matchedPath) { 339 | var pathChunks = path.split('/'); 340 | if (langFrom && localizedURLs[langFrom]) { 341 | if (langTo == langFrom) { return path; } 342 | var map = localizedURLs[langFrom]; 343 | var reversedMap = {}; 344 | Object.keys(map).forEach(function (key) { 345 | reversedMap[map[key]] = key; 346 | }); 347 | var matchedPathChunks = matchedPath.split('/'); 348 | for (var i = 0; i < pathChunks.length; i++) { 349 | var pathChunk = pathChunks[i]; 350 | if (matchedPathChunks[i].charAt(0) == ':') { continue; } 351 | pathChunks[i] = reversedMap[pathChunk] || pathChunk; 352 | } 353 | } 354 | for (var i$1 = 0; i$1 < pathChunks.length; i$1++) { 355 | var pathChunk$1 = pathChunks[i$1]; 356 | if (pathChunk$1.charAt(0) == ':') { continue; } 357 | pathChunks[i$1] = localizedURLs[langTo][pathChunk$1] || pathChunk$1; 358 | } 359 | return pathChunks.join('/'); 360 | } 361 | function getPrefferedLanguage () { 362 | function extractLanguage (s) { 363 | return s.split('-')[0].toLowerCase(); 364 | } 365 | if (navigator.languages && navigator.languages.length) { return extractLanguage(navigator.languages[0] || ''); } 366 | return extractLanguage(navigator.language || navigator.browserLanguage || navigator.userLanguage || ''); 367 | } 368 | function localizePath (fullPath, lang) { 369 | if (!lang || !localizedURLs[lang]) { lang = i18n.locale; } 370 | var path = fullPath; 371 | var query = ''; 372 | if (fullPath.includes('?')) { 373 | path = fullPath.split('?')[0]; 374 | query = '?' + fullPath.split('?')[1]; 375 | } 376 | else if (fullPath.includes('#')) { 377 | path = fullPath.split('#')[0]; 378 | query = '#' + fullPath.split('#')[1]; 379 | } 380 | var pathChunks = path.split('/'); 381 | var pathLang = (localizedURLs[pathChunks[1]] ? pathChunks[1] : false); 382 | var currentPathLang = this.$router.currentRoute.path.split('/')[1]; 383 | if (lang == defaultLanguage && !localizedURLs[currentPathLang] && !pathLang) { return fullPath; } 384 | var resolvedPath = false; 385 | if (pathLang) { 386 | var resolvedRoute = this.$router.resolve(path); 387 | if (resolvedRoute.route.matched.length != 0) { 388 | resolvedPath = resolvedRoute.route.matched[resolvedRoute.route.matched.length - 1].path; 389 | resolvedPath = (resolvedPath.charAt(0) == '/' ? resolvedPath : '/' + resolvedPath); 390 | } 391 | else { 392 | err('Router could not resolve path "' + path + '". URL localization may not work as expected.'); 393 | } 394 | pathChunks.splice(1, 1); 395 | path = pathChunks.join('/'); 396 | } 397 | var translatedPath = translatePath(path, lang, pathLang, (resolvedPath || path)); 398 | translatedPath = '/' + lang + (translatedPath.charAt(0) != '/' ? '/' : '') + translatedPath; 399 | return translatedPath + query; 400 | } 401 | if (typeof window !== 'undefined' && window.Vue) { 402 | window.Vue.use(LangRouter); 403 | } 404 | 405 | export { LangRouter as default, i18n }; 406 | -------------------------------------------------------------------------------- /dist/lang-router.js: -------------------------------------------------------------------------------- 1 | /** 2 | * vue-lang-router v1.3.1 3 | * (c) 2021 Radek Altof 4 | * Released under the MIT License. 5 | */ 6 | 7 | var LangRouter = (function (exports, VueI18n, VueRouter) { 8 | 'use strict'; 9 | 10 | function _interopDefaultLegacy (e) { return e && typeof e === 'object' && 'default' in e ? e : { 'default': e }; } 11 | 12 | var VueI18n__default = /*#__PURE__*/_interopDefaultLegacy(VueI18n); 13 | var VueRouter__default = /*#__PURE__*/_interopDefaultLegacy(VueRouter); 14 | 15 | function mergeObjects() { 16 | var arguments$1 = arguments; 17 | var targetObject = {}; 18 | for (var i = 0; i < arguments.length; i++) { 19 | var sourceObject = arguments$1[i]; 20 | for (var key in sourceObject) { 21 | if (Object.prototype.hasOwnProperty.call(sourceObject, key)) { 22 | targetObject[key] = sourceObject[key]; 23 | } 24 | } 25 | } 26 | return targetObject; 27 | } 28 | var _spread = Object.assign || mergeObjects; 29 | 30 | var script$1 = { 31 | name: 'LocalizedLink', 32 | props: [ 'to' ], 33 | methods: { 34 | localizedTo: function localizedTo () { 35 | if (typeof this.to === 'string') { 36 | return this.$localizePath(this.to); 37 | } 38 | else if (typeof this.to === 'object' && typeof this.to.path === 'string') { 39 | var o = JSON.parse(JSON.stringify(this.to)); 40 | o.path = this.$localizePath(o.path); 41 | return o; 42 | } 43 | else { 44 | return this.to; 45 | } 46 | }, 47 | }, 48 | }; 49 | 50 | function normalizeComponent(template, style, script, scopeId, isFunctionalTemplate, moduleIdentifier , shadowMode, createInjector, createInjectorSSR, createInjectorShadow) { 51 | if (typeof shadowMode !== 'boolean') { 52 | createInjectorSSR = createInjector; 53 | createInjector = shadowMode; 54 | shadowMode = false; 55 | } 56 | var options = typeof script === 'function' ? script.options : script; 57 | if (template && template.render) { 58 | options.render = template.render; 59 | options.staticRenderFns = template.staticRenderFns; 60 | options._compiled = true; 61 | if (isFunctionalTemplate) { 62 | options.functional = true; 63 | } 64 | } 65 | if (scopeId) { 66 | options._scopeId = scopeId; 67 | } 68 | var hook; 69 | if (moduleIdentifier) { 70 | hook = function (context) { 71 | context = 72 | context || 73 | (this.$vnode && this.$vnode.ssrContext) || 74 | (this.parent && this.parent.$vnode && this.parent.$vnode.ssrContext); 75 | if (!context && typeof __VUE_SSR_CONTEXT__ !== 'undefined') { 76 | context = __VUE_SSR_CONTEXT__; 77 | } 78 | if (style) { 79 | style.call(this, createInjectorSSR(context)); 80 | } 81 | if (context && context._registeredComponents) { 82 | context._registeredComponents.add(moduleIdentifier); 83 | } 84 | }; 85 | options._ssrRegister = hook; 86 | } 87 | else if (style) { 88 | hook = shadowMode 89 | ? function (context) { 90 | style.call(this, createInjectorShadow(context, this.$root.$options.shadowRoot)); 91 | } 92 | : function (context) { 93 | style.call(this, createInjector(context)); 94 | }; 95 | } 96 | if (hook) { 97 | if (options.functional) { 98 | var originalRender = options.render; 99 | options.render = function renderWithStyleInjection(h, context) { 100 | hook.call(context); 101 | return originalRender(h, context); 102 | }; 103 | } 104 | else { 105 | var existing = options.beforeCreate; 106 | options.beforeCreate = existing ? [].concat(existing, hook) : [hook]; 107 | } 108 | } 109 | return script; 110 | } 111 | 112 | /* script */ 113 | var __vue_script__$1 = script$1; 114 | 115 | /* template */ 116 | var __vue_render__$1 = function () {var _vm=this;var _h=_vm.$createElement;var _c=_vm._self._c||_h;return _c('router-link',_vm._b({attrs:{"to":_vm.localizedTo()}},'router-link',_vm.$attrs,false),[_vm._t("default")],2)}; 117 | var __vue_staticRenderFns__$1 = []; 118 | 119 | /* style */ 120 | var __vue_inject_styles__$1 = undefined; 121 | /* scoped */ 122 | var __vue_scope_id__$1 = undefined; 123 | /* module identifier */ 124 | var __vue_module_identifier__$1 = undefined; 125 | /* functional template */ 126 | var __vue_is_functional_template__$1 = false; 127 | /* style inject */ 128 | 129 | /* style inject SSR */ 130 | 131 | /* style inject shadow dom */ 132 | 133 | 134 | 135 | var __vue_component__$1 = /*#__PURE__*/normalizeComponent( 136 | { render: __vue_render__$1, staticRenderFns: __vue_staticRenderFns__$1 }, 137 | __vue_inject_styles__$1, 138 | __vue_script__$1, 139 | __vue_scope_id__$1, 140 | __vue_is_functional_template__$1, 141 | __vue_module_identifier__$1, 142 | false, 143 | undefined, 144 | undefined, 145 | undefined 146 | ); 147 | 148 | var script = { 149 | name: 'LanguageSwitcher', 150 | data: function data () { 151 | return { 152 | currentUrl: this.url || this.$router.currentRoute.fullPath, 153 | links: [], 154 | }; 155 | }, 156 | props: [ 'tag', 'active-class', 'url' ], 157 | methods: { 158 | getTag: function getTag () { 159 | if (this.tag) { return this.tag; } 160 | else { return 'div'; } 161 | }, 162 | generateLinks: function generateLinks () { 163 | var links = []; 164 | var activeClass = this.activeClass || 'router-active-language'; 165 | var tr = this._langRouter.translations; 166 | for (var lang in tr) { 167 | if (tr.hasOwnProperty(lang)) { 168 | links.push({ 169 | activeClass: (lang == this.$i18n.locale ? activeClass : ''), 170 | langIndex: lang, 171 | langName: tr[lang].name || lang, 172 | url: this.$localizePath(this.currentUrl, lang), 173 | }); 174 | } 175 | } 176 | this.links = links; 177 | }, 178 | }, 179 | watch: { 180 | $route: function $route (to) { 181 | this.currentUrl = this.url || to.fullPath; 182 | this.generateLinks(); 183 | }, 184 | }, 185 | mounted: function mounted () { 186 | this.generateLinks(); 187 | }, 188 | }; 189 | 190 | /* script */ 191 | var __vue_script__ = script; 192 | 193 | /* template */ 194 | var __vue_render__ = function () {var _vm=this;var _h=_vm.$createElement;var _c=_vm._self._c||_h;return _c(_vm.getTag(),{tag:"component",staticClass:"router-language-switcher"},[_vm._t("default",null,{"links":_vm.links})],2)}; 195 | var __vue_staticRenderFns__ = []; 196 | 197 | /* style */ 198 | var __vue_inject_styles__ = undefined; 199 | /* scoped */ 200 | var __vue_scope_id__ = undefined; 201 | /* module identifier */ 202 | var __vue_module_identifier__ = undefined; 203 | /* functional template */ 204 | var __vue_is_functional_template__ = false; 205 | /* style inject */ 206 | 207 | /* style inject SSR */ 208 | 209 | /* style inject shadow dom */ 210 | 211 | 212 | 213 | var __vue_component__ = /*#__PURE__*/normalizeComponent( 214 | { render: __vue_render__, staticRenderFns: __vue_staticRenderFns__ }, 215 | __vue_inject_styles__, 216 | __vue_script__, 217 | __vue_scope_id__, 218 | __vue_is_functional_template__, 219 | __vue_module_identifier__, 220 | false, 221 | undefined, 222 | undefined, 223 | undefined 224 | ); 225 | 226 | var defaultLanguage, translations, localizedURLs; exports.i18n = void 0; 227 | var loadedTranslations = []; 228 | function err (msg, error) { 229 | console.error('LangRouter: ' + msg); 230 | if (typeof error !== 'undefined') { console.error(error); } 231 | } 232 | var LangRouter = function LangRouter (options) { 233 | for (var lang in translations) { 234 | if (translations.hasOwnProperty(lang) && !localizedURLs[lang]) { 235 | localizedURLs[lang] = {}; 236 | } 237 | } 238 | for (var lang$1 in localizedURLs) { 239 | if (localizedURLs.hasOwnProperty(lang$1)) { 240 | addAliasesToRoutes(options.routes, lang$1); 241 | } 242 | } 243 | var router = new VueRouter__default['default'](options); 244 | router.beforeEach(switchLanguage); 245 | return router; 246 | }; 247 | LangRouter.install = function (Vue, options) { 248 | if (LangRouter.installed) { 249 | err('Already installed.'); 250 | return; 251 | } 252 | LangRouter.installed = true; 253 | if (!options) { 254 | err('Options missing.'); 255 | } 256 | defaultLanguage = options.defaultLanguage; 257 | translations = options.translations; 258 | localizedURLs = options.localizedURLs || {}; 259 | var isArr; 260 | if ((isArr = Array.isArray(translations)) || typeof translations !== 'object') { 261 | err('options.translations should be an object, received ' + (isArr ? 'array' : typeof translations) + ' instead.'); 262 | } 263 | if ((isArr = Array.isArray(localizedURLs)) || typeof localizedURLs !== 'object') { 264 | err('options.localizedURLs should be an object, received ' + (isArr ? 'array' : typeof localizedURLs) + ' instead.'); 265 | } 266 | if (typeof defaultLanguage !== 'string') { 267 | err('options.defaultLanguage should be a string, received ' + typeof defaultLanguage + ' instead.'); 268 | } 269 | Vue.use(VueI18n__default['default']); 270 | Vue.use(VueRouter__default['default']); 271 | var messages = {}; 272 | for (var lang in translations) { 273 | if (translations.hasOwnProperty(lang)) { 274 | var langMessages = translations[lang].messages; 275 | if (typeof langMessages === 'object' && !Array.isArray(langMessages)) { 276 | messages[lang] = translations[lang].messages; 277 | loadedTranslations.push(lang); 278 | } 279 | } 280 | } 281 | exports.i18n = new VueI18n__default['default'](_spread({}, {locale: defaultLanguage, 282 | fallbackLocale: defaultLanguage, 283 | messages: messages}, 284 | options.i18nOptions)); 285 | Vue.prototype._langRouter = { translations: translations }; 286 | Vue.prototype.$localizePath = localizePath; 287 | Vue.component('localized-link', __vue_component__$1); 288 | Vue.component('language-switcher', __vue_component__); 289 | }; 290 | function setLanguage (lang) { 291 | exports.i18n.locale = lang; 292 | document.querySelector('html').setAttribute('lang', lang); 293 | localStorage.setItem('VueAppLanguage', lang); 294 | return lang; 295 | } 296 | function loadLanguage (lang) { 297 | if (loadedTranslations.includes(lang)) { 298 | return Promise.resolve(setLanguage(lang)); 299 | } 300 | if (!translations[lang] || typeof translations[lang].load !== 'function') { 301 | err('Unable to load translations for "' + lang + '", "load" function is missing!'); 302 | } 303 | return translations[lang].load().then(function (messages) { 304 | exports.i18n.setLocaleMessage(lang, messages.default || messages); 305 | loadedTranslations.push(lang); 306 | return setLanguage(lang); 307 | }).catch(function (error) { 308 | err('Failed to load "' + lang + '" translation.', error); 309 | }); 310 | } 311 | function addAliasesToRoutes (routes, lang, child) { 312 | routes.forEach(function (route) { 313 | var alias = translatePath(route.path, lang); 314 | if (!child) { alias = '/' + lang + (alias.charAt(0) != '/' ? '/' : '') + alias; } 315 | if (route.alias) { 316 | if (!Array.isArray(route.alias)) { route.alias = [ route.alias ]; } 317 | } 318 | else { route.alias = []; } 319 | if (route.path != alias && route.alias.indexOf(alias) == -1) { route.alias.push(alias); } 320 | if (route.children) { addAliasesToRoutes(route.children, lang, true); } 321 | }); 322 | } 323 | function switchLanguage (to, from, next) { 324 | var lang = to.path.split('/')[1]; 325 | if (!translations[lang]) { 326 | var savedLang = localStorage.getItem('VueAppLanguage'); 327 | if (savedLang && translations[savedLang]) { lang = savedLang; } 328 | else { 329 | var preferredLang = getPrefferedLanguage(); 330 | if (preferredLang && translations[preferredLang]) { lang = preferredLang; } 331 | else { lang = defaultLanguage; } 332 | } 333 | if (lang != defaultLanguage) { 334 | var translatedPath = translatePath(to.path, lang); 335 | translatedPath = '/' + lang + (translatedPath.charAt(0) != '/' ? '/' : '') + translatedPath; 336 | return next({ path: translatedPath, query: to.query, hash: to.hash }); 337 | } 338 | } 339 | loadLanguage(lang).then(function () { 340 | return next(); 341 | }); 342 | } 343 | function translatePath (path, langTo, langFrom, matchedPath) { 344 | var pathChunks = path.split('/'); 345 | if (langFrom && localizedURLs[langFrom]) { 346 | if (langTo == langFrom) { return path; } 347 | var map = localizedURLs[langFrom]; 348 | var reversedMap = {}; 349 | Object.keys(map).forEach(function (key) { 350 | reversedMap[map[key]] = key; 351 | }); 352 | var matchedPathChunks = matchedPath.split('/'); 353 | for (var i = 0; i < pathChunks.length; i++) { 354 | var pathChunk = pathChunks[i]; 355 | if (matchedPathChunks[i].charAt(0) == ':') { continue; } 356 | pathChunks[i] = reversedMap[pathChunk] || pathChunk; 357 | } 358 | } 359 | for (var i$1 = 0; i$1 < pathChunks.length; i$1++) { 360 | var pathChunk$1 = pathChunks[i$1]; 361 | if (pathChunk$1.charAt(0) == ':') { continue; } 362 | pathChunks[i$1] = localizedURLs[langTo][pathChunk$1] || pathChunk$1; 363 | } 364 | return pathChunks.join('/'); 365 | } 366 | function getPrefferedLanguage () { 367 | function extractLanguage (s) { 368 | return s.split('-')[0].toLowerCase(); 369 | } 370 | if (navigator.languages && navigator.languages.length) { return extractLanguage(navigator.languages[0] || ''); } 371 | return extractLanguage(navigator.language || navigator.browserLanguage || navigator.userLanguage || ''); 372 | } 373 | function localizePath (fullPath, lang) { 374 | if (!lang || !localizedURLs[lang]) { lang = exports.i18n.locale; } 375 | var path = fullPath; 376 | var query = ''; 377 | if (fullPath.includes('?')) { 378 | path = fullPath.split('?')[0]; 379 | query = '?' + fullPath.split('?')[1]; 380 | } 381 | else if (fullPath.includes('#')) { 382 | path = fullPath.split('#')[0]; 383 | query = '#' + fullPath.split('#')[1]; 384 | } 385 | var pathChunks = path.split('/'); 386 | var pathLang = (localizedURLs[pathChunks[1]] ? pathChunks[1] : false); 387 | var currentPathLang = this.$router.currentRoute.path.split('/')[1]; 388 | if (lang == defaultLanguage && !localizedURLs[currentPathLang] && !pathLang) { return fullPath; } 389 | var resolvedPath = false; 390 | if (pathLang) { 391 | var resolvedRoute = this.$router.resolve(path); 392 | if (resolvedRoute.route.matched.length != 0) { 393 | resolvedPath = resolvedRoute.route.matched[resolvedRoute.route.matched.length - 1].path; 394 | resolvedPath = (resolvedPath.charAt(0) == '/' ? resolvedPath : '/' + resolvedPath); 395 | } 396 | else { 397 | err('Router could not resolve path "' + path + '". URL localization may not work as expected.'); 398 | } 399 | pathChunks.splice(1, 1); 400 | path = pathChunks.join('/'); 401 | } 402 | var translatedPath = translatePath(path, lang, pathLang, (resolvedPath || path)); 403 | translatedPath = '/' + lang + (translatedPath.charAt(0) != '/' ? '/' : '') + translatedPath; 404 | return translatedPath + query; 405 | } 406 | if (typeof window !== 'undefined' && window.Vue) { 407 | window.Vue.use(LangRouter); 408 | } 409 | 410 | exports['default'] = LangRouter; 411 | 412 | Object.defineProperty(exports, '__esModule', { value: true }); 413 | 414 | return exports; 415 | 416 | }({}, VueI18n, VueRouter)); 417 | -------------------------------------------------------------------------------- /dist/lang-router.umd.js: -------------------------------------------------------------------------------- 1 | /** 2 | * vue-lang-router v1.3.1 3 | * (c) 2021 Radek Altof 4 | * Released under the MIT License. 5 | */ 6 | 7 | (function (global, factory) { 8 | typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports, require('vue-i18n'), require('vue-router')) : 9 | typeof define === 'function' && define.amd ? define(['exports', 'vue-i18n', 'vue-router'], factory) : 10 | (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.LangRouter = {}, global.VueI18n, global.VueRouter)); 11 | }(this, (function (exports, VueI18n, VueRouter) { 'use strict'; 12 | 13 | function _interopDefaultLegacy (e) { return e && typeof e === 'object' && 'default' in e ? e : { 'default': e }; } 14 | 15 | var VueI18n__default = /*#__PURE__*/_interopDefaultLegacy(VueI18n); 16 | var VueRouter__default = /*#__PURE__*/_interopDefaultLegacy(VueRouter); 17 | 18 | function mergeObjects() { 19 | var arguments$1 = arguments; 20 | var targetObject = {}; 21 | for (var i = 0; i < arguments.length; i++) { 22 | var sourceObject = arguments$1[i]; 23 | for (var key in sourceObject) { 24 | if (Object.prototype.hasOwnProperty.call(sourceObject, key)) { 25 | targetObject[key] = sourceObject[key]; 26 | } 27 | } 28 | } 29 | return targetObject; 30 | } 31 | var _spread = Object.assign || mergeObjects; 32 | 33 | var script$1 = { 34 | name: 'LocalizedLink', 35 | props: [ 'to' ], 36 | methods: { 37 | localizedTo: function localizedTo () { 38 | if (typeof this.to === 'string') { 39 | return this.$localizePath(this.to); 40 | } 41 | else if (typeof this.to === 'object' && typeof this.to.path === 'string') { 42 | var o = JSON.parse(JSON.stringify(this.to)); 43 | o.path = this.$localizePath(o.path); 44 | return o; 45 | } 46 | else { 47 | return this.to; 48 | } 49 | }, 50 | }, 51 | }; 52 | 53 | function normalizeComponent(template, style, script, scopeId, isFunctionalTemplate, moduleIdentifier , shadowMode, createInjector, createInjectorSSR, createInjectorShadow) { 54 | if (typeof shadowMode !== 'boolean') { 55 | createInjectorSSR = createInjector; 56 | createInjector = shadowMode; 57 | shadowMode = false; 58 | } 59 | var options = typeof script === 'function' ? script.options : script; 60 | if (template && template.render) { 61 | options.render = template.render; 62 | options.staticRenderFns = template.staticRenderFns; 63 | options._compiled = true; 64 | if (isFunctionalTemplate) { 65 | options.functional = true; 66 | } 67 | } 68 | if (scopeId) { 69 | options._scopeId = scopeId; 70 | } 71 | var hook; 72 | if (moduleIdentifier) { 73 | hook = function (context) { 74 | context = 75 | context || 76 | (this.$vnode && this.$vnode.ssrContext) || 77 | (this.parent && this.parent.$vnode && this.parent.$vnode.ssrContext); 78 | if (!context && typeof __VUE_SSR_CONTEXT__ !== 'undefined') { 79 | context = __VUE_SSR_CONTEXT__; 80 | } 81 | if (style) { 82 | style.call(this, createInjectorSSR(context)); 83 | } 84 | if (context && context._registeredComponents) { 85 | context._registeredComponents.add(moduleIdentifier); 86 | } 87 | }; 88 | options._ssrRegister = hook; 89 | } 90 | else if (style) { 91 | hook = shadowMode 92 | ? function (context) { 93 | style.call(this, createInjectorShadow(context, this.$root.$options.shadowRoot)); 94 | } 95 | : function (context) { 96 | style.call(this, createInjector(context)); 97 | }; 98 | } 99 | if (hook) { 100 | if (options.functional) { 101 | var originalRender = options.render; 102 | options.render = function renderWithStyleInjection(h, context) { 103 | hook.call(context); 104 | return originalRender(h, context); 105 | }; 106 | } 107 | else { 108 | var existing = options.beforeCreate; 109 | options.beforeCreate = existing ? [].concat(existing, hook) : [hook]; 110 | } 111 | } 112 | return script; 113 | } 114 | 115 | /* script */ 116 | var __vue_script__$1 = script$1; 117 | 118 | /* template */ 119 | var __vue_render__$1 = function () {var _vm=this;var _h=_vm.$createElement;var _c=_vm._self._c||_h;return _c('router-link',_vm._b({attrs:{"to":_vm.localizedTo()}},'router-link',_vm.$attrs,false),[_vm._t("default")],2)}; 120 | var __vue_staticRenderFns__$1 = []; 121 | 122 | /* style */ 123 | var __vue_inject_styles__$1 = undefined; 124 | /* scoped */ 125 | var __vue_scope_id__$1 = undefined; 126 | /* module identifier */ 127 | var __vue_module_identifier__$1 = undefined; 128 | /* functional template */ 129 | var __vue_is_functional_template__$1 = false; 130 | /* style inject */ 131 | 132 | /* style inject SSR */ 133 | 134 | /* style inject shadow dom */ 135 | 136 | 137 | 138 | var __vue_component__$1 = /*#__PURE__*/normalizeComponent( 139 | { render: __vue_render__$1, staticRenderFns: __vue_staticRenderFns__$1 }, 140 | __vue_inject_styles__$1, 141 | __vue_script__$1, 142 | __vue_scope_id__$1, 143 | __vue_is_functional_template__$1, 144 | __vue_module_identifier__$1, 145 | false, 146 | undefined, 147 | undefined, 148 | undefined 149 | ); 150 | 151 | var script = { 152 | name: 'LanguageSwitcher', 153 | data: function data () { 154 | return { 155 | currentUrl: this.url || this.$router.currentRoute.fullPath, 156 | links: [], 157 | }; 158 | }, 159 | props: [ 'tag', 'active-class', 'url' ], 160 | methods: { 161 | getTag: function getTag () { 162 | if (this.tag) { return this.tag; } 163 | else { return 'div'; } 164 | }, 165 | generateLinks: function generateLinks () { 166 | var links = []; 167 | var activeClass = this.activeClass || 'router-active-language'; 168 | var tr = this._langRouter.translations; 169 | for (var lang in tr) { 170 | if (tr.hasOwnProperty(lang)) { 171 | links.push({ 172 | activeClass: (lang == this.$i18n.locale ? activeClass : ''), 173 | langIndex: lang, 174 | langName: tr[lang].name || lang, 175 | url: this.$localizePath(this.currentUrl, lang), 176 | }); 177 | } 178 | } 179 | this.links = links; 180 | }, 181 | }, 182 | watch: { 183 | $route: function $route (to) { 184 | this.currentUrl = this.url || to.fullPath; 185 | this.generateLinks(); 186 | }, 187 | }, 188 | mounted: function mounted () { 189 | this.generateLinks(); 190 | }, 191 | }; 192 | 193 | /* script */ 194 | var __vue_script__ = script; 195 | 196 | /* template */ 197 | var __vue_render__ = function () {var _vm=this;var _h=_vm.$createElement;var _c=_vm._self._c||_h;return _c(_vm.getTag(),{tag:"component",staticClass:"router-language-switcher"},[_vm._t("default",null,{"links":_vm.links})],2)}; 198 | var __vue_staticRenderFns__ = []; 199 | 200 | /* style */ 201 | var __vue_inject_styles__ = undefined; 202 | /* scoped */ 203 | var __vue_scope_id__ = undefined; 204 | /* module identifier */ 205 | var __vue_module_identifier__ = undefined; 206 | /* functional template */ 207 | var __vue_is_functional_template__ = false; 208 | /* style inject */ 209 | 210 | /* style inject SSR */ 211 | 212 | /* style inject shadow dom */ 213 | 214 | 215 | 216 | var __vue_component__ = /*#__PURE__*/normalizeComponent( 217 | { render: __vue_render__, staticRenderFns: __vue_staticRenderFns__ }, 218 | __vue_inject_styles__, 219 | __vue_script__, 220 | __vue_scope_id__, 221 | __vue_is_functional_template__, 222 | __vue_module_identifier__, 223 | false, 224 | undefined, 225 | undefined, 226 | undefined 227 | ); 228 | 229 | var defaultLanguage, translations, localizedURLs; exports.i18n = void 0; 230 | var loadedTranslations = []; 231 | function err (msg, error) { 232 | console.error('LangRouter: ' + msg); 233 | if (typeof error !== 'undefined') { console.error(error); } 234 | } 235 | var LangRouter = function LangRouter (options) { 236 | for (var lang in translations) { 237 | if (translations.hasOwnProperty(lang) && !localizedURLs[lang]) { 238 | localizedURLs[lang] = {}; 239 | } 240 | } 241 | for (var lang$1 in localizedURLs) { 242 | if (localizedURLs.hasOwnProperty(lang$1)) { 243 | addAliasesToRoutes(options.routes, lang$1); 244 | } 245 | } 246 | var router = new VueRouter__default['default'](options); 247 | router.beforeEach(switchLanguage); 248 | return router; 249 | }; 250 | LangRouter.install = function (Vue, options) { 251 | if (LangRouter.installed) { 252 | err('Already installed.'); 253 | return; 254 | } 255 | LangRouter.installed = true; 256 | if (!options) { 257 | err('Options missing.'); 258 | } 259 | defaultLanguage = options.defaultLanguage; 260 | translations = options.translations; 261 | localizedURLs = options.localizedURLs || {}; 262 | var isArr; 263 | if ((isArr = Array.isArray(translations)) || typeof translations !== 'object') { 264 | err('options.translations should be an object, received ' + (isArr ? 'array' : typeof translations) + ' instead.'); 265 | } 266 | if ((isArr = Array.isArray(localizedURLs)) || typeof localizedURLs !== 'object') { 267 | err('options.localizedURLs should be an object, received ' + (isArr ? 'array' : typeof localizedURLs) + ' instead.'); 268 | } 269 | if (typeof defaultLanguage !== 'string') { 270 | err('options.defaultLanguage should be a string, received ' + typeof defaultLanguage + ' instead.'); 271 | } 272 | Vue.use(VueI18n__default['default']); 273 | Vue.use(VueRouter__default['default']); 274 | var messages = {}; 275 | for (var lang in translations) { 276 | if (translations.hasOwnProperty(lang)) { 277 | var langMessages = translations[lang].messages; 278 | if (typeof langMessages === 'object' && !Array.isArray(langMessages)) { 279 | messages[lang] = translations[lang].messages; 280 | loadedTranslations.push(lang); 281 | } 282 | } 283 | } 284 | exports.i18n = new VueI18n__default['default'](_spread({}, {locale: defaultLanguage, 285 | fallbackLocale: defaultLanguage, 286 | messages: messages}, 287 | options.i18nOptions)); 288 | Vue.prototype._langRouter = { translations: translations }; 289 | Vue.prototype.$localizePath = localizePath; 290 | Vue.component('localized-link', __vue_component__$1); 291 | Vue.component('language-switcher', __vue_component__); 292 | }; 293 | function setLanguage (lang) { 294 | exports.i18n.locale = lang; 295 | document.querySelector('html').setAttribute('lang', lang); 296 | localStorage.setItem('VueAppLanguage', lang); 297 | return lang; 298 | } 299 | function loadLanguage (lang) { 300 | if (loadedTranslations.includes(lang)) { 301 | return Promise.resolve(setLanguage(lang)); 302 | } 303 | if (!translations[lang] || typeof translations[lang].load !== 'function') { 304 | err('Unable to load translations for "' + lang + '", "load" function is missing!'); 305 | } 306 | return translations[lang].load().then(function (messages) { 307 | exports.i18n.setLocaleMessage(lang, messages.default || messages); 308 | loadedTranslations.push(lang); 309 | return setLanguage(lang); 310 | }).catch(function (error) { 311 | err('Failed to load "' + lang + '" translation.', error); 312 | }); 313 | } 314 | function addAliasesToRoutes (routes, lang, child) { 315 | routes.forEach(function (route) { 316 | var alias = translatePath(route.path, lang); 317 | if (!child) { alias = '/' + lang + (alias.charAt(0) != '/' ? '/' : '') + alias; } 318 | if (route.alias) { 319 | if (!Array.isArray(route.alias)) { route.alias = [ route.alias ]; } 320 | } 321 | else { route.alias = []; } 322 | if (route.path != alias && route.alias.indexOf(alias) == -1) { route.alias.push(alias); } 323 | if (route.children) { addAliasesToRoutes(route.children, lang, true); } 324 | }); 325 | } 326 | function switchLanguage (to, from, next) { 327 | var lang = to.path.split('/')[1]; 328 | if (!translations[lang]) { 329 | var savedLang = localStorage.getItem('VueAppLanguage'); 330 | if (savedLang && translations[savedLang]) { lang = savedLang; } 331 | else { 332 | var preferredLang = getPrefferedLanguage(); 333 | if (preferredLang && translations[preferredLang]) { lang = preferredLang; } 334 | else { lang = defaultLanguage; } 335 | } 336 | if (lang != defaultLanguage) { 337 | var translatedPath = translatePath(to.path, lang); 338 | translatedPath = '/' + lang + (translatedPath.charAt(0) != '/' ? '/' : '') + translatedPath; 339 | return next({ path: translatedPath, query: to.query, hash: to.hash }); 340 | } 341 | } 342 | loadLanguage(lang).then(function () { 343 | return next(); 344 | }); 345 | } 346 | function translatePath (path, langTo, langFrom, matchedPath) { 347 | var pathChunks = path.split('/'); 348 | if (langFrom && localizedURLs[langFrom]) { 349 | if (langTo == langFrom) { return path; } 350 | var map = localizedURLs[langFrom]; 351 | var reversedMap = {}; 352 | Object.keys(map).forEach(function (key) { 353 | reversedMap[map[key]] = key; 354 | }); 355 | var matchedPathChunks = matchedPath.split('/'); 356 | for (var i = 0; i < pathChunks.length; i++) { 357 | var pathChunk = pathChunks[i]; 358 | if (matchedPathChunks[i].charAt(0) == ':') { continue; } 359 | pathChunks[i] = reversedMap[pathChunk] || pathChunk; 360 | } 361 | } 362 | for (var i$1 = 0; i$1 < pathChunks.length; i$1++) { 363 | var pathChunk$1 = pathChunks[i$1]; 364 | if (pathChunk$1.charAt(0) == ':') { continue; } 365 | pathChunks[i$1] = localizedURLs[langTo][pathChunk$1] || pathChunk$1; 366 | } 367 | return pathChunks.join('/'); 368 | } 369 | function getPrefferedLanguage () { 370 | function extractLanguage (s) { 371 | return s.split('-')[0].toLowerCase(); 372 | } 373 | if (navigator.languages && navigator.languages.length) { return extractLanguage(navigator.languages[0] || ''); } 374 | return extractLanguage(navigator.language || navigator.browserLanguage || navigator.userLanguage || ''); 375 | } 376 | function localizePath (fullPath, lang) { 377 | if (!lang || !localizedURLs[lang]) { lang = exports.i18n.locale; } 378 | var path = fullPath; 379 | var query = ''; 380 | if (fullPath.includes('?')) { 381 | path = fullPath.split('?')[0]; 382 | query = '?' + fullPath.split('?')[1]; 383 | } 384 | else if (fullPath.includes('#')) { 385 | path = fullPath.split('#')[0]; 386 | query = '#' + fullPath.split('#')[1]; 387 | } 388 | var pathChunks = path.split('/'); 389 | var pathLang = (localizedURLs[pathChunks[1]] ? pathChunks[1] : false); 390 | var currentPathLang = this.$router.currentRoute.path.split('/')[1]; 391 | if (lang == defaultLanguage && !localizedURLs[currentPathLang] && !pathLang) { return fullPath; } 392 | var resolvedPath = false; 393 | if (pathLang) { 394 | var resolvedRoute = this.$router.resolve(path); 395 | if (resolvedRoute.route.matched.length != 0) { 396 | resolvedPath = resolvedRoute.route.matched[resolvedRoute.route.matched.length - 1].path; 397 | resolvedPath = (resolvedPath.charAt(0) == '/' ? resolvedPath : '/' + resolvedPath); 398 | } 399 | else { 400 | err('Router could not resolve path "' + path + '". URL localization may not work as expected.'); 401 | } 402 | pathChunks.splice(1, 1); 403 | path = pathChunks.join('/'); 404 | } 405 | var translatedPath = translatePath(path, lang, pathLang, (resolvedPath || path)); 406 | translatedPath = '/' + lang + (translatedPath.charAt(0) != '/' ? '/' : '') + translatedPath; 407 | return translatedPath + query; 408 | } 409 | if (typeof window !== 'undefined' && window.Vue) { 410 | window.Vue.use(LangRouter); 411 | } 412 | 413 | exports['default'] = LangRouter; 414 | 415 | Object.defineProperty(exports, '__esModule', { value: true }); 416 | 417 | }))); 418 | --------------------------------------------------------------------------------