diff --git a/.eslintrc.js b/.eslintrc.js index de6d94271..034e0f176 100755 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -16,6 +16,8 @@ module.exports = { rules: { '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }], 'arrow-parens': 'off', + 'import/named': 'off', + 'import/namespace': 'off', 'no-console': [ 'error', { allow: ['assert', 'warn', 'error', 'info'] diff --git a/docs/content/en/api.md b/docs/content/en/api.md index 2cc284d5c..14dc175e3 100644 --- a/docs/content/en/api.md +++ b/docs/content/en/api.md @@ -160,6 +160,12 @@ Instance of [VueI18n class](http://kazupon.github.io/vue-i18n/api/#vuei18n-class Default locale as specified in options. +#### localeCodes + + - **Type**: `Array` + + List of locale codes of registered locales. + #### locales - **Type**: `Array` diff --git a/docs/content/es/api.md b/docs/content/es/api.md index 9b3f275b0..b89797185 100644 --- a/docs/content/es/api.md +++ b/docs/content/es/api.md @@ -160,6 +160,12 @@ Instance of [VueI18n class](http://kazupon.github.io/vue-i18n/api/#vuei18n-class Default locale as specified in options. +#### localeCodes + + - **Type**: `Array` + + List of locale codes of registered locales. + #### locales - **Type**: `Array` diff --git a/package.json b/package.json index 17fbbda78..d24d92190 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "dev:basic:generate": "nuxt generate -c ./test/fixture/basic/nuxt.config.js", "start:dist": "jiti ./test/utils/http-server-internal.js --port 8080 -v dist", "coverage": "codecov", - "lint": "eslint --ext .js,.vue,.ts src test types", + "lint": "eslint --ext .js,.vue,.ts src test types && tsc", "test": "yarn test:types && yarn test:unit && yarn test:e2e-ssr && yarn test:e2e-browser", "test:e2e-ssr": "jest test/module.test", "test:e2e-browser": "jest test/browser.test", @@ -36,10 +36,16 @@ "docs:dev": "cd ./docs && yarn dev && cd ..", "docs:build": "cd ./docs && yarn generate && cd .." }, + "husky": { + "hooks": { + "pre-commit": "npm run lint", + "post-merge": "yarn" + } + }, "eslintIgnore": [ "src/templates/options.js", "test/fixture/typescript/**/*", - "**/*.d.ts" + "types/vue.d.ts" ], "files": [ "src", @@ -75,7 +81,6 @@ "collectCoverageFrom": [ "src/**/*.js", "!src/templates/*.js", - "!src/plugins/*.js", "!src/helpers/utils.js", "!src/helpers/constants.js" ] @@ -95,16 +100,19 @@ "@babel/core": "7.13.8", "@babel/preset-env": "7.13.8", "@babel/runtime": "7.13.8", - "@nuxt/types": "2.15.2", + "@nuxt/types": "2.15.3", "@nuxtjs/composition-api": "0.21.0", "@nuxtjs/eslint-config-typescript": "6.0.0", "@nuxtjs/module-test-utils": "1.6.3", "@release-it/conventional-changelog": "2.0.1", "@types/argparse": "2.0.5", + "@types/cookie": "^0.4.0", "@types/finalhandler": "1.1.0", "@types/jest": "26.0.20", "@types/jest-dev-server": "4.2.0", + "@types/js-cookie": "^2.2.6", "@types/jsdom": "16.2.6", + "@types/lodash.merge": "^4.6.6", "@types/request-promise-native": "1.0.17", "@types/serve-static": "1.13.9", "argparse": "2.0.1", @@ -114,9 +122,11 @@ "core-js": "3.9.1", "eslint": "7.21.0", "finalhandler": "1.1.2", + "husky": "4.3.8", "jest": "26.6.3", "jest-dev-server": "4.4.0", "jsdom": "16.4.0", + "lodash.merge": "^4.6.2", "messageformat": "2.3.0", "nuxt": "2.15.2", "playwright-chromium": "1.9.1", diff --git a/src/core/hooks.js b/src/core/hooks.js index 566cf2574..1aa2b78ae 100644 --- a/src/core/hooks.js +++ b/src/core/hooks.js @@ -1,12 +1,19 @@ -import { MODULE_NAME, STRATEGIES } from '../helpers/constants' +import { STRATEGIES } from '../helpers/constants' +import { formatMessage } from '../templates/utils-common' -export function createExtendRoutesHook (moduleContainer, options) { - const nuxtOptions = moduleContainer.options +/** + * @this {import('@nuxt/types/config/module').ModuleThis} + * + * @param {import('../../types/internal').ResolvedOptions} options + * @return {import('@nuxt/types/config/router').NuxtOptionsRouter['extendRoutes']} + */ +export function createExtendRoutesHook (options) { + const nuxtOptions = this.options let includeUprefixedFallback = nuxtOptions.target === 'static' // Doesn't seem like we can tell whether we are in nuxt generate from the module so we'll // take advantage of the 'generate:before' hook to store variable. - moduleContainer.nuxt.hook('generate:before', () => { includeUprefixedFallback = true }) + this.nuxt.hook('generate:before', () => { includeUprefixedFallback = true }) const pagesDir = nuxtOptions.dir && nuxtOptions.dir.pages ? nuxtOptions.dir.pages : 'pages' const { trailingSlash } = nuxtOptions.router @@ -28,33 +35,30 @@ export function createExtendRoutesHook (moduleContainer, options) { } } -export function buildHook (moduleContainer, options) { +/** + * @this {import('@nuxt/types/config/module').ModuleThis} + * + * @param {import('../../types/internal').ResolvedOptions} options + */ +export function buildHook (options) { if (options.strategy === STRATEGIES.NO_PREFIX && options.differentDomains) { // eslint-disable-next-line no-console - console.warn('[' + MODULE_NAME + '] The `differentDomains` option and `no_prefix` strategy are not compatible. Change strategy or disable `differentDomains` option.') + console.warn(formatMessage('The `differentDomains` option and `no_prefix` strategy are not compatible. Change strategy or disable `differentDomains` option.')) } if ('forwardedHost' in options) { // eslint-disable-next-line no-console - console.warn('[' + MODULE_NAME + '] The `forwardedHost` option is deprecated. You can safely remove it. See: https://github.com/nuxt-community/i18n-module/pull/630.') + console.warn(formatMessage('The `forwardedHost` option is deprecated. You can safely remove it. See: https://github.com/nuxt-community/i18n-module/pull/630.')) } // Add vue-i18n-loader if applicable if (options.vueI18nLoader) { - moduleContainer.extendBuild(config => { - const vueLoader = config.module.rules.find(el => el.loader.includes('vue-loader')) - if (vueLoader && vueLoader.options && vueLoader.options.loaders) { - // vue-loader under 15.0.0 - /* istanbul ignore next */ - vueLoader.options.loaders.i18n = require.resolve('@intlify/vue-i18n-loader') - } else { - // vue-loader after 15.0.0 - config.module.rules.push({ - resourceQuery: /blockType=i18n/, - type: 'javascript/auto', - loader: require.resolve('@intlify/vue-i18n-loader') - }) - } + this.extendBuild(config => { + config.module?.rules.push({ + resourceQuery: /blockType=i18n/, + type: 'javascript/auto', + loader: require.resolve('@intlify/vue-i18n-loader') + }) }) } } diff --git a/src/helpers/components.js b/src/helpers/components.js index 0396e4325..256cd6e33 100644 --- a/src/helpers/components.js +++ b/src/helpers/components.js @@ -1,31 +1,43 @@ -const { readFileSync } = require('fs') -const parser = require('@babel/parser') -const traverse = require('@babel/traverse').default +import { readFileSync } from 'fs' +import { parse } from '@babel/parser' +import traverse from '@babel/traverse' // Must not be an explicit dependency to avoid version mismatch issue. // See https://github.com/nuxt-community/i18n-module/issues/297 -const compiler = require('vue-template-compiler') -const { COMPONENT_OPTIONS_KEY, MODULE_NAME } = require('./constants') +import { parseComponent } from 'vue-template-compiler' +import { formatMessage } from '../templates/utils-common' +import { COMPONENT_OPTIONS_KEY } from './constants' /** * Extracts nuxtI18n component options for given component file path. * - * @param {string} path The path to the component file - * @return {Record} + * @typedef {Required>} ComputedPageOptions + * + * @param {import('@nuxt/types/config/router').NuxtRouteConfig['component']} component + * @return {ComputedPageOptions | false} */ -exports.extractComponentOptions = path => { - let componentOptions = {} +export function extractComponentOptions (component) { + if (typeof (component) !== 'string') { + return false + } + + /** @type {ComputedPageOptions | false} */ + let componentOptions = { + locales: [], + paths: {} + } + let contents try { - contents = readFileSync(path).toString() + contents = readFileSync(component).toString() } catch (error) { - console.warn(`[${MODULE_NAME}] Couldn't read page component file (${error.message})`) + console.warn(formatMessage(`Couldn't read page component file (${error.message})`)) } if (!contents) { return componentOptions } - const Component = compiler.parseComponent(contents) + const Component = parseComponent(contents) if (!Component.script || Component.script.content.length < 1) { return componentOptions @@ -34,7 +46,7 @@ exports.extractComponentOptions = path => { const script = Component.script.content try { - const parsed = parser.parse(script, { + const parsed = parse(script, { sourceType: 'module', plugins: [ 'nullishCoalescingOperator', @@ -50,8 +62,11 @@ exports.extractComponentOptions = path => { traverse(parsed, { enter (path) { + // @ts-ignore if (path.node.type === 'Property') { + // @ts-ignore if (path.node.key.name === COMPONENT_OPTIONS_KEY) { + // @ts-ignore const data = script.substring(path.node.start, path.node.end) componentOptions = Function(`return ({${data}})`)()[COMPONENT_OPTIONS_KEY] // eslint-disable-line } @@ -60,7 +75,7 @@ exports.extractComponentOptions = path => { }) } catch (error) { // eslint-disable-next-line no-console - console.warn('[' + MODULE_NAME + `] Error parsing "${COMPONENT_OPTIONS_KEY}" component option in file "${path}".`) + console.warn(formatMessage(`Error parsing "${COMPONENT_OPTIONS_KEY}" component option in file "${component}"`)) } return componentOptions diff --git a/src/helpers/constants.js b/src/helpers/constants.js index edf8dbab2..a81606c70 100644 --- a/src/helpers/constants.js +++ b/src/helpers/constants.js @@ -1,26 +1,26 @@ -const packageJson = require('../../package.json') +/** + * @typedef {import('../../types').Options} Options + */ // Internals -exports.MODULE_NAME = packageJson.name -exports.ROOT_DIR = 'nuxt-i18n' -exports.LOCALE_CODE_KEY = 'code' -exports.LOCALE_ISO_KEY = 'iso' -exports.LOCALE_DIR_KEY = 'dir' -exports.LOCALE_DOMAIN_KEY = 'domain' -exports.LOCALE_FILE_KEY = 'file' +export const ROOT_DIR = 'nuxt-i18n' // Options -const STRATEGIES = { - PREFIX: 'prefix', - PREFIX_EXCEPT_DEFAULT: 'prefix_except_default', - PREFIX_AND_DEFAULT: 'prefix_and_default', - NO_PREFIX: 'no_prefix' +const STRATEGY_PREFIX = 'prefix' +const STRATEGY_PREFIX_EXCEPT_DEFAULT = 'prefix_except_default' +const STRATEGY_PREFIX_AND_DEFAULT = 'prefix_and_default' +const STRATEGY_NO_PREFIX = 'no_prefix' +export const STRATEGIES = { + PREFIX: STRATEGY_PREFIX, + PREFIX_EXCEPT_DEFAULT: STRATEGY_PREFIX_EXCEPT_DEFAULT, + PREFIX_AND_DEFAULT: STRATEGY_PREFIX_AND_DEFAULT, + NO_PREFIX: STRATEGY_NO_PREFIX } -exports.STRATEGIES = STRATEGIES +export const COMPONENT_OPTIONS_KEY = 'nuxtI18n' -exports.COMPONENT_OPTIONS_KEY = 'nuxtI18n' -exports.DEFAULT_OPTIONS = { +/** @type {Options} */ +export const DEFAULT_OPTIONS = { vueI18n: {}, vueI18nLoader: false, locales: [], @@ -28,20 +28,20 @@ exports.DEFAULT_OPTIONS = { defaultDirection: 'ltr', routesNameSeparator: '___', defaultLocaleRouteNameSuffix: 'default', - strategy: STRATEGIES.PREFIX_EXCEPT_DEFAULT, + strategy: STRATEGY_PREFIX_EXCEPT_DEFAULT, lazy: false, langDir: null, rootRedirect: null, detectBrowserLanguage: { - useCookie: true, + alwaysRedirect: false, cookieCrossOrigin: false, cookieDomain: null, cookieKey: 'i18n_redirected', cookieSecure: false, - alwaysRedirect: false, fallbackLocale: '', onlyOnNoPrefix: false, - onlyOnRoot: false + onlyOnRoot: false, + useCookie: true }, differentDomains: false, seo: false, @@ -58,4 +58,3 @@ exports.DEFAULT_OPTIONS = { beforeLanguageSwitch: () => null, onLanguageSwitched: () => null } -exports.NESTED_OPTIONS = ['detectBrowserLanguage', 'vuex'] diff --git a/src/helpers/routes.js b/src/helpers/routes.js index a979423cc..a7687fccc 100644 --- a/src/helpers/routes.js +++ b/src/helpers/routes.js @@ -1,56 +1,78 @@ -const { STRATEGIES } = require('./constants') -const { extractComponentOptions } = require('./components') -const { getPageOptions, getLocaleCodes } = require('./utils') - -exports.makeRoutes = (baseRoutes, { +import { STRATEGIES } from './constants' +import { extractComponentOptions } from './components' +import { getPageOptions } from './utils' + +/** + * @typedef {import('@nuxt/types/config/router').NuxtRouteConfig} NuxtRouteConfig + * @typedef {import('../../types/internal').ResolvedOptions & { + * pagesDir: string + * includeUprefixedFallback: boolean + * trailingSlash: import('@nuxt/types/config/router').NuxtOptionsRouter['trailingSlash'] + * }} MakeRouteOptions + * + * @param {NuxtRouteConfig[]} baseRoutes + * @param {MakeRouteOptions} options + * @return {NuxtRouteConfig[]} + */ +export function makeRoutes (baseRoutes, { defaultLocale, defaultLocaleRouteNameSuffix, differentDomains, includeUprefixedFallback, - locales, + localeCodes, pages, pagesDir, parsePages, routesNameSeparator, strategy, trailingSlash -}) => { - locales = getLocaleCodes(locales) +}) { + /** @type {NuxtRouteConfig[]} */ let localizedRoutes = [] - const buildLocalizedRoutes = (route, routeOptions = {}, isChild = false, isExtraRouteTree = false) => { + /** + * @param {NuxtRouteConfig} route + * @param {readonly import('../../types').Locale[]} allowedLocaleCodes + * @param {boolean} [isChild=false] + * @param {boolean} [isExtraRouteTree=false] + * @return {NuxtRouteConfig | NuxtRouteConfig[]} + */ + const buildLocalizedRoutes = (route, allowedLocaleCodes, isChild = false, isExtraRouteTree = false) => { + /** @type {NuxtRouteConfig[]} */ const routes = [] - let pageOptions // Skip route if it is only a redirect without a component. if (route.redirect && !route.component) { return route } - // Extract i18n options from page - if (parsePages) { - pageOptions = extractComponentOptions(route.component) - } else { - pageOptions = getPageOptions(route, pages, locales, pagesDir, defaultLocale) - } + const pageOptions = parsePages + ? extractComponentOptions(route.component) + : getPageOptions(route, pages, allowedLocaleCodes, pagesDir, defaultLocale) // Skip route if i18n is disabled on page if (pageOptions === false) { return route } - // Component's specific options + // Component-specific options const componentOptions = { - locales, + // @ts-ignore + locales: localeCodes, ...pageOptions, - ...routeOptions + ...{ locales: allowedLocaleCodes } } - // Double check locales to remove any locales not found in pageOptions - // This is there to prevent children routes being localized even though - // they are disabled in the configuration - if (typeof componentOptions.locales !== 'undefined' && componentOptions.locales.length > 0 && - typeof pageOptions.locales !== 'undefined' && pageOptions.locales.length > 0) { - componentOptions.locales = componentOptions.locales.filter((locale) => pageOptions.locales.includes(locale)) + + // Double check locales to remove any locales not found in pageOptions. + // This is there to prevent children routes being localized even though they are disabled in the configuration. + if (componentOptions.locales.length > 0 && pageOptions.locales !== undefined && pageOptions.locales.length > 0) { + const filteredLocales = [] + for (const locale of componentOptions.locales) { + if (pageOptions.locales.includes(locale)) { + filteredLocales.push(locale) + } + } + componentOptions.locales = filteredLocales } // Generate routes for component's supported locales @@ -69,12 +91,13 @@ exports.makeRoutes = (baseRoutes, { if (route.children) { localizedRoute.children = [] for (let i = 0, length1 = route.children.length; i < length1; i++) { - localizedRoute.children = localizedRoute.children.concat(buildLocalizedRoutes(route.children[i], { locales: [locale] }, true, isExtraRouteTree)) + localizedRoute.children = localizedRoute.children.concat(buildLocalizedRoutes(route.children[i], [locale], true, isExtraRouteTree)) } } // Get custom path if any if (componentOptions.paths && componentOptions.paths[locale]) { + // @ts-ignore path = componentOptions.paths[locale] } @@ -91,12 +114,12 @@ exports.makeRoutes = (baseRoutes, { defaultRoute.name = localizedRoute.name + routesNameSeparator + defaultLocaleRouteNameSuffix } - if (defaultRoute.children) { + if (route.children) { // Recreate child routes with default suffix added defaultRoute.children = [] for (const childRoute of route.children) { // isExtraRouteTree argument is true to indicate that this is extra route added for PREFIX_AND_DEFAULT strategy - defaultRoute.children = defaultRoute.children.concat(buildLocalizedRoutes(childRoute, { locales: [locale] }, true, true)) + defaultRoute.children = defaultRoute.children.concat(buildLocalizedRoutes(childRoute, [locale], true, true)) } } @@ -145,10 +168,11 @@ exports.makeRoutes = (baseRoutes, { for (let i = 0, length1 = baseRoutes.length; i < length1; i++) { const route = baseRoutes[i] - localizedRoutes = localizedRoutes.concat(buildLocalizedRoutes(route, { locales })) + localizedRoutes = localizedRoutes.concat(buildLocalizedRoutes(route, localeCodes)) } try { + // @ts-ignore const { sortRoutes } = require('@nuxt/utils') localizedRoutes = sortRoutes(localizedRoutes) } catch (error) { diff --git a/src/helpers/utils.js b/src/helpers/utils.js index 169cacf1e..8b25a0502 100644 --- a/src/helpers/utils.js +++ b/src/helpers/utils.js @@ -1,43 +1,28 @@ -const { LOCALE_CODE_KEY } = require('./constants') - /** - * Get an array of locale codes from a list of locales - * @param {Array} locales Locales list from nuxt config - * @return {Array} List of locale codes + * @typedef {import('../../types/internal').ResolvedOptions} ResolvedOptions */ -const getLocaleCodes = (locales = []) => { - if (locales.length) { - // If first item is a sting, assume locales is a list of codes already - if (typeof locales[0] === 'string') { - return locales - } - // Attempt to get codes from a list of objects - if (typeof locales[0][LOCALE_CODE_KEY] === 'string') { - return locales.map(locale => locale[LOCALE_CODE_KEY]) - } - } - return [] -} - -exports.getLocaleCodes = getLocaleCodes /** * Retrieve page's options from the module's configuration for a given route - * @param {Object} route Route - * @param {Object} pages Pages options from module's configuration - * @param {Array} locales Locale from module's configuration - * @param {String} pagesDir Pages dir from Nuxt's configuration - * @param {String} defaultLocale Default locale from Nuxt's configuration - * @return {Object} Page options + * + * @typedef {{ locales: readonly string[], paths: Record} } ComputedPageOptions + * + * @param {import('@nuxt/types/config/router').NuxtRouteConfig} route + * @param {ResolvedOptions['pages']} pages Pages options from module's configuration + * @param {ResolvedOptions['localeCodes']} localeCodes + * @param {string} pagesDir Pages dir from Nuxt's configuration + * @param {ResolvedOptions['defaultLocale']} defaultLocale Default locale from Nuxt's configuration + * @return {ComputedPageOptions | false} Page options */ -exports.getPageOptions = (route, pages, locales, pagesDir, defaultLocale) => { +export function getPageOptions (route, pages, localeCodes, pagesDir, defaultLocale) { + /** @type {ComputedPageOptions} */ const options = { - locales: getLocaleCodes(locales), + locales: localeCodes, paths: {} } const pattern = new RegExp(`${pagesDir}/`, 'i') const chunkName = route.chunkName ? route.chunkName.replace(pattern, '') : route.name - const pageOptions = pages[chunkName] + const pageOptions = chunkName ? pages[chunkName] : undefined // Routing disabled if (pageOptions === false) { return false @@ -51,16 +36,20 @@ exports.getPageOptions = (route, pages, locales, pagesDir, defaultLocale) => { options.locales = options.locales.filter(locale => pageOptions[locale] !== false) // Construct paths object - options.locales - .forEach(locale => { - if (typeof pageOptions[locale] === 'string') { - // Set custom path if any - options.paths[locale] = pageOptions[locale] - } else if (typeof pageOptions[defaultLocale] === 'string') { - // Set default locale's custom path if any - options.paths[locale] = pageOptions[defaultLocale] - } - }) + for (const locale of options.locales) { + const customLocalePath = pageOptions[locale] + if (typeof customLocalePath === 'string') { + // Set custom path if any + options.paths[locale] = customLocalePath + continue + } + + const customDefaultLocalePath = pageOptions[defaultLocale] + if (typeof customDefaultLocalePath === 'string') { + // Set default locale's custom path if any + options.paths[locale] = customDefaultLocalePath + } + } return options } diff --git a/src/index.js b/src/index.js index c0c6447d1..80e00b027 100644 --- a/src/index.js +++ b/src/index.js @@ -1,36 +1,33 @@ import { resolve, join } from 'path' import { readdirSync } from 'fs' +import merge from 'lodash.merge' +// @ts-ignore import { directive as i18nExtensionsDirective } from '@intlify/vue-i18n-extensions' -import { MODULE_NAME, COMPONENT_OPTIONS_KEY, DEFAULT_OPTIONS, LOCALE_CODE_KEY, LOCALE_ISO_KEY, LOCALE_DIR_KEY, LOCALE_DOMAIN_KEY, LOCALE_FILE_KEY, NESTED_OPTIONS, ROOT_DIR, STRATEGIES } from './helpers/constants' -import { getLocaleCodes } from './helpers/utils' +import { COMPONENT_OPTIONS_KEY, DEFAULT_OPTIONS, ROOT_DIR, STRATEGIES } from './helpers/constants' import { buildHook, createExtendRoutesHook } from './core/hooks' +import { formatMessage } from './templates/utils-common' -export default function (userOptions) { - const options = { ...DEFAULT_OPTIONS, ...userOptions, ...this.options.i18n } - // Options that have nested config options must be merged - // individually with defaults to prevent missing options - for (const key of NESTED_OPTIONS) { - if (options[key] !== false) { - options[key] = { ...DEFAULT_OPTIONS[key], ...options[key] } - } - } +/** @type {import('@nuxt/types').Module} */ +export default function (moduleOptions) { + /** @type {import('../types/internal').ResolvedOptions} */ + const options = merge({}, DEFAULT_OPTIONS, moduleOptions, this.options.i18n) if (!Object.values(STRATEGIES).includes(options.strategy)) { // eslint-disable-next-line no-console - console.error('[' + MODULE_NAME + '] Invalid "strategy" option "' + options.strategy + '" (must be one of: ' + Object.values(STRATEGIES).join(', ') + ').') + console.error(formatMessage(`Invalid "strategy" option "${options.strategy}" (must be one of: ${Object.values(STRATEGIES).join(', ')}).`)) return } if (options.lazy) { if (!options.langDir) { - throw new Error(`[${MODULE_NAME}] When using the "lazy" option you must also set the "langDir" option.`) + throw new Error(formatMessage('When using the "lazy" option you must also set the "langDir" option.')) } if (!options.locales.length || typeof options.locales[0] === 'string') { - throw new Error(`[${MODULE_NAME}] When using the "langDir" option the "locales" option must be a list of objects.`) + throw new Error(formatMessage('When using the "langDir" option the "locales" option must be a list of objects.')) } for (const locale of options.locales) { - if (!locale[LOCALE_FILE_KEY]) { - throw new Error(`[${MODULE_NAME}] All locale objects must have the "file" property set when using "lazy".\nFound none in:\n${JSON.stringify(locale, null, 2)}.`) + if (typeof (locale) === 'string' || !locale.file) { + throw new Error(formatMessage(`All locales must be objects and have the "file" property set when using "lazy".\nFound none in:\n${JSON.stringify(locale, null, 2)}.`)) } } options.langDir = this.nuxt.resolver.resolveAlias(options.langDir) @@ -39,27 +36,36 @@ export default function (userOptions) { // Templates (including plugins). // This is done here rather than in the build hook to ensure the order the plugins are added // is predictable between different modules. - const localeCodes = getLocaleCodes(options.locales) const nuxtOptions = this.options - const { trailingSlash } = nuxtOptions.router + const normalizedLocales = [] + for (const locale of options.locales) { + if (typeof (locale) === 'string') { + normalizedLocales.push({ code: locale }) + } else { + normalizedLocales.push(locale) + } + } + options.normalizedLocales = normalizedLocales + // Get an array of locale codes from the list of locales. + options.localeCodes = options.normalizedLocales.map(locale => locale.code) const templatesOptions = { - ...options, - IS_UNIVERSAL_MODE: nuxtOptions.mode === 'universal', - MODULE_NAME, - LOCALE_CODE_KEY, - LOCALE_ISO_KEY, - LOCALE_DIR_KEY, - LOCALE_DOMAIN_KEY, - LOCALE_FILE_KEY, - STRATEGIES, - COMPONENT_OPTIONS_KEY, - localeCodes, - trailingSlash + Constants: { + COMPONENT_OPTIONS_KEY, + STRATEGIES + }, + nuxtOptions: { + isUniversalMode: nuxtOptions.mode === 'universal', + trailingSlash: nuxtOptions.router.trailingSlash + }, + options } const templatesPath = join(__dirname, '/templates') for (const file of readdirSync(templatesPath)) { + if (!file.endsWith('.js')) { + continue + } if (file.startsWith('plugin.')) { if (file === 'plugin.seo.js' && !options.seo) { continue @@ -79,14 +85,22 @@ export default function (userOptions) { } } - if (options.strategy !== STRATEGIES.NO_PREFIX && localeCodes.length) { - this.extendRoutes(createExtendRoutesHook(this, options)) + if (options.strategy !== STRATEGIES.NO_PREFIX && options.localeCodes.length) { + this.extendRoutes(createExtendRoutesHook.call(this, options)) } - this.nuxt.hook('build:before', () => buildHook(this, options)) + this.nuxt.hook('build:before', () => buildHook.call(this, options)) this.options.alias['~i18n-klona'] = require.resolve('klona/full').replace(/\.js$/, '.mjs') + + if (!Array.isArray(this.options.router.middleware)) { + throw new TypeError(formatMessage('options.router.middleware is not an array.')) + } this.options.router.middleware.push('nuxti18n') + + if (!this.options.render.bundleRenderer || typeof (this.options.render.bundleRenderer) !== 'object') { + throw new TypeError(formatMessage('options.render.bundleRenderer is not an object.')) + } this.options.render.bundleRenderer.directives = this.options.render.bundleRenderer.directives || {} this.options.render.bundleRenderer.directives.t = i18nExtensionsDirective } diff --git a/src/templates/head-meta.js b/src/templates/head-meta.js index 2cc9b199e..861dc239a 100644 --- a/src/templates/head-meta.js +++ b/src/templates/head-meta.js @@ -1,21 +1,20 @@ import VueMeta from 'vue-meta' -import { - defaultLocale, - defaultDirection, - COMPONENT_OPTIONS_KEY, - LOCALE_DIR_KEY, - LOCALE_ISO_KEY, - MODULE_NAME, - STRATEGIES, - strategy -} from './options' +import { Constants, options } from './options' +import { formatMessage } from './utils-common' +/** @typedef {Required>} SeoMeta */ + +/** + * @this {import('vue/types/vue').Vue} + * @return {import('vue-meta').MetaInfo} + */ export function nuxtI18nHead ({ addDirAttribute = true, addSeoAttributes = false } = {}) { // Can happen when using from a global mixin. if (!this.$i18n) { return {} } + /** @type {SeoMeta} */ const metaObject = { htmlAttrs: {}, link: [], @@ -23,8 +22,8 @@ export function nuxtI18nHead ({ addDirAttribute = true, addSeoAttributes = false } const currentLocale = this.$i18n.localeProperties - const currentLocaleIso = currentLocale[LOCALE_ISO_KEY] - const currentLocaleDir = currentLocale[LOCALE_DIR_KEY] || defaultDirection + const currentLocaleIso = currentLocale.iso + const currentLocaleDir = currentLocale.dir || options.defaultDirection /** * Adding Direction Attribute: @@ -38,37 +37,49 @@ export function nuxtI18nHead ({ addDirAttribute = true, addSeoAttributes = false */ if ( addSeoAttributes && + // @ts-ignore (VueMeta.hasMetaInfo ? VueMeta.hasMetaInfo(this) : this._hasMetaInfo) && this.$i18n.locale && this.$i18n.locales && - this.$options[COMPONENT_OPTIONS_KEY] !== false && - !(this.$options[COMPONENT_OPTIONS_KEY] && this.$options[COMPONENT_OPTIONS_KEY].seo === false) + this.$options[Constants.COMPONENT_OPTIONS_KEY] !== false && + // @ts-ignore + !(this.$options[Constants.COMPONENT_OPTIONS_KEY] && this.$options[Constants.COMPONENT_OPTIONS_KEY].seo === false) ) { if (currentLocaleIso) { metaObject.htmlAttrs.lang = currentLocaleIso // TODO: simple lang or "specific" lang with territory? } - addHreflangLinks.bind(this)(this.$i18n.locales, this.$i18n.__baseUrl, metaObject.link) + const locales = /** @type {import('../../types').LocaleObject[]} */(this.$i18n.locales) + + addHreflangLinks.bind(this)(locales, this.$i18n.__baseUrl, metaObject.link) addCanonicalLinks.bind(this)(this.$i18n.__baseUrl, metaObject.link) addCurrentOgLocale.bind(this)(currentLocale, currentLocaleIso, metaObject.meta) - addAlternateOgLocales.bind(this)(this.$i18n.locales, currentLocaleIso, metaObject.meta) + addAlternateOgLocales.bind(this)(locales, currentLocaleIso, metaObject.meta) } /** * Internals: */ + /** + * @this {import('vue/types/vue').Vue} + * + * @param {import('../../types').LocaleObject[]} locales + * @param {string} baseUrl + * @param {SeoMeta['link']} link + */ function addHreflangLinks (locales, baseUrl, link) { - if (strategy === STRATEGIES.NO_PREFIX) { + if (options.strategy === Constants.STRATEGIES.NO_PREFIX) { return } + /** @type {Map} */ const localeMap = new Map() for (const locale of locales) { - const localeIso = isoFromLocale(locale) + const localeIso = locale.iso if (!localeIso) { // eslint-disable-next-line no-console - console.warn(`[${MODULE_NAME}] Locale ISO code is required to generate alternate link`) + console.warn(formatMessage('Locale ISO code is required to generate alternate link')) continue } @@ -82,24 +93,36 @@ export function nuxtI18nHead ({ addDirAttribute = true, addSeoAttributes = false } for (const [iso, mapLocale] of localeMap.entries()) { - link.push({ - hid: `i18n-alt-${iso}`, - rel: 'alternate', - href: toAbsoluteUrl(this.switchLocalePath(mapLocale.code), baseUrl), - hreflang: iso - }) + const localePath = this.switchLocalePath(mapLocale.code) + if (localePath) { + link.push({ + hid: `i18n-alt-${iso}`, + rel: 'alternate', + href: toAbsoluteUrl(localePath, baseUrl), + hreflang: iso + }) + } } - if (defaultLocale) { - link.push({ - hid: 'i18n-xd', - rel: 'alternate', - href: toAbsoluteUrl(this.switchLocalePath(defaultLocale), baseUrl), - hreflang: 'x-default' - }) + if (options.defaultLocale) { + const localePath = this.switchLocalePath(options.defaultLocale) + if (localePath) { + link.push({ + hid: 'i18n-xd', + rel: 'alternate', + href: toAbsoluteUrl(localePath, baseUrl), + hreflang: 'x-default' + }) + } } } + /** + * @this {import('vue/types/vue').Vue} + * + * @param {string} baseUrl + * @param {SeoMeta['link']} link + */ function addCanonicalLinks (baseUrl, link) { const currentRoute = this.localeRoute({ ...this.$route, @@ -117,6 +140,13 @@ export function nuxtI18nHead ({ addDirAttribute = true, addSeoAttributes = false } } + /** + * @this {import('vue/types/vue').Vue} + * + * @param {import('../../types').LocaleObject} currentLocale + * @param {string | undefined} currentLocaleIso + * @param {SeoMeta['meta']} meta + */ function addCurrentOgLocale (currentLocale, currentLocaleIso, meta) { const hasCurrentLocaleAndIso = currentLocale && currentLocaleIso @@ -128,33 +158,46 @@ export function nuxtI18nHead ({ addDirAttribute = true, addSeoAttributes = false hid: 'i18n-og', property: 'og:locale', // Replace dash with underscore as defined in spec: language_TERRITORY - content: underscoreIsoFromLocale(currentLocale) + content: hypenToUnderscore(currentLocaleIso) }) } + /** + * @this {import('vue/types/vue').Vue} + * + * @param {import('../../types').LocaleObject[]} locales + * @param {string | undefined} currentLocaleIso + * @param {SeoMeta['meta']} meta + */ function addAlternateOgLocales (locales, currentLocaleIso, meta) { const localesWithoutCurrent = locales.filter(locale => { - const localeIso = isoFromLocale(locale) + const localeIso = locale.iso return localeIso && localeIso !== currentLocaleIso }) - const alternateLocales = localesWithoutCurrent.map(locale => ({ - hid: `i18n-og-alt-${isoFromLocale(locale)}`, - property: 'og:locale:alternate', - content: underscoreIsoFromLocale(locale) - })) - - meta.push(...alternateLocales) - } + if (localesWithoutCurrent.length) { + const alternateLocales = localesWithoutCurrent.map(locale => ({ + hid: `i18n-og-alt-${locale.iso}`, + property: 'og:locale:alternate', + content: hypenToUnderscore(locale.iso) + })) - function isoFromLocale (locale) { - return locale[LOCALE_ISO_KEY] + meta.push(...alternateLocales) + } } - function underscoreIsoFromLocale (locale) { - return isoFromLocale(locale).replace(/-/g, '_') + /** + * @param {string | undefined} str + * @return {string} + */ + function hypenToUnderscore (str) { + return (str || '').replace(/-/g, '_') } + /** + * @param {string} urlOrPath + * @param {string} baseUrl + */ function toAbsoluteUrl (urlOrPath, baseUrl) { if (urlOrPath.match(/^https?:\/\//)) { return urlOrPath @@ -165,7 +208,10 @@ export function nuxtI18nHead ({ addDirAttribute = true, addSeoAttributes = false return metaObject } -/** @deprecated */ +/** + * @deprecated Use `nuxtI18nHead()` instead. + * @this {import('vue/types/vue').Vue} + */ export function nuxtI18nSeo () { return nuxtI18nHead.call(this, { addDirAttribute: false, addSeoAttributes: true }) } diff --git a/src/templates/middleware.js b/src/templates/middleware.js index 75597407f..e1e38e8b6 100644 --- a/src/templates/middleware.js +++ b/src/templates/middleware.js @@ -1,7 +1,8 @@ -import middleware from '../middleware' +// @ts-ignore +import nuxtMiddleware from '../middleware' /** @type {import('@nuxt/types').Middleware} */ -middleware.nuxti18n = async (context) => { +const i18nMiddleware = async (context) => { const { app, isHMR } = context if (isHMR) { @@ -14,3 +15,5 @@ middleware.nuxti18n = async (context) => { context.redirect(status, redirectPath, query) } } + +nuxtMiddleware.nuxti18n = i18nMiddleware diff --git a/src/templates/options.d.ts b/src/templates/options.d.ts new file mode 100644 index 000000000..1582dca6d --- /dev/null +++ b/src/templates/options.d.ts @@ -0,0 +1,19 @@ +import Vue from 'vue' +import { ComponentOptions } from 'vue/types/options' +import { STRATEGIES } from '../helpers/constants' +import { LocaleFileExport, ResolvedOptions } from '../../types/internal' + +interface ModuleConstants { + COMPONENT_OPTIONS_KEY: keyof Pick, 'nuxtI18n'> + STRATEGIES: typeof STRATEGIES +} + +interface ModuleNuxtOptions { + isUniversalMode: boolean + trailingSlash: boolean | undefined +} + +export const asyncLocales: Record Promise> +export const Constants: ModuleConstants +export const nuxtOptions: ModuleNuxtOptions +export const options: ResolvedOptions diff --git a/src/templates/options.js b/src/templates/options.js index 8ae89a38d..1c3eb3b1a 100644 --- a/src/templates/options.js +++ b/src/templates/options.js @@ -7,21 +7,34 @@ function stringifyValue(value) { } } -for (const [key, value] of Object.entries(options)) { - if (key === 'vueI18n' && typeof value === 'string') { -%>export const <%= key %> = (context) => import('<%= value %>').then(m => m.default(context)) +for (const [rootKey, rootValue] of Object.entries(options)) { + if (Array.isArray(rootValue)) { +%>export const <%= rootKey %> = <%= stringifyValue(rootValue) %> <% - } else { -%>export const <%= key %> = <%= stringifyValue(value) %> + } else { +%>export const <%= rootKey %> = { +<% + for (const [key, value] of Object.entries(rootValue)) { + if (key === 'vueI18n' && typeof value === 'string') { +%> <%= key %>: (context) => import('<%= value %>').then(m => m.default(context)), +<% + } else { +%> <%= key %>: <%= stringifyValue(value) %>, <% + } } +%>} +<% + } } -%> -<% if (options.lazy && options.langDir) { %> -export const ASYNC_LOCALES = { +const { lazy, locales, langDir } = options.options +if (lazy && langDir) { %> +export const asyncLocales = { <%= Array.from( - new Set(options.locales.map(l => `'${l.file}': () => import('../${relativeToBuild(options.langDir, l.file)}' /* webpackChunkName: "lang-${l.file}" */)`)) + new Set(locales.map(l => `'${l.file}': () => import('../${relativeToBuild(langDir, l.file)}' /* webpackChunkName: "lang-${l.file}" */)`)) ).join(',\n ') %> } -<% } %> +<% +} +%> diff --git a/src/templates/plugin.main.js b/src/templates/plugin.main.js index 6a2509e36..ad0dd8589 100644 --- a/src/templates/plugin.main.js +++ b/src/templates/plugin.main.js @@ -1,29 +1,7 @@ import Vue from 'vue' import VueI18n from 'vue-i18n' import { nuxtI18nHead, nuxtI18nSeo } from './head-meta' -import { - baseUrl, - beforeLanguageSwitch, - defaultLocale, - defaultLocaleRouteNameSuffix, - detectBrowserLanguage, - differentDomains, - IS_UNIVERSAL_MODE, - lazy, - LOCALE_CODE_KEY, - LOCALE_DOMAIN_KEY, - localeCodes, - locales, - MODULE_NAME, - onLanguageSwitched, - rootRedirect, - routesNameSeparator, - skipSettingLocaleOnNavigate, - STRATEGIES, - strategy, - vueI18n, - vuex -} from './options' +import { Constants, nuxtOptions, options } from './options' import { createLocaleFromRouteGetter, getLocaleCookie, @@ -37,29 +15,35 @@ import { syncVuex } from './utils-common' import { loadLanguageAsync } from './utils' +// @ts-ignore import { klona } from '~i18n-klona' Vue.use(VueI18n) +const detectBrowserLanguage = /** @type {Required} */(options.detectBrowserLanguage) const { alwaysRedirect, onlyOnNoPrefix, onlyOnRoot, fallbackLocale } = detectBrowserLanguage -const getLocaleFromRoute = createLocaleFromRouteGetter(localeCodes, { routesNameSeparator, defaultLocaleRouteNameSuffix }) +const getLocaleFromRoute = createLocaleFromRouteGetter(options.localeCodes, { + routesNameSeparator: options.routesNameSeparator, + defaultLocaleRouteNameSuffix: options.defaultLocaleRouteNameSuffix +}) /** @type {import('@nuxt/types').Plugin} */ export default async (context) => { const { app, route, store, req, res, redirect } = context - if (vuex && store) { - registerStore(store, vuex, localeCodes, MODULE_NAME) + if (options.vuex && store) { + registerStore(store, options.vuex, options.localeCodes) } - if (process.server && lazy) { + if (process.server && options.lazy) { context.beforeNuxtRender(({ nuxtState }) => { + /** @type {Record} */ const langs = {} const { fallbackLocale, locale } = app.i18n if (locale) { langs[locale] = app.i18n.getLocaleMessage(locale) } - if (fallbackLocale && locale !== fallbackLocale) { + if (typeof (fallbackLocale) === 'string' && locale !== fallbackLocale) { langs[fallbackLocale] = app.i18n.getLocaleMessage(fallbackLocale) } nuxtState.__i18n = { langs } @@ -68,7 +52,15 @@ export default async (context) => { const { useCookie, cookieKey, cookieDomain, cookieSecure, cookieCrossOrigin } = detectBrowserLanguage + /** + * @param {string | undefined} newLocale + * @param {{ initialSetup?: boolean }} [options=false] + */ const loadAndSetLocale = async (newLocale, { initialSetup = false } = {}) => { + if (!newLocale) { + return + } + // Abort if different domains option enabled if (!initialSetup && app.i18n.differentDomains) { return @@ -90,11 +82,12 @@ export default async (context) => { } // Lazy-loading enabled - if (lazy) { + if (options.lazy) { const i18nFallbackLocale = app.i18n.fallbackLocale // Load fallback locale(s). if (i18nFallbackLocale) { + /** @type {Promise[]} */ let localesToLoadPromises = [] if (Array.isArray(i18nFallbackLocale)) { localesToLoadPromises = i18nFallbackLocale.map(fbLocale => loadLanguageAsync(context, fbLocale)) @@ -115,9 +108,12 @@ export default async (context) => { } app.i18n.locale = newLocale - app.i18n.localeProperties = klona(locales.find(l => l[LOCALE_CODE_KEY] === newLocale) || { code: newLocale }) + // @ts-ignore + app.i18n.localeProperties = klona(options.locales.find(l => l.code === newLocale) || { code: newLocale }) - await syncVuex(store, newLocale, app.i18n.getLocaleMessage(newLocale), { vuex }) + if (options.vuex) { + await syncVuex(store, newLocale, app.i18n.getLocaleMessage(newLocale), options.vuex) + } // Must retrieve from context as it might have changed since plugin initialization. const { route } = context @@ -136,20 +132,27 @@ export default async (context) => { } } + /** + * Gets the redirect path for locale. + * + * @param {import("vue-router").Route} route + * @param {string | undefined} locale + * @return {string} The redirect path for locale. + */ const getRedirectPathForLocale = (route, locale) => { // Redirects are ignored if it is a nuxt generate. if (process.static && process.server) { return '' } - if (!locale || app.i18n.differentDomains || strategy === STRATEGIES.NO_PREFIX) { + if (!locale || app.i18n.differentDomains || options.strategy === Constants.STRATEGIES.NO_PREFIX) { return '' } if (getLocaleFromRoute(route) === locale) { // If "onlyOnRoot" or "onlyOnNoPrefix" is set and strategy is "prefix_and_default", prefer unprefixed route for // default locale. - if (!(onlyOnRoot || onlyOnNoPrefix) || locale !== defaultLocale || strategy !== STRATEGIES.PREFIX_AND_DEFAULT) { + if (!(onlyOnRoot || onlyOnNoPrefix) || locale !== options.defaultLocale || options.strategy !== Constants.STRATEGIES.PREFIX_AND_DEFAULT) { return '' } } @@ -169,16 +172,20 @@ export default async (context) => { return redirectPath } - // Called by middleware on navigation (also on the initial one). + /** + * Called by middleware on navigation (also on the initial one). + * + * @type {import('../../types/internal').onNavigateInternal} + */ const onNavigate = async route => { // Handle root path redirect - if (route.path === '/' && rootRedirect) { + if (route.path === '/' && options.rootRedirect) { let statusCode = 302 - let path = rootRedirect + let path = options.rootRedirect - if (typeof rootRedirect !== 'string') { - statusCode = rootRedirect.statusCode - path = rootRedirect.path + if (typeof options.rootRedirect !== 'string') { + statusCode = options.rootRedirect.statusCode + path = options.rootRedirect.path } return [statusCode, `/${path}`, /* preserve query */true] @@ -190,14 +197,17 @@ export default async (context) => { return [302, storedRedirect] } - const options = { differentDomains, locales, localeDomainKey: LOCALE_DOMAIN_KEY, localeCodeKey: LOCALE_CODE_KEY, moduleName: MODULE_NAME } - app.i18n.__baseUrl = resolveBaseUrl(baseUrl, context, app.i18n.locale, options) + const resolveBaseUrlOptions = { + differentDomains: options.differentDomains, + normalizedLocales: options.normalizedLocales + } + app.i18n.__baseUrl = resolveBaseUrl(options.baseUrl, context, app.i18n.locale, resolveBaseUrlOptions) const finalLocale = - (detectBrowserLanguage && doDetectBrowserLanguage(route)) || + (options.detectBrowserLanguage && doDetectBrowserLanguage(route)) || getLocaleFromRoute(route) || app.i18n.locale || app.i18n.defaultLocale || '' - if (skipSettingLocaleOnNavigate) { + if (options.skipSettingLocaleOnNavigate) { app.i18n.__pendingLocale = finalLocale app.i18n.__pendingLocalePromise = new Promise(resolve => { app.i18n.__resolvePendingLocalePromise = resolve @@ -214,7 +224,7 @@ export default async (context) => { return } await app.i18n.setLocale(app.i18n.__pendingLocale) - app.i18n.__resolvePendingLocalePromise() + app.i18n.__resolvePendingLocalePromise('') app.i18n.__pendingLocale = null } @@ -227,28 +237,32 @@ export default async (context) => { const getBrowserLocale = () => { if (process.client && typeof navigator !== 'undefined' && navigator.languages) { // Get browser language either from navigator if running on client side, or from the headers - return matchBrowserLocale(locales, navigator.languages) + return matchBrowserLocale(options.normalizedLocales, navigator.languages) } else if (req && typeof req.headers['accept-language'] !== 'undefined') { - return matchBrowserLocale(locales, parseAcceptLanguage(req.headers['accept-language'])) + return matchBrowserLocale(options.normalizedLocales, parseAcceptLanguage(req.headers['accept-language'])) } else { return undefined } } + /** + * @param {import('vue-router').Route} route + * @return {string} Returns true if the browser language was detected. + */ const doDetectBrowserLanguage = route => { // Browser detection is ignored if it is a nuxt generate. if (process.static && process.server) { - return false + return '' } - if (strategy !== STRATEGIES.NO_PREFIX) { + if (options.strategy !== Constants.STRATEGIES.NO_PREFIX) { if (onlyOnRoot) { if (route.path !== '/') { - return false + return '' } } else if (onlyOnNoPrefix) { - if (!alwaysRedirect && route.path.match(getLocalesRegex(localeCodes))) { - return false + if (!alwaysRedirect && route.path.match(getLocalesRegex(options.localeCodes))) { + return '' } } } @@ -271,18 +285,24 @@ export default async (context) => { } } - return false + return '' } + /** + * Extends the newly created vue-i18n instance with nuxt-i18n properties. + * + * @param {import('vue-i18n').IVueI18n} i18n + */ const extendVueI18nInstance = i18n => { - i18n.locales = klona(locales) - i18n.localeProperties = klona(locales.find(l => l[LOCALE_CODE_KEY] === i18n.locale) || { code: i18n.locale }) - i18n.defaultLocale = defaultLocale - i18n.differentDomains = differentDomains - i18n.beforeLanguageSwitch = beforeLanguageSwitch - i18n.onLanguageSwitched = onLanguageSwitched + i18n.locales = klona(options.locales) + i18n.localeCodes = klona(options.localeCodes) + i18n.localeProperties = klona(options.normalizedLocales.find(l => l.code === i18n.locale) || { code: i18n.locale }) + i18n.defaultLocale = options.defaultLocale + i18n.differentDomains = options.differentDomains + i18n.beforeLanguageSwitch = options.beforeLanguageSwitch + i18n.onLanguageSwitched = options.onLanguageSwitched i18n.setLocaleCookie = locale => setLocaleCookie(locale, res, { useCookie, cookieDomain, cookieKey, cookieSecure, cookieCrossOrigin }) - i18n.getLocaleCookie = () => getLocaleCookie(req, { useCookie, cookieKey, localeCodes }) + i18n.getLocaleCookie = () => getLocaleCookie(req, { useCookie, cookieKey, localeCodes: options.localeCodes }) i18n.setLocale = (locale) => loadAndSetLocale(locale) i18n.getBrowserLocale = () => getBrowserLocale() i18n.finalizePendingLocaleChange = finalizePendingLocaleChange @@ -294,16 +314,21 @@ export default async (context) => { } // Set instance options - const vueI18nOptions = typeof vueI18n === 'function' ? await vueI18n(context) : klona(vueI18n) + const vueI18nOptions = typeof options.vueI18n === 'function' ? await options.vueI18n(context) : klona(options.vueI18n) vueI18nOptions.componentInstanceCreatedListener = extendVueI18nInstance + // @ts-ignore app.i18n = new VueI18n(vueI18nOptions) // Initialize locale and fallbackLocale as vue-i18n defaults those to 'en-US' if falsey app.i18n.locale = '' + app.i18n.localeCodes = [] app.i18n.localeProperties = { code: '' } app.i18n.fallbackLocale = vueI18nOptions.fallbackLocale || '' extendVueI18nInstance(app.i18n) - const options = { differentDomains, locales, localeDomainKey: LOCALE_DOMAIN_KEY, localeCodeKey: LOCALE_CODE_KEY, moduleName: MODULE_NAME } - app.i18n.__baseUrl = resolveBaseUrl(baseUrl, context, '', options) + const resolveBaseUrlOptions = { + differentDomains: options.differentDomains, + normalizedLocales: options.normalizedLocales + } + app.i18n.__baseUrl = resolveBaseUrl(options.baseUrl, context, '', resolveBaseUrlOptions) app.i18n.__onNavigate = onNavigate Vue.prototype.$nuxtI18nSeo = nuxtI18nSeo @@ -314,22 +339,26 @@ export default async (context) => { store.$i18n = app.i18n if (store.state.localeDomains) { - app.i18n.locales.forEach(locale => { + for (const locale of app.i18n.locales) { + if (typeof (locale) === 'string') { + continue + } locale.domain = store.state.localeDomains[locale.code] - }) + } } } - let finalLocale = detectBrowserLanguage && doDetectBrowserLanguage(route) + /** @type {string | undefined} */ + let finalLocale = options.detectBrowserLanguage ? doDetectBrowserLanguage(route) : '' if (!finalLocale) { + const { vuex } = options if (vuex && vuex.syncLocale && store && store.state[vuex.moduleName].locale !== '') { finalLocale = store.state[vuex.moduleName].locale } else if (app.i18n.differentDomains) { - const options = { localeDomainKey: LOCALE_DOMAIN_KEY, localeCodeKey: LOCALE_CODE_KEY } - const domainLocale = getLocaleDomain(locales, req, options) + const domainLocale = getLocaleDomain(options.normalizedLocales, req) finalLocale = domainLocale - } else if (strategy !== STRATEGIES.NO_PREFIX) { + } else if (options.strategy !== Constants.STRATEGIES.NO_PREFIX) { const routeLocale = getLocaleFromRoute(route) finalLocale = routeLocale } else if (useCookie) { @@ -343,7 +372,7 @@ export default async (context) => { await loadAndSetLocale(finalLocale, { initialSetup: true }) - if (process.client && process.static && IS_UNIVERSAL_MODE) { + if (process.client && process.static && nuxtOptions.isUniversalMode) { const [_, redirectTo] = await onNavigate(context.route) if (redirectTo) { location.assign(redirectTo) diff --git a/src/templates/plugin.routing.js b/src/templates/plugin.routing.js index 23fd8f60f..222089b5d 100644 --- a/src/templates/plugin.routing.js +++ b/src/templates/plugin.routing.js @@ -1,29 +1,21 @@ import './middleware' import Vue from 'vue' -import { - defaultLocale, - defaultLocaleRouteNameSuffix, - LOCALE_CODE_KEY, - LOCALE_DOMAIN_KEY, - MODULE_NAME, - routesNameSeparator, - STRATEGIES, - strategy, - trailingSlash, - vuex -} from './options' +import { Constants, nuxtOptions, options } from './options' import { getDomainFromLocale } from './utils-common' +/** + * @this {import('../../types/internal').PluginProxy} + * @type {Vue['localePath']} + */ function localePath (route, locale) { - const localizedRoute = localeRoute.call(this, route, locale) - - if (!localizedRoute) { - return - } - - return localizedRoute.fullPath + const localizedRoute = this.localeRoute(route, locale) + return localizedRoute ? localizedRoute.fullPath : '' } +/** + * @this {import('../../types/internal').PluginProxy} + * @type {Vue['localeRoute']} + */ function localeRoute (route, locale) { // Abort if no route or no locale if (!route) { @@ -51,8 +43,8 @@ function localeRoute (route, locale) { let localizedRoute = Object.assign({}, route) - if (route.path && !route.name) { - const resolvedRoute = this.router.resolve(route).route + if (localizedRoute.path && !localizedRoute.name) { + const resolvedRoute = this.router.resolve(localizedRoute).route const resolvedRouteName = this.getRouteBaseName(resolvedRoute) if (resolvedRouteName) { localizedRoute = { @@ -62,22 +54,22 @@ function localeRoute (route, locale) { hash: resolvedRoute.hash } } else { - const isDefaultLocale = locale === defaultLocale + const isDefaultLocale = locale === options.defaultLocale // if route has a path defined but no name, resolve full route using the path const isPrefixed = // don't prefix default locale - !(isDefaultLocale && [STRATEGIES.PREFIX_EXCEPT_DEFAULT, STRATEGIES.PREFIX_AND_DEFAULT].includes(strategy)) && + !(isDefaultLocale && [Constants.STRATEGIES.PREFIX_EXCEPT_DEFAULT, Constants.STRATEGIES.PREFIX_AND_DEFAULT].includes(options.strategy)) && // no prefix for any language - !(strategy === STRATEGIES.NO_PREFIX) && + !(options.strategy === Constants.STRATEGIES.NO_PREFIX) && // no prefix for different domains !i18n.differentDomains if (isPrefixed) { - localizedRoute.path = `/${locale}${route.path}` + localizedRoute.path = `/${locale}${localizedRoute.path}` } - localizedRoute.path = localizedRoute.path.replace(/\/+$/, '') + (trailingSlash ? '/' : '') || '/' + localizedRoute.path = localizedRoute.path.replace(/\/+$/, '') + (nuxtOptions.trailingSlash ? '/' : '') || '/' } } else { - if (!route.name && !route.path) { + if (!localizedRoute.name && !localizedRoute.path) { localizedRoute.name = this.getRouteBaseName() } @@ -97,6 +89,10 @@ function localeRoute (route, locale) { return this.router.resolve(route).route } +/** + * @this {import('../../types/internal').PluginProxy} + * @type {Vue['switchLocalePath']} + */ function switchLocalePath (locale) { const name = this.getRouteBaseName() if (!name) { @@ -104,15 +100,10 @@ function switchLocalePath (locale) { } const { i18n, route, store } = this - - if (!route) { - return '' - } - const { params, ...routeCopy } = route let langSwitchParams = {} - if (vuex && vuex.syncRouteParams && store) { - langSwitchParams = store.getters[`${vuex.moduleName}/localeRouteParams`](locale) + if (options.vuex && options.vuex.syncRouteParams && store) { + langSwitchParams = store.getters[`${options.vuex.moduleName}/localeRouteParams`](locale) } const baseRoute = Object.assign({}, routeCopy, { name, @@ -126,14 +117,11 @@ function switchLocalePath (locale) { // Handle different domains if (i18n.differentDomains) { - const options = { + const getDomainOptions = { differentDomains: i18n.differentDomains, - locales: i18n.locales, - localeDomainKey: LOCALE_DOMAIN_KEY, - localeCodeKey: LOCALE_CODE_KEY, - moduleName: MODULE_NAME + normalizedLocales: options.normalizedLocales } - const domain = getDomainFromLocale(locale, this.req, options) + const domain = getDomainFromLocale(locale, this.req, getDomainOptions) if (domain) { path = domain + path } @@ -142,40 +130,60 @@ function switchLocalePath (locale) { return path } +/** + * @this {import('../../types/internal').PluginProxy} + * @type {Vue['getRouteBaseName']} + */ function getRouteBaseName (givenRoute) { const route = givenRoute !== undefined ? givenRoute : this.route if (!route || !route.name) { - return null + return } - return route.name.split(routesNameSeparator)[0] + return route.name.split(options.routesNameSeparator)[0] } +/** + * @param {string | undefined} routeName + * @param {string} locale + */ function getLocaleRouteName (routeName, locale) { - let name = routeName + (strategy === STRATEGIES.NO_PREFIX ? '' : routesNameSeparator + locale) + let name = routeName + (options.strategy === Constants.STRATEGIES.NO_PREFIX ? '' : options.routesNameSeparator + locale) - if (locale === defaultLocale && strategy === STRATEGIES.PREFIX_AND_DEFAULT) { - name += routesNameSeparator + defaultLocaleRouteNameSuffix + if (locale === options.defaultLocale && options.strategy === Constants.STRATEGIES.PREFIX_AND_DEFAULT) { + name += options.routesNameSeparator + options.defaultLocaleRouteNameSuffix } return name } +/** + * @template {(...args: any[]) => any} T + * @param {T} targetFunction + * @return {(this: Vue, ...args: Parameters) => ReturnType} + */ const VueInstanceProxy = function (targetFunction) { return function () { const proxy = { getRouteBaseName: this.getRouteBaseName, i18n: this.$i18n, localePath: this.localePath, + localeRoute: this.localeRoute, req: process.server ? this.$ssrContext.req : null, route: this.$route, router: this.$router, store: this.$store } - return targetFunction.apply(proxy, arguments) + return targetFunction.call(proxy, ...arguments) } } +/** + * @template {(...args: any[]) => any} T + * @param {import('@nuxt/types').Context} context + * @param {T} targetFunction + * @return {(...args: Parameters) => ReturnType} + */ const NuxtContextProxy = function (context, targetFunction) { return function () { const { app, req, route, store } = context @@ -184,16 +192,18 @@ const NuxtContextProxy = function (context, targetFunction) { getRouteBaseName: app.getRouteBaseName, i18n: app.i18n, localePath: app.localePath, + localeRoute: app.localeRoute, req: process.server ? req : null, route, router: app.router, store } - return targetFunction.apply(proxy, arguments) + return targetFunction.call(proxy, ...arguments) } } +/** @type {import('vue').PluginObject} */ const plugin = { install (Vue) { Vue.mixin({ @@ -207,6 +217,7 @@ const plugin = { } } +/** @type {import('@nuxt/types').Plugin} */ export default (context) => { Vue.use(plugin) const { app, store } = context diff --git a/src/templates/plugin.seo.js b/src/templates/plugin.seo.js index 9db015580..897c7f869 100644 --- a/src/templates/plugin.seo.js +++ b/src/templates/plugin.seo.js @@ -1,10 +1,13 @@ import Vue from 'vue' -import { nuxtI18nSeo } from './head-meta' +import { nuxtI18nHead } from './head-meta' +/** @type {Vue.PluginObject} */ const plugin = { install (Vue) { Vue.mixin({ - head: nuxtI18nSeo + head () { + return nuxtI18nHead.call(this, { addDirAttribute: false, addSeoAttributes: true }) + } }) } } diff --git a/src/templates/utils-common.js b/src/templates/utils-common.js index f33714058..b57504e46 100644 --- a/src/templates/utils-common.js +++ b/src/templates/utils-common.js @@ -2,12 +2,25 @@ import Cookie from 'cookie' import JsCookie from 'js-cookie' import isHTTPS from 'is-https' +/** @typedef {import('../../types/internal').ResolvedOptions} ResolvedOptions */ + +/** + * Formats a log message, prefixing module's name to it. + * + * @param {string} text + * @return {string} + */ +export function formatMessage (text) { + return `[nuxt-i18n] ${text}` +} + /** * Parses locales provided from browser through `accept-language` header. + * * @param {string} input * @return {string[]} An array of locale codes. Priority determined by order in array. */ -export const parseAcceptLanguage = input => { +export function parseAcceptLanguage (input) { // Example input: en-US,en;q=0.9,nb;q=0.8,no;q=0.7 // Contains tags separated by comma. // Each tag consists of locale code (2-3 letter language code) and optionally country code @@ -18,23 +31,27 @@ export const parseAcceptLanguage = input => { /** * Find locale code that best matches provided list of browser locales. - * @param {(string[]|Object[])} appLocales The user-configured locale codes that are to be matched. - * @param {string[]} browserLocales The locales to match against configured. - * @return {string|undefined} + * + * @param {ResolvedOptions['normalizedLocales']} appLocales The user-configured locales that are to be matched. + * @param {readonly string[]} browserLocales The locales to match against configured. + * @return {string | undefined} */ -export const matchBrowserLocale = (appLocales, browserLocales) => { +export function matchBrowserLocale (appLocales, browserLocales) { /** @type {{ code: string, score: number }[]} */ const matchedLocales = [] // Normalise appLocales input - appLocales = appLocales.map(appLocale => ({ - code: typeof appLocale === 'string' ? appLocale : appLocale.code, - iso: typeof appLocale === 'string' ? appLocale : (appLocale.iso || appLocale.code) - })) + /** @type {{ code: string, iso: string }[]} */ + const normalizedAppLocales = [] + for (const appLocale of appLocales) { + const { code } = appLocale + const iso = appLocale.iso || code + normalizedAppLocales.push({ code, iso }) + } // First pass: match exact locale. for (const [index, browserCode] of browserLocales.entries()) { - const matchedLocale = appLocales.find(appLocale => appLocale.iso.toLowerCase() === browserCode.toLowerCase()) + const matchedLocale = normalizedAppLocales.find(appLocale => appLocale.iso.toLowerCase() === browserCode.toLowerCase()) if (matchedLocale) { matchedLocales.push({ code: matchedLocale.code, score: 1 - index / browserLocales.length }) break @@ -44,7 +61,7 @@ export const matchBrowserLocale = (appLocales, browserLocales) => { // Second pass: match only locale code part of the browser locale (not including country). for (const [index, browserCode] of browserLocales.entries()) { const languageCode = browserCode.split('-')[0].toLowerCase() - const matchedLocale = appLocales.find(appLocale => appLocale.iso.split('-')[0].toLowerCase() === languageCode) + const matchedLocale = normalizedAppLocales.find(appLocale => appLocale.iso.split('-')[0].toLowerCase() === languageCode) if (matchedLocale) { // Deduct a thousandth for being non-exact match. matchedLocales.push({ code: matchedLocale.code, score: 0.999 - index / browserLocales.length }) @@ -69,20 +86,21 @@ export const matchBrowserLocale = (appLocales, browserLocales) => { /** * Resolves base URL value if provided as function. Otherwise just returns verbatim. - * @param {string | function} baseUrl + * + * @param {string | ((context: import('@nuxt/types').Context) => string)} baseUrl * @param {import('@nuxt/types').Context} context - * @param {import('../../types').NuxtVueI18n.Locale} localeCode - * @param {object} options + * @param {import('../../types').Locale} localeCode + * @param {Pick} options * @return {string} */ -export const resolveBaseUrl = (baseUrl, context, localeCode, { differentDomains, locales, localeDomainKey, localeCodeKey, moduleName }) => { +export function resolveBaseUrl (baseUrl, context, localeCode, { differentDomains, normalizedLocales }) { if (typeof baseUrl === 'function') { return baseUrl(context) } if (differentDomains && localeCode) { // Lookup the `differentDomain` origin associated with given locale. - const domain = getDomainFromLocale(localeCode, context.req, { locales, localeDomainKey, localeCodeKey, moduleName }) + const domain = getDomainFromLocale(localeCode, context.req, { normalizedLocales }) if (domain) { return domain } @@ -94,68 +112,73 @@ export const resolveBaseUrl = (baseUrl, context, localeCode, { differentDomains, /** * Gets the `differentDomain` domain from locale. * - * @param {string} localeCode The locale code - * @param {import('connect').IncomingMessage} [req] Request object - * @param {object} options + * @param {string} localeCode + * @param {import('http').IncomingMessage | undefined} req + * @param {Pick} options * @return {string | undefined} */ -export const getDomainFromLocale = (localeCode, req, { locales, localeDomainKey, localeCodeKey, moduleName }) => { +export function getDomainFromLocale (localeCode, req, { normalizedLocales }) { // Lookup the `differentDomain` origin associated with given locale. - const lang = locales.find(locale => locale[localeCodeKey] === localeCode) - if (lang && lang[localeDomainKey]) { + const lang = normalizedLocales.find(locale => locale.code === localeCode) + if (lang && lang.domain) { let protocol if (process.server) { protocol = (req && isHTTPS(req)) ? 'https' : 'http' } else { protocol = window.location.protocol.split(':')[0] } - return `${protocol}://${lang[localeDomainKey]}` + return `${protocol}://${lang.domain}` } // eslint-disable-next-line no-console - console.warn(`[${moduleName}] Could not find domain name for locale ${localeCode}`) + console.warn(formatMessage(`Could not find domain name for locale ${localeCode}`)) } /** * Get locale code that corresponds to current hostname - * @param {object} locales - * @param {object} [req] Request object - * @param {{ localeDomainKey: string, localeCodeKey: string }} options - * @return {string | null} Locade code found if any + * + * @param {ResolvedOptions['normalizedLocales']} locales + * @param {import('http').IncomingMessage | undefined} req + * @return {string} Locale code found if any */ -export const getLocaleDomain = (locales, req, { localeDomainKey, localeCodeKey }) => { - let host = null +export function getLocaleDomain (locales, req) { + /** @type {string | undefined} */ + let host if (process.client) { host = window.location.host } else if (req) { - host = req.headers['x-forwarded-host'] || req.headers.host + const detectedHost = req.headers['x-forwarded-host'] || req.headers.host + host = Array.isArray(detectedHost) ? detectedHost[0] : detectedHost } if (host) { - const matchingLocale = locales.find(l => l[localeDomainKey] === host) + const matchingLocale = locales.find(l => l.domain === host) if (matchingLocale) { - return matchingLocale[localeCodeKey] + return matchingLocale.code } } - return null + return '' } /** * Creates a RegExp for route paths - * @param {string[]} localeCodes + * + * @param {readonly string[]} localeCodes * @return {RegExp} */ -export const getLocalesRegex = localeCodes => new RegExp(`^/(${localeCodes.join('|')})(?:/|$)`, 'i') +export function getLocalesRegex (localeCodes) { + return new RegExp(`^/(${localeCodes.join('|')})(?:/|$)`, 'i') +} /** * Creates getter for getLocaleFromRoute - * @param {string[]} localeCodes - * @param {{ routesNameSeparator: string, defaultLocaleRouteNameSuffix: string }} options - * @return {(route) => string| null} + * + * @param {readonly string[]} localeCodes + * @param {Pick} options */ -export const createLocaleFromRouteGetter = (localeCodes, { routesNameSeparator, defaultLocaleRouteNameSuffix }) => { +export function createLocaleFromRouteGetter (localeCodes, { routesNameSeparator, defaultLocaleRouteNameSuffix }) { const localesPattern = `(${localeCodes.join('|')})` const defaultSuffixPattern = `(?:${routesNameSeparator}${defaultLocaleRouteNameSuffix})?` const regexpName = new RegExp(`${routesNameSeparator}${localesPattern}${defaultSuffixPattern}$`, 'i') @@ -164,10 +187,8 @@ export const createLocaleFromRouteGetter = (localeCodes, { routesNameSeparator, * Extract locale code from given route: * - If route has a name, try to extract locale from it * - Otherwise, fall back to using the routes'path - * @param {Object} route - * @param {string[]} localeCodes - * @param {{ routesNameSeparator: string, defaultLocaleRouteNameSuffix: string }} options - * @return {string | null} Locale code found if any + * @param {import('vue-router').Route} route + * @return {string} Locale code found if any */ const getLocaleFromRoute = route => { // Extract from route name @@ -184,18 +205,18 @@ export const createLocaleFromRouteGetter = (localeCodes, { routesNameSeparator, } } - return null + return '' } return getLocaleFromRoute } /** - * @param {object} [req] - * @param {{ useCookie: boolean, localeCodes: string[], cookieKey: string}} options - * @return {string | void} + * @param {import('http').IncomingMessage | undefined} req + * @param {{ useCookie: boolean, cookieKey: string, localeCodes: readonly string[] }} options + * @return {string | undefined} */ -export const getLocaleCookie = (req, { useCookie, cookieKey, localeCodes }) => { +export function getLocaleCookie (req, { useCookie, cookieKey, localeCodes }) { if (useCookie) { let localeCode @@ -206,7 +227,7 @@ export const getLocaleCookie = (req, { useCookie, cookieKey, localeCodes }) => { localeCode = cookies[cookieKey] } - if (localeCodes.includes(localeCode)) { + if (localeCode && localeCodes.includes(localeCode)) { return localeCode } } @@ -214,14 +235,15 @@ export const getLocaleCookie = (req, { useCookie, cookieKey, localeCodes }) => { /** * @param {string} locale - * @param {object} [res] - * @param {{ useCookie: boolean, cookieDomain: string, cookieKey: string, cookieSecure: boolean, cookieCrossOrigin: boolean}} options + * @param {import('http').ServerResponse | undefined} res + * @param {{ useCookie: boolean, cookieDomain: string | null, cookieKey: string, cookieSecure: boolean, cookieCrossOrigin: boolean}} options */ -export const setLocaleCookie = (locale, res, { useCookie, cookieDomain, cookieKey, cookieSecure, cookieCrossOrigin }) => { +export function setLocaleCookie (locale, res, { useCookie, cookieDomain, cookieKey, cookieSecure, cookieCrossOrigin }) { if (!useCookie) { return } const date = new Date() + /** @type {import('cookie').CookieSerializeOptions} */ const cookieOptions = { expires: new Date(date.setDate(date.getDate() + 365)), path: '/', @@ -234,11 +256,12 @@ export const setLocaleCookie = (locale, res, { useCookie, cookieDomain, cookieKe } if (process.client) { + // @ts-ignore JsCookie.set(cookieKey, locale, cookieOptions) } else if (res) { let headers = res.getHeader('Set-Cookie') || [] - if (typeof headers === 'string') { - headers = [headers] + if (!Array.isArray(headers)) { + headers = [String(headers)] } const redirectCookie = Cookie.serialize(cookieKey, locale, cookieOptions) @@ -248,8 +271,21 @@ export const setLocaleCookie = (locale, res, { useCookie, cookieDomain, cookieKe } } -export const registerStore = (store, vuex, localeCodes, moduleName) => { - store.registerModule(vuex.moduleName, { +/** + * @param {import('vuex').Store>} store + * @param {Required} vuex + * @param {readonly string[]} localeCodes + */ +export function registerStore (store, vuex, localeCodes) { + /** @typedef {{ + * locale?: string + * messages?: Record + * routeParams?: Record> + * }} ModuleStore + * + * @type {import('vuex').Module} + */ + const storeModule = { namespaced: true, state: () => ({ ...(vuex.syncLocale ? { locale: '' } : {}), @@ -275,7 +311,7 @@ export const registerStore = (store, vuex, localeCodes, moduleName) => { ? { setRouteParams ({ commit }, params) { if (process.env.NODE_ENV === 'development') { - validateRouteParams(params, localeCodes, moduleName) + validateRouteParams(params, localeCodes) } commit('setRouteParams', params) } @@ -308,22 +344,28 @@ export const registerStore = (store, vuex, localeCodes, moduleName) => { getters: { ...(vuex.syncRouteParams ? { - localeRouteParams: ({ routeParams }) => locale => routeParams[locale] || {} + localeRouteParams: ({ routeParams }) => { + /** @type {(locale: string) => Record} */ + const paramsGetter = locale => (routeParams && routeParams[locale]) || {} + return paramsGetter + } } : {}) } - }, { preserveState: !!store.state[vuex.moduleName] }) + } + store.registerModule(vuex.moduleName, storeModule, { preserveState: !!store.state[vuex.moduleName] }) } /** * Dispatch store module actions to keep it in sync with app's locale data - * @param {Store} store Vuex store - * @param {String} locale Current locale - * @param {Object} messages Current messages - * @param {{ vuex: object }} options - * @return {Promise(void)} + * + * @param {import('vuex').Store} store + * @param {string | null} locale The current locale + * @param {object | null} messages Current messages + * @param {ResolvedOptions['vuex']} vuex + * @return {Promise} */ -export const syncVuex = async (store, locale = null, messages = null, { vuex }) => { +export async function syncVuex (store, locale = null, messages = null, vuex) { if (vuex && store) { if (locale !== null && vuex.syncLocale) { await store.dispatch(vuex.moduleName + '/setLocale', locale) @@ -334,28 +376,32 @@ export const syncVuex = async (store, locale = null, messages = null, { vuex }) } } +/** + * @param {any} value + * @return {boolean} + */ const isObject = value => value && !Array.isArray(value) && typeof value === 'object' /** * Validate setRouteParams action's payload + * * @param {object} routeParams The action's payload - * @param {string[]} localeCodes - * @param {string} moduleName + * @param {readonly string[]} localeCodes */ -export const validateRouteParams = (routeParams, localeCodes, moduleName) => { +export function validateRouteParams (routeParams, localeCodes) { if (!isObject(routeParams)) { // eslint-disable-next-line no-console - console.warn(`[${moduleName}] Route params should be an object`) + console.warn(formatMessage('Route params should be an object')) return } for (const [key, value] of Object.entries(routeParams)) { if (!localeCodes.includes(key)) { // eslint-disable-next-line no-console - console.warn(`[${moduleName}] Trying to set route params for key ${key} which is not a valid locale`) + console.warn(formatMessage(`Trying to set route params for key ${key} which is not a valid locale`)) } else if (!isObject(value)) { // eslint-disable-next-line no-console - console.warn(`[${moduleName}] Trying to set route params for locale ${key} with a non-object value`) + console.warn(formatMessage(`Trying to set route params for locale ${key} with a non-object value`)) } } } diff --git a/src/templates/utils.js b/src/templates/utils.js index 34471292d..055bc54af 100755 --- a/src/templates/utils.js +++ b/src/templates/utils.js @@ -1,28 +1,28 @@ -import { - LOCALE_CODE_KEY, - LOCALE_FILE_KEY, - MODULE_NAME/* <% if (options.lazy && options.langDir) { %> */, - ASYNC_LOCALES/* <% } %> */ -} from './options' +import { asyncLocales } from './options' +import { formatMessage } from './utils-common' /** * Asynchronously load messages from translation files - * @param {Context} context Nuxt context - * @param {String} locale Language code to load + * + * @param {import('@nuxt/types').Context} context + * @param {string} locale Language code to load + * @return {Promise} */ export async function loadLanguageAsync (context, locale) { const { app } = context + const { i18n } = app - if (!app.i18n.loadedLanguages) { - app.i18n.loadedLanguages = [] + if (!i18n.loadedLanguages) { + i18n.loadedLanguages = [] } - if (!app.i18n.loadedLanguages.includes(locale)) { - const localeObject = app.i18n.locales.find(l => l[LOCALE_CODE_KEY] === locale) + if (!i18n.loadedLanguages.includes(locale)) { + const localeObject = /** @type {import('../../types').LocaleObject[]} */(i18n.locales).find(l => l.code === locale) if (localeObject) { - const file = localeObject[LOCALE_FILE_KEY] + const { file } = localeObject if (file) { - /* <% if (options.lazy && options.langDir) { %> */ + /* <% if (options.options.lazy && options.options.langDir) { %> */ + /** @type {import('vue-i18n').LocaleMessageObject | undefined} */ let messages if (process.client) { const { nuxtState } = context @@ -30,27 +30,28 @@ export async function loadLanguageAsync (context, locale) { messages = nuxtState.__i18n.langs[locale] // Even if already cached in Nuxt state, trigger locale import so that HMR kicks-in on changes to that file. if (context.isDev) { - ASYNC_LOCALES[file]() + asyncLocales[file]() } } } if (!messages) { try { - const getter = await ASYNC_LOCALES[file]().then(m => m.default || m) + // @ts-ignore + const getter = await asyncLocales[file]().then(m => m.default || m) messages = typeof getter === 'function' ? await Promise.resolve(getter(context, locale)) : getter } catch (error) { // eslint-disable-next-line no-console - console.error(error) + console.error(formatMessage(`Failed loading async locale export: ${error.message}`)) } } if (messages) { - app.i18n.setLocaleMessage(locale, messages) - app.i18n.loadedLanguages.push(locale) + i18n.setLocaleMessage(locale, messages) + i18n.loadedLanguages.push(locale) } /* <% } %> */ } else { // eslint-disable-next-line no-console - console.warn(`[${MODULE_NAME}] Could not find lang file for locale ${locale}`) + console.warn(formatMessage(`Could not find lang file for locale ${locale}`)) } } } diff --git a/test/fixture/basic/components/LangSwitcher.vue b/test/fixture/basic/components/LangSwitcher.vue index 9a17e78aa..bda9ffb45 100644 --- a/test/fixture/basic/components/LangSwitcher.vue +++ b/test/fixture/basic/components/LangSwitcher.vue @@ -24,9 +24,9 @@ export default { name: 'LangSwitcher', computed: { - /** @return {import('../../../../types').NuxtVueI18n.Options.LocaleObject[]} */ localesExcludingCurrent () { - return this.$i18n.locales.filter(locale => locale.code !== this.$i18n.locale) + const locales = /** @type {import('../../../../types').LocaleObject[]} */(this.$i18n.locales) + return locales.filter(locale => locale.code !== this.$i18n.locale) } } } diff --git a/test/fixture/basic/middleware/i18n-middleware.js b/test/fixture/basic/middleware/i18n-middleware.js index e36207f73..5ab8b0c5f 100644 --- a/test/fixture/basic/middleware/i18n-middleware.js +++ b/test/fixture/basic/middleware/i18n-middleware.js @@ -4,10 +4,8 @@ const middleware = ({ app }) => { return } - const localeCodes = app.i18n.locales.map(locale => typeof (locale) === 'string' ? locale : locale.code) - // Tests localePath, switchLocalePath and getRouteBaseName from app context. - app.allLocalePaths = localeCodes.map(locale => app.switchLocalePath(locale)) + app.allLocalePaths = app.i18n.localeCodes.map(locale => app.switchLocalePath(locale)) app.routeBaseName = app.getRouteBaseName() app.localizedRoute = app.localeRoute(app.routeBaseName, 'fr') } diff --git a/test/fixture/typescript/nuxt.config.ts b/test/fixture/typescript/nuxt.config.ts index 62d558897..02e762ad5 100644 --- a/test/fixture/typescript/nuxt.config.ts +++ b/test/fixture/typescript/nuxt.config.ts @@ -1,18 +1,16 @@ -import { Configuration as NuxtConfiguration } from '@nuxt/types' -import { NuxtVueI18n } from '../../../types/vue'; +import { NuxtConfig } from '@nuxt/types' -const nuxtI18nOptions: NuxtVueI18n.Options.AllOptionsInterface { - locales: [ - { code: 'en', iso: 'en-US', name: 'English' }, - { code: 'pl', iso: 'pl-PL', name: 'Polish' } - ], - defaultLocale: 'en', - parsePages: true -} - -const config: NuxtConfiguration = { +const config: NuxtConfig = { buildModules: ['@nuxt/typescript-build'], - modules: ['nuxt-i18n', nuxtI18nOptions], + modules: ['nuxt-i18n'], + i18n: { + locales: [ + { code: 'en', iso: 'en-US', name: 'English' }, + { code: 'pl', iso: 'pl-PL', name: 'Polish' } + ], + defaultLocale: 'en', + parsePages: true + } }; export default config diff --git a/test/unit.test.js b/test/unit.test.js index ef51b45c4..5a930a137 100644 --- a/test/unit.test.js +++ b/test/unit.test.js @@ -6,8 +6,10 @@ describe('parsePages', () => { const { extractComponentOptions } = await import('../src/helpers/components') const options = extractComponentOptions(path.join(__dirname, './fixture/typescript/pages/index.vue')) expect(options).toHaveProperty('paths') - expect(options.paths).toHaveProperty('pl') - expect(options.paths.pl).toBe('/polish') + if (options) { + expect(options.paths).toHaveProperty('pl') + expect(options.paths.pl).toBe('/polish') + } }) test('triggers warning with invalid in-component options', async () => { @@ -18,7 +20,8 @@ describe('parsePages', () => { expect(spy.mock.calls[0][0]).toContain('Error parsing') spy.mockRestore() - expect(Object.keys(options).length).toBe(0) + expect(options).toHaveProperty('paths') + expect(options).toHaveProperty('locales') }) }) @@ -31,7 +34,7 @@ describe('parseAcceptLanguage', () => { describe('matchBrowserLocale', () => { test('matches highest-ranked full locale', () => { // Both locales match first browser locale - full locale should win. - const appLocales = ['en', 'en-US'] + const appLocales = [{ code: 'en' }, { code: 'en-US' }] const browserLocales = ['en-US', 'en'] expect(matchBrowserLocale(appLocales, browserLocales)).toBe('en-US') @@ -40,56 +43,56 @@ describe('matchBrowserLocale', () => { test('matches highest-ranked short locale', () => { // Both locales match first browser locale - short locale should win. // This is because browser locale order defines scoring so we prefer higher-scored over exact. - const appLocales = ['en', 'en-US'] + const appLocales = [{ code: 'en' }, { code: 'en-US' }] const browserLocales = ['en', 'en-US'] expect(matchBrowserLocale(appLocales, browserLocales)).toBe('en') }) test('matches highest-ranked short locale (only short defined)', () => { - const appLocales = ['en'] + const appLocales = [{ code: 'en' }] const browserLocales = ['en-US', 'en'] expect(matchBrowserLocale(appLocales, browserLocales)).toBe('en') }) test('matches highest-ranked short locale', () => { - const appLocales = ['en', 'fr'] + const appLocales = [{ code: 'en' }, { code: 'fr' }] const browserLocales = ['en-US', 'en-GB'] expect(matchBrowserLocale(appLocales, browserLocales)).toBe('en') }) test('does not match any locale', () => { - const appLocales = ['pl', 'fr'] + const appLocales = [{ code: 'pl' }, { code: 'fr' }] const browserLocales = ['en-US', 'en'] expect(matchBrowserLocale(appLocales, browserLocales)).toBe(undefined) }) test('matches full locale with mixed short and full, full having highest rank', () => { - const appLocales = ['en-US', 'en-GB', 'en'] + const appLocales = [{ code: 'en-US' }, { code: 'en-GB' }, { code: 'en' }] const browserLocales = ['en-GB', 'en-US', 'en'] expect(matchBrowserLocale(appLocales, browserLocales)).toBe('en-GB') }) test('matches short locale with mixed short and full, short having highest rank', () => { - const appLocales = ['en-US', 'en-GB', 'en'] + const appLocales = [{ code: 'en-US' }, { code: 'en-GB' }, { code: 'en' }] const browserLocales = ['en', 'en-GB', 'en-US'] expect(matchBrowserLocale(appLocales, browserLocales)).toBe('en') }) test('matches short locale case-insensitively', () => { - const appLocales = ['EN', 'en-GB'] + const appLocales = [{ code: 'EN' }, { code: 'en-GB' }] const browserLocales = ['en', 'en-GB', 'en-US'] expect(matchBrowserLocale(appLocales, browserLocales)).toBe('EN') }) test('matches long locale case-insensitively', () => { - const appLocales = ['en-gb', 'en-US'] + const appLocales = [{ code: 'en-gb' }, { code: 'en-US' }] const browserLocales = ['en-GB', 'en-US'] expect(matchBrowserLocale(appLocales, browserLocales)).toBe('en-gb') @@ -110,7 +113,7 @@ describe('matchBrowserLocale', () => { }) test('matches locale when only languages match', () => { - const appLocales = ['en-GB', 'en-US'] + const appLocales = [{ code: 'en-GB' }, { code: 'en-US' }] const browserLocales = ['en-IN', 'en'] expect(matchBrowserLocale(appLocales, browserLocales)).toBe('en-GB') diff --git a/tsconfig.json b/tsconfig.json index e662dd314..4a4f21713 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,6 +10,7 @@ ], "esModuleInterop": true, "allowJs": true, + "checkJs": true, "sourceMap": true, "strict": true, "noEmit": true, @@ -22,9 +23,16 @@ "./*", ], }, + "resolveJsonModule": true, "types": [ "@types/node", "@nuxt/types", ], }, + "include": [ + "src", + ], + "exclude": [ + "src/templates/options.js", + ] } diff --git a/types/index.d.ts b/types/index.d.ts index 5e6ceafc4..d90acd3ad 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -1,4 +1,73 @@ -// augment typings of Vue.js import './vue' +import { Locale, I18nOptions } from 'vue-i18n' +import { Context as NuxtContext } from '@nuxt/types' export { NuxtVueI18n } from './nuxt-i18n' + +export { Locale } +export type Strategies = 'no_prefix' | 'prefix_except_default' | 'prefix' | 'prefix_and_default' +export type Directions = 'ltr' | 'rtl' | 'auto' + +export interface LocaleObject extends Record { + code: Locale + dir?: Directions + file?: string + isCatchallLocale?: boolean + iso?: string +} + +export interface DetectBrowserLanguageOptions { + alwaysRedirect?: boolean + cookieCrossOrigin?: boolean + cookieDomain?: string | null + cookieKey?: string + cookieSecure?: boolean + fallbackLocale?: Locale | null + onlyOnNoPrefix?: boolean + onlyOnRoot?: boolean + useCookie?: boolean +} + +export interface RootRedirectOptions { + path: string + statusCode: number +} + +export interface VuexOptions { + moduleName?: string + syncLocale?: boolean + syncMessages?: boolean + syncRouteParams?: boolean +} + +// Options that are also exposed on the VueI18n instance. +export interface BaseOptions { + beforeLanguageSwitch?: (oldLocale: string, newLocale: string) => void + defaultDirection?: Directions + defaultLocale?: Locale + defaultLocaleRouteNameSuffix?: string + differentDomains?: boolean + locales?: Locale[] | LocaleObject[] + onLanguageSwitched?: (oldLocale: string, newLocale: string) => void +} + +export interface Options extends BaseOptions { + baseUrl?: string | ((context: NuxtContext) => string) + detectBrowserLanguage?: DetectBrowserLanguageOptions | false + langDir?: string | null + lazy?: boolean + pages?: { + [key: string]: false | { + [key: string]: false | string + } + } + parsePages?: boolean + rootRedirect?: string | null | RootRedirectOptions + routesNameSeparator?: string + seo?: boolean + skipSettingLocaleOnNavigate?: boolean, + strategy?: Strategies + vueI18n?: I18nOptions | string + vueI18nLoader?: boolean + vuex?: VuexOptions | false +} diff --git a/types/internal.d.ts b/types/internal.d.ts new file mode 100644 index 000000000..6a158b355 --- /dev/null +++ b/types/internal.d.ts @@ -0,0 +1,42 @@ +import { IncomingMessage } from 'http' +import { Context as NuxtContext } from '@nuxt/types' +import { Route } from 'vue-router' +import { LocaleMessageObject, I18nOptions, Locale } from 'vue-i18n' +import Vue from 'vue' +import { DetectBrowserLanguageOptions, VuexOptions, Options, LocaleObject } from '.' + +export type LocaleFileExport = ((context: NuxtContext) => LocaleMessageObject) | ({ default: (context: NuxtContext) => LocaleMessageObject }) | LocaleMessageObject +export type onNavigateInternal = (route: Route) => Promise<[number | null, string | null] | [number | null, string | null, boolean | undefined]> + +export interface ResolvedOptions extends Omit, 'detectBrowserLanguage' | 'vueI18n' | 'vuex'> { + detectBrowserLanguage: Required | false + localeCodes: readonly Locale[] + normalizedLocales: readonly LocaleObject[] + vueI18n: I18nOptions | ((context: NuxtContext) => Promise) + vuex: Required | false +} + +export interface PluginProxy { + getRouteBaseName: Vue['getRouteBaseName'] + i18n: Vue['$i18n'], + localePath: Vue['localePath'], + localeRoute: Vue['localeRoute'], + req?: IncomingMessage, + route: Vue['$route'], + router: Vue['$router'], + store: Vue['$store'] +} + +declare module 'vue-i18n' { + // the VueI18n class expands here: https://goo.gl/Xtp9EG + // it is necessary for the $i18n property in Vue interface: "readonly $i18n: VueI18n & IVueI18n" + interface IVueI18n { + // Internal. + __baseUrl: string + __onNavigate: onNavigateInternal + __pendingLocale: string | null | undefined + __pendingLocalePromise: Promise | undefined + __redirect: string | null + __resolvePendingLocalePromise: (value: string) => void | undefined + } +} diff --git a/types/nuxt-i18n.d.ts b/types/nuxt-i18n.d.ts index bd4248f82..9394a9d5a 100644 --- a/types/nuxt-i18n.d.ts +++ b/types/nuxt-i18n.d.ts @@ -1,10 +1,7 @@ import VueI18n from 'vue-i18n' -import { MetaInfo } from 'vue-meta' import { Context as NuxtContext } from '@nuxt/types' -/** - * The nuxt-i18n types namespace - */ +/** @deprecated Use individually exported types instead of this namespace. */ declare namespace NuxtVueI18n { type Locale = VueI18n.Locale type Strategies = 'no_prefix' | 'prefix_except_default' | 'prefix' | 'prefix_and_default' @@ -30,14 +27,15 @@ declare namespace NuxtVueI18n { } interface DetectBrowserLanguageInterface { - useCookie?: boolean - crossOriginCookie?: boolean + alwaysRedirect?: boolean + cookieCrossOrigin?: boolean cookieDomain?: string | null cookieKey?: string - alwaysRedirect?: boolean + cookieSecure?: boolean fallbackLocale?: Locale | null onlyOnNoPrefix?: boolean onlyOnRoot?: boolean + useCookie?: boolean } interface RootRedirectInterface { @@ -60,7 +58,6 @@ declare namespace NuxtVueI18n { defaultLocaleRouteNameSuffix?: string locales?: Array differentDomains?: boolean - forwardedHost?: boolean onLanguageSwitched?: (oldLocale: string, newLocale: string) => void } @@ -88,30 +85,3 @@ declare namespace NuxtVueI18n { } } } - -export interface NuxtI18nSeo { - htmlAttrs?: MetaInfo['htmlAttrs'] - link?: MetaInfo['link'] - meta?: MetaInfo['meta'] -} - -export interface NuxtI18nHeadOptions { - /** - * Adds a `dir` attribute to the HTML element. - * Default: `true` - */ - addDirAttribute: boolean - /** - * Adds various SEO attributes. - * Default: `false` - */ - addSeoAttributes: boolean -} - -export interface NuxtI18nComponentOptions { - paths?: { - [key: string]: string | false - } - locales?: Array - seo?: false -} diff --git a/types/test/index.ts b/types/test/index.ts index 52addf707..3ddc565c4 100644 --- a/types/test/index.ts +++ b/types/test/index.ts @@ -2,13 +2,13 @@ /* eslint-disable no-unused-vars */ import Vue from 'vue' import Vuex from 'vuex' -import { Location } from 'vue-router' +import { Route } from 'vue-router' import '../index' const vm = new Vue() const locale = 'en' -let path: string +let path: string | undefined // localePath @@ -23,13 +23,13 @@ path = vm.switchLocalePath(locale) // getRouteBaseName -const routeBaseName: string = vm.getRouteBaseName(vm.$route) +const routeBaseName: string | undefined = vm.getRouteBaseName(vm.$route) // localeRoute -const localizedRoute: Location | undefined = vm.localeRoute('about', 'fr') +const localizedRoute: Route | undefined = vm.localeRoute('about', 'fr') if (localizedRoute) { - vm.$router.push(localizedRoute) + vm.$router.push({ path: localizedRoute.path }) } // $i18n diff --git a/types/vue.d.ts b/types/vue.d.ts index 295cae09c..ef20497d9 100644 --- a/types/vue.d.ts +++ b/types/vue.d.ts @@ -1,40 +1,67 @@ import 'vue' import 'vuex' -import { Location, RawLocation, Route } from 'vue-router' +import '@nuxt/types' +import { RawLocation, Route } from 'vue-router' import VueI18n, { IVueI18n } from 'vue-i18n' import { MetaInfo } from 'vue-meta' -import { NuxtI18nComponentOptions, NuxtVueI18n, NuxtI18nSeo, NuxtI18nHeadOptions } from './nuxt-i18n' +import { BaseOptions, LocaleObject, Options } from '.' + +interface NuxtI18nComponentOptions { + paths?: { + [key: string]: string | false + } + locales?: Array + seo?: false +} + +interface NuxtI18nHeadOptions { + /** + * Adds a `dir` attribute to the HTML element. + * Default: `true` + */ + addDirAttribute: boolean + /** + * Adds various SEO attributes. + * Default: `false` + */ + addSeoAttributes: boolean +} + +interface NuxtI18nSeo { + htmlAttrs?: MetaInfo['htmlAttrs'] + link?: MetaInfo['link'] + meta?: MetaInfo['meta'] +} -/** - * Extends types in vue-i18n - */ declare module 'vue-i18n' { // the VueI18n class expands here: https://goo.gl/Xtp9EG // it is necessary for the $i18n property in Vue interface: "readonly $i18n: VueI18n & IVueI18n" - interface IVueI18n extends NuxtVueI18n.Options.NuxtI18nInterface { - localeProperties: NuxtVueI18n.Options.LocaleObject - getLocaleCookie(): string | undefined - setLocaleCookie(locale: string): undefined - setLocale(locale: string): Promise - getBrowserLocale(): string | undefined + interface IVueI18n extends Required { finalizePendingLocaleChange(): Promise + getBrowserLocale(): string | undefined + getLocaleCookie(): string | undefined + loadedLanguages: string[] | undefined + localeCodes: readonly Locale[] + localeProperties: LocaleObject + setLocale(locale: string): Promise + setLocaleCookie(locale: string): void waitForPendingLocaleChange(): Promise } } -/** - * Extends types in vue - */ -declare module 'vue/types/vue' { - interface Vue { - readonly $i18n: VueI18n & IVueI18n - /** @deprecated */ - $nuxtI18nSeo(): NuxtI18nSeo - $nuxtI18nHead(options?: NuxtI18nHeadOptions): MetaInfo - getRouteBaseName(route?: Route): string +interface NuxtI18nApi { + getRouteBaseName(route?: Route): string | undefined localePath(route: RawLocation, locale?: string): string - localeRoute(route: RawLocation, locale?: string): Location | undefined + localeRoute(route: RawLocation, locale?: string): Route | undefined switchLocalePath(locale: string): string +} + +declare module 'vue/types/vue' { + interface Vue extends NuxtI18nApi { + // $i18n is already added by vue-i18n. + /** @deprecated Use `nuxtI18nHead()` instead. */ + $nuxtI18nHead(options?: NuxtI18nHeadOptions): MetaInfo + $nuxtI18nSeo(): NuxtI18nSeo } } @@ -44,29 +71,18 @@ declare module 'vue/types/options' { } } -/** - * Extends types in Nuxt - */ declare module '@nuxt/types' { - interface NuxtAppOptions extends NuxtVueI18n.Options.NuxtI18nInterface { - readonly i18n: VueI18n & IVueI18n - getRouteBaseName(route?: Route): string - localePath(route: RawLocation, locale?: string): string - localeRoute(route: RawLocation, locale?: string): Location | undefined - switchLocalePath(locale: string): string + interface NuxtAppOptions extends NuxtI18nApi { + i18n: VueI18n & IVueI18n } - interface NuxtOptions { - i18n?: NuxtVueI18n.Options.AllOptionsInterface + interface NuxtConfig { + i18n?: Options } } declare module 'vuex/types/index' { - interface Store { - readonly $i18n: VueI18n & IVueI18n - getRouteBaseName(route?: Route): string - localePath(route: RawLocation, locale?: string): string - localeRoute(route: RawLocation, locale?: string): Location | undefined - switchLocalePath(locale: string): string + interface Store extends NuxtI18nApi { + $i18n: VueI18n & IVueI18n } } diff --git a/yarn.lock b/yarn.lock index 5fc268f76..81072550a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1511,10 +1511,10 @@ rc9 "^1.2.0" std-env "^2.2.1" -"@nuxt/types@2.15.2": - version "2.15.2" - resolved "https://registry.yarnpkg.com/@nuxt/types/-/types-2.15.2.tgz#040b2cf96b0fad86baccead95721b4e28444613f" - integrity sha512-X7fZDhuSMhkhVt1XCODby1Pmog9Tf5LNzKGkFmQ/PxlK4lbJXivsosXWgOzmDkDW4TXxv6+LvXEbN1h8VQmzOQ== +"@nuxt/types@2.15.3": + version "2.15.3" + resolved "https://registry.yarnpkg.com/@nuxt/types/-/types-2.15.3.tgz#2c93829554ff261f488f41d7332aa1cc2e343ada" + integrity sha512-dOO5uSDOjeMxDtowRd3b1ZMMeoUQjVHsf/3rMjAgIDojESbIkLE+yN0zSPBzDomjh8KF82ZNYDeDrN34cadW5g== dependencies: "@types/autoprefixer" "^9.7.2" "@types/babel__core" "^7.1.12" @@ -1524,7 +1524,7 @@ "@types/file-loader" "^4.2.0" "@types/html-minifier" "^4.0.0" "@types/less" "^3.0.2" - "@types/node" "^12.20.4" + "@types/node" "^12.20.5" "@types/optimize-css-assets-webpack-plugin" "^5.0.2" "@types/pug" "^2.0.4" "@types/sass" "^1.16.0" @@ -1954,6 +1954,11 @@ dependencies: "@types/node" "*" +"@types/cookie@^0.4.0": + version "0.4.0" + resolved "https://registry.yarnpkg.com/@types/cookie/-/cookie-0.4.0.tgz#14f854c0f93d326e39da6e3b6f34f7d37513d108" + integrity sha512-y7mImlc/rNkvCRmg8gC3/lj87S7pTUIJ6QGjwHR9WQJcFs+ZMTOaoPrkdFA/YdbuqVEmEbb5RdhVxMkAcgOnpg== + "@types/etag@^1.8.0": version "1.8.0" resolved "https://registry.yarnpkg.com/@types/etag/-/etag-1.8.0.tgz#37f0b1f3ea46da7ae319bbedb607e375b4c99f7e" @@ -2055,6 +2060,11 @@ jest-diff "^26.0.0" pretty-format "^26.0.0" +"@types/js-cookie@^2.2.6": + version "2.2.6" + resolved "https://registry.yarnpkg.com/@types/js-cookie/-/js-cookie-2.2.6.tgz#f1a1cb35aff47bc5cfb05cb0c441ca91e914c26f" + integrity sha512-+oY0FDTO2GYKEV0YPvSshGq9t7YozVkgvXLty7zogQNuCxBhT9/3INX9Q7H1aRZ4SUDRXAKlJuA4EA5nTt7SNw== + "@types/jsdom@16.2.6": version "16.2.6" resolved "https://registry.yarnpkg.com/@types/jsdom/-/jsdom-16.2.6.tgz#9ddf0521e49be5365797e690c3ba63148e562c29" @@ -2086,6 +2096,18 @@ resolved "https://registry.yarnpkg.com/@types/less/-/less-3.0.2.tgz#2761d477678c8374cb9897666871662eb1d1115e" integrity sha512-62vfe65cMSzYaWmpmhqCMMNl0khen89w57mByPi1OseGfcV/LV03fO8YVrNj7rFQsRWNJo650WWyh6m7p8vZmA== +"@types/lodash.merge@^4.6.6": + version "4.6.6" + resolved "https://registry.yarnpkg.com/@types/lodash.merge/-/lodash.merge-4.6.6.tgz#b84b403c1d31bc42d51772d1cd5557fa008cd3d6" + integrity sha512-IB90krzMf7YpfgP3u/EvZEdXVvm4e3gJbUvh5ieuI+o+XqiNEt6fCzqNRaiLlPVScLI59RxIGZMQ3+Ko/DJ8vQ== + dependencies: + "@types/lodash" "*" + +"@types/lodash@*": + version "4.14.168" + resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.168.tgz#fe24632e79b7ade3f132891afff86caa5e5ce008" + integrity sha512-oVfRvqHV/V6D1yifJbVRU3TMp8OT6o6BG+U9MkwuJ3U8/CsDHvalRpsxBqivn71ztOFZBTfJMvETbqHiaNSj7Q== + "@types/mime@^1": version "1.3.2" resolved "https://registry.yarnpkg.com/@types/mime/-/mime-1.3.2.tgz#93e25bf9ee75fe0fd80b594bc4feb0e862111b5a" @@ -2101,10 +2123,10 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-14.14.34.tgz#07935194fc049069a1c56c0c274265abeddf88da" integrity sha512-dBPaxocOK6UVyvhbnpFIj2W+S+1cBTkHQbFQfeeJhoKFbzYcVUGHvddeWPSucKATb3F0+pgDq0i6ghEaZjsugA== -"@types/node@^12.20.4": - version "12.20.5" - resolved "https://registry.yarnpkg.com/@types/node/-/node-12.20.5.tgz#4ca82a766f05c359fd6c77505007e5a272f4bb9b" - integrity sha512-5Oy7tYZnu3a4pnJ//d4yVvOImExl4Vtwf0D40iKUlU+XlUsyV9iyFWyCFlwy489b72FMAik/EFwRkNLjjOdSPg== +"@types/node@^12.20.5": + version "12.20.6" + resolved "https://registry.yarnpkg.com/@types/node/-/node-12.20.6.tgz#7b73cce37352936e628c5ba40326193443cfba25" + integrity sha512-sRVq8d+ApGslmkE9e3i+D3gFGk7aZHAT+G4cIpIEdLJYPsWiSPwcAnJEjddLQQDqV3Ra2jOclX/Sv6YrvGYiWA== "@types/normalize-package-data@^2.4.0": version "2.4.0" @@ -3927,6 +3949,11 @@ compare-func@^2.0.0: array-ify "^1.0.0" dot-prop "^5.1.0" +compare-versions@^3.6.0: + version "3.6.0" + resolved "https://registry.yarnpkg.com/compare-versions/-/compare-versions-3.6.0.tgz#1a5689913685e5a87637b8d3ffca75514ec41d62" + integrity sha512-W6Af2Iw1z4CB7q4uU4hv646dW9GQuBM+YpC0UvUCWSD8w90SJjp+ujJuXaEMtAXBtSqGfMPuFOVn4/+FlaqfBA== + component-emitter@^1.2.1: version "1.3.0" resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.3.0.tgz#16e4070fba8ae29b679f2215853ee181ab2eabc0" @@ -4244,7 +4271,7 @@ core-util-is@1.0.2, core-util-is@~1.0.0: resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" integrity sha1-tf1UIgqivFq1eqtxQMlAdUUDwac= -cosmiconfig@7.0.0: +cosmiconfig@7.0.0, cosmiconfig@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-7.0.0.tgz#ef9b44d773959cae63ddecd122de23853b60f8d3" integrity sha512-pondGvTuVYDk++upghXJabWzL6Kxu6f26ljFw64Swq9v6sQPUL3EUlVDV56diOjpCayKihL6hVe8exIACU4XcA== @@ -5690,7 +5717,7 @@ find-process@^1.4.3: commander "^5.1.0" debug "^4.1.1" -find-up@5.0.0: +find-up@5.0.0, find-up@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/find-up/-/find-up-5.0.0.tgz#4c92819ecb7083561e4f4a240a86be5198f536fc" integrity sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng== @@ -5728,6 +5755,13 @@ find-up@^4.0.0, find-up@^4.1.0: locate-path "^5.0.0" path-exists "^4.0.0" +find-versions@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/find-versions/-/find-versions-4.0.0.tgz#3c57e573bf97769b8cb8df16934b627915da4965" + integrity sha512-wgpWy002tA+wgmO27buH/9KzyEOQnKsG/R0yrcjPT9BOFm0zRBVQbZ95nRGXWMywS8YR5knRbpohio0bcJABxQ== + dependencies: + semver-regex "^3.1.2" + flat-cache@^3.0.4: version "3.0.4" resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-3.0.4.tgz#61b0338302b2fe9f957dcc32fc2a87f1c3048b11" @@ -6511,6 +6545,22 @@ human-signals@^2.1.0: resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-2.1.0.tgz#dc91fcba42e4d06e4abaed33b3e7a3c02f514ea0" integrity sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw== +husky@4: + version "4.3.8" + resolved "https://registry.yarnpkg.com/husky/-/husky-4.3.8.tgz#31144060be963fd6850e5cc8f019a1dfe194296d" + integrity sha512-LCqqsB0PzJQ/AlCgfrfzRe3e3+NvmefAdKQhRYpxS4u6clblBoDdzzvHi8fmxKRzvMxPY/1WZWzomPZww0Anow== + dependencies: + chalk "^4.0.0" + ci-info "^2.0.0" + compare-versions "^3.6.0" + cosmiconfig "^7.0.0" + find-versions "^4.0.0" + opencollective-postinstall "^2.0.2" + pkg-dir "^5.0.0" + please-upgrade-node "^3.2.0" + slash "^3.0.0" + which-pm-runs "^1.0.0" + iconv-lite@0.4.24, iconv-lite@^0.4.24: version "0.4.24" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" @@ -7951,6 +8001,11 @@ lodash.memoize@^4.1.2: resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe" integrity sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4= +lodash.merge@^4.6.2: + version "4.6.2" + resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a" + integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== + lodash.sortby@^4.7.0: version "4.7.0" resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438" @@ -8819,6 +8874,11 @@ onetime@^5.1.0, onetime@^5.1.2: dependencies: mimic-fn "^2.1.0" +opencollective-postinstall@^2.0.2: + version "2.0.3" + resolved "https://registry.yarnpkg.com/opencollective-postinstall/-/opencollective-postinstall-2.0.3.tgz#7a0fff978f6dbfa4d006238fbac98ed4198c3259" + integrity sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q== + opener@1.5.2, opener@^1.5.2: version "1.5.2" resolved "https://registry.yarnpkg.com/opener/-/opener-1.5.2.tgz#5d37e1f35077b9dcac4301372271afdeb2a13598" @@ -9293,6 +9353,13 @@ pkg-dir@^4.1.0, pkg-dir@^4.2.0: dependencies: find-up "^4.0.0" +pkg-dir@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-5.0.0.tgz#a02d6aebe6ba133a928f74aec20bafdfe6b8e760" + integrity sha512-NPE8TDbzl/3YQYY7CSS228s3g2ollTFnc+Qi3tqmqJp9Vg2ovUpixcJEo2HJScN2Ez+kEaal6y70c0ehqJBJeA== + dependencies: + find-up "^5.0.0" + playwright-chromium@1.9.1: version "1.9.1" resolved "https://registry.yarnpkg.com/playwright-chromium/-/playwright-chromium-1.9.1.tgz#3f9c85ce74393aeefc76ccc58477eb339ba40b39" @@ -9312,6 +9379,13 @@ playwright-chromium@1.9.1: stack-utils "^2.0.3" ws "^7.3.1" +please-upgrade-node@^3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/please-upgrade-node/-/please-upgrade-node-3.2.0.tgz#aeddd3f994c933e4ad98b99d9a556efa0e2fe942" + integrity sha512-gQR3WpIgNIKwBMVLkpMUeR3e1/E1y42bqDQZfql+kDeXd8COYfM8PQA4X6y7a8u9Ua9FHmsrrmirW2vHs45hWg== + dependencies: + semver-compare "^1.0.0" + pluralize@^8.0.0: version "8.0.0" resolved "https://registry.yarnpkg.com/pluralize/-/pluralize-8.0.0.tgz#1a6fa16a38d12a1901e0320fa017051c539ce3b1" @@ -10876,6 +10950,11 @@ scule@^0.1.1: resolved "https://registry.yarnpkg.com/scule/-/scule-0.1.1.tgz#6bf026f1815c646f061761f9bfd7a3e783f2d05c" integrity sha512-1j2RlmUNADEprCkzDaeo8w2tdum/mvQWAKdRaS2raud7IOnPaDbLSFKhcY5xXPbAFYWk4ZQ0BUnfmg0ZUcI+Pg== +semver-compare@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/semver-compare/-/semver-compare-1.0.0.tgz#0dee216a1c941ab37e9efb1788f6afc5ff5537fc" + integrity sha1-De4hahyUGrN+nvsXiPavxf9VN/w= + semver-diff@^3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/semver-diff/-/semver-diff-3.1.1.tgz#05f77ce59f325e00e2706afd67bb506ddb1ca32b" @@ -10883,6 +10962,11 @@ semver-diff@^3.1.1: dependencies: semver "^6.3.0" +semver-regex@^3.1.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/semver-regex/-/semver-regex-3.1.2.tgz#34b4c0d361eef262e07199dbef316d0f2ab11807" + integrity sha512-bXWyL6EAKOJa81XG1OZ/Yyuq+oT0b2YLlxx7c+mrdYPaPbnj6WgVULXhinMIeZGufuUBu/eVRqXEhiv4imfwxA== + "semver@2 || 3 || 4 || 5", semver@^5.5.0, semver@^5.6.0: version "5.7.1" resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7" @@ -12638,6 +12722,11 @@ which-module@^2.0.0: resolved "https://registry.yarnpkg.com/which-module/-/which-module-2.0.0.tgz#d9ef07dce77b9902b8a3a8fa4b31c3e3f7e6e87a" integrity sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho= +which-pm-runs@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/which-pm-runs/-/which-pm-runs-1.0.0.tgz#670b3afbc552e0b55df6b7780ca74615f23ad1cb" + integrity sha1-Zws6+8VS4LVd9rd4DKdGFfI60cs= + which@^1.2.12, which@^1.2.9: version "1.3.1" resolved "https://registry.yarnpkg.com/which/-/which-1.3.1.tgz#a45043d54f5805316da8d62f9f50918d3da70b0a"