From 23cb159c7ecace199fd2dcea9a5f76242b7200d4 Mon Sep 17 00:00:00 2001 From: kazuya kawaguchi Date: Thu, 19 Jan 2023 18:27:38 +0900 Subject: [PATCH] feat: extend lazy loading resolve #1797 --- .vscode/settings.json | 9 +++- src/gen.ts | 74 ++++++++++++++++++++--------- src/options.d.ts | 8 +++- src/runtime/internal.ts | 59 +++++++++++++++++++---- src/types.ts | 3 +- src/utils.ts | 20 ++++---- src/vue-i18n-routing.d.ts | 8 ++++ test/__snapshots__/gen.test.ts.snap | 48 +++++++++++++++---- test/gen.test.ts | 44 ++++++++++++++--- test/utils.test.ts | 54 ++++++++++++++++++++- 10 files changed, 267 insertions(+), 60 deletions(-) create mode 100644 src/vue-i18n-routing.d.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index c6341b442..d34300eb6 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -9,5 +9,12 @@ "editor.codeActionsOnSave": { "source.fixAll.eslint": true }, - "typescript.tsdk": "node_modules/typescript/lib" + "typescript.tsdk": "node_modules/typescript/lib", + "i18n-ally.localesPaths": [ + "playground/locales", + "specs/fixtures/basic/locales", + "specs/fixtures/head/locales", + "specs/fixtures/vue_i18n_options_loader/locales", + "specs/fixtures/lazy/lang" + ] } diff --git a/src/gen.ts b/src/gen.ts index 0afa2a21c..4c8a81cc1 100644 --- a/src/gen.ts +++ b/src/gen.ts @@ -44,23 +44,34 @@ export function generateLoaderOptions( } } + const generatedImports = new Map() const importMapper = new Map() - for (const { code, path, file } of syncLocaleFiles) { - importMapper.set(code, genSafeVariableName(`locale_${code}`)) - let loadPath = path - if (file && langDir) { - loadPath = resolveLocaleRelativePath(localesRelativeBase, langDir, file) + + function generateSyncImports(gen: string, path?: string) { + if (!path) { + return gen } - let assertFormat = '' - if (file) { - const { ext } = parsePath(file) - assertFormat = ext.slice(1) + + const { base, ext } = parsePath(path) + if (!generatedImports.has(base)) { + let loadPath = path + if (langDir) { + loadPath = resolveLocaleRelativePath(localesRelativeBase, langDir, base) + } + const assertFormat = ext.slice(1) + const variableName = genSafeVariableName(`locale_${convertToImportId(base)}`) + gen += `${genImport(loadPath, variableName, assertFormat ? { assert: { type: assertFormat } } : {})}\n` + importMapper.set(base, variableName) + generatedImports.set(base, loadPath) } - genCode += `${genImport( - loadPath, - genSafeVariableName(`locale_${code}`), - assertFormat ? { assert: { type: assertFormat } } : {} - )}\n` + + return gen + } + + for (const { path, paths } of syncLocaleFiles) { + ;(path ? [path] : paths || []).forEach(p => { + genCode = generateSyncImports(genCode, p) + }) } // prettier-ignore @@ -102,15 +113,20 @@ export function generateLoaderOptions( } else if (rootKey === 'localeInfo') { let codes = `export const localeMessages = {\n` if (langDir) { - for (const { code } of syncLocaleFiles) { - codes += ` ${toCode(code)}: () => Promise.resolve(${importMapper.get(code)}),\n` + for (const { code, path, paths } of syncLocaleFiles) { + const syncPaths = path ? [path] : paths || [] + codes += ` ${toCode(code)}: [${syncPaths.map(path => { + const { base } = parsePath(path) + return `{ key: ${toCode(generatedImports.get(base))}, load: () => Promise.resolve(${importMapper.get(base)}) }` + })}],\n` } - for (const { code, path, file } of asyncLocaleFiles) { - let loadPath = path - if (file && langDir) { - loadPath = resolveLocaleRelativePath(localesRelativeBase, langDir, file) - } - codes += ` ${toCode(code)}: ${genDynamicImport(loadPath, { comment: `webpackChunkName: "lang-${code}"` })},\n` + for (const { code, path, paths } of asyncLocaleFiles) { + const dynamicPaths = path ? [path] : paths || [] + codes += ` ${toCode(code)}: [${dynamicPaths.map(path => { + const { base } = parsePath(path) + const loadPath = resolveLocaleRelativePath(localesRelativeBase, langDir, base) + return `{ key: ${toCode(loadPath)}, load: ${genDynamicImport(loadPath, { comment: `webpackChunkName: "lang-${base}"` })} }` + })}],\n` } } codes += `}\n` @@ -130,6 +146,20 @@ export function generateLoaderOptions( return genCode } +const IMPORT_ID_CACHES = new Map() + +function convertToImportId(file: string) { + if (IMPORT_ID_CACHES.has(file)) { + return IMPORT_ID_CACHES.get(file) + } + + const { name } = parsePath(file) + const id = name.replace(/-/g, '_').replace(/\./g, '_') + IMPORT_ID_CACHES.set(file, id) + + return id +} + function resolveLocaleRelativePath(relativeBase: string, langDir: string, file: string) { return normalize(`${relativeBase}/${langDir}/${file}`) } diff --git a/src/options.d.ts b/src/options.d.ts index 5f4e7b5e1..dc86c65c9 100644 --- a/src/options.d.ts +++ b/src/options.d.ts @@ -7,10 +7,16 @@ import type { DeepRequired } from 'ts-essentials' * stub type definition for @nuxtjs/i18n internally */ +type LocaleLoader = { + key: string + // eslint-disable-next-line @typescript-eslint/no-explicit-any + load: () => Promise +} + // eslint-disable-next-line @typescript-eslint/no-explicit-any export const loadMessages: () => Promise = () => Promise.resolve({}) // eslint-disable-next-line @typescript-eslint/no-explicit-any -export const localeMessages: Record Promise> = {} +export const localeMessages: Record = {} // eslint-disable-next-line @typescript-eslint/no-explicit-any export const additionalMessages: Record Promise>> = {} // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/src/runtime/internal.ts b/src/runtime/internal.ts index 95695efea..e8d1f2621 100644 --- a/src/runtime/internal.ts +++ b/src/runtime/internal.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { isArray, isString, isFunction } from '@intlify/shared' +import { isArray, isString, isFunction, isObject, hasOwn } from '@intlify/shared' import { findBrowserLocale, getLocalesRegex, @@ -94,6 +94,24 @@ export function parseAcceptLanguage(input: string): string[] { return input.split(',').map(tag => tag.split(';')[0]) } +const isNotObjectOrIsArray = (val: unknown) => !isObject(val) || isArray(val) + +function deepCopy(src: any, des: any) { + if (isNotObjectOrIsArray(src) || isNotObjectOrIsArray(des)) { + return + } + + for (const key in src) { + if (hasOwn(src, key)) { + if (isNotObjectOrIsArray(src[key]) || isNotObjectOrIsArray(des[key])) { + des[key] = src[key] + } else { + deepCopy(src[key], des[key]) + } + } + } +} + async function loadMessage(context: NuxtApp, loader: () => Promise) { let message: LocaleMessages | null = null try { @@ -112,6 +130,7 @@ async function loadMessage(context: NuxtApp, loader: () => Promise) { } const loadedLocales: Locale[] = [] +const loadedMessages = new Map>() export async function loadLocale( context: NuxtApp, @@ -119,16 +138,40 @@ export async function loadLocale( setter: (locale: Locale, message: LocaleMessages) => void ) { if (process.server || process.dev || !loadedLocales.includes(locale)) { - const loader = localeMessages[locale] - if (loader != null) { - const message = await loadMessage(context, loader) - if (message != null) { - setter(locale, message) + const loaders = localeMessages[locale] + if (loaders != null) { + if (loaders.length === 1) { + const { key, load } = loaders[0] + if (!loadedMessages.has(key)) { + const message = await loadMessage(context, load) + if (message != null) { + loadedMessages.set(key, message) + setter(locale, message) + loadedLocales.push(locale) + } + } + } else if (loaders.length > 1) { + const targetMessage: LocaleMessages = {} + for (const { key, load } of loaders) { + let message: LocaleMessages | undefined | null = null + if (loadedMessages.has(key)) { + message = loadedMessages.get(key) + } else { + message = await loadMessage(context, load) + if (message != null) { + loadedMessages.set(key, message) + } + } + if (message != null) { + deepCopy(message, targetMessage) + } + } + setter(locale, targetMessage) loadedLocales.push(locale) } - } else { - console.warn(formatMessage('Could not find ' + locale + ' locale in localeMessages')) } + } else { + console.warn(formatMessage('Could not find ' + locale + ' locale code in localeMessages')) } } diff --git a/src/types.ts b/src/types.ts index 3ae88aef1..0d6964393 100644 --- a/src/types.ts +++ b/src/types.ts @@ -19,7 +19,8 @@ export interface DetectBrowserLanguageOptions { } export type LocaleInfo = { - path: string + path?: string + paths?: string[] } & LocaleObject export interface RootRedirectOptions { diff --git a/src/utils.ts b/src/utils.ts index aef86b2b6..a430f3f98 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,6 +1,6 @@ import { promises as fs, constants as FS_CONSTANTS } from 'node:fs' import { resolveFiles } from '@nuxt/kit' -import { parse, parse as parsePath } from 'pathe' +import { parse, parse as parsePath, resolve } from 'pathe' import { encodePath } from 'ufo' import { resolveLockfile } from 'pkg-types' import { isObject, isString } from '@intlify/shared' @@ -54,16 +54,14 @@ export function getNormalizedLocales(locales: NuxtI18nOptions['locales']): Local export async function resolveLocales(path: string, locales: LocaleObject[]): Promise { const files = await resolveFiles(path, '**/*{json,json5,yaml,yml}') - return files.map(file => { - const parsed = parse(file) - const locale = locales.find((locale: string | LocaleObject) => isObject(locale) && locale.file === parsed.base) - return locales == null - ? { - path: file, - file: parsed.base, - code: parsed.name - } - : Object.assign({ path: file }, locale) + const find = (f: string) => files.find(file => file === resolve(path, f)) + return (locales as LocaleInfo[]).map(locale => { + if (locale.file) { + locale.path = find(locale.file) + } else if (locale.files) { + locale.paths = locale.files.map(file => find(file)).filter(Boolean) as string[] + } + return locale }) } diff --git a/src/vue-i18n-routing.d.ts b/src/vue-i18n-routing.d.ts new file mode 100644 index 000000000..bd1ad3ca7 --- /dev/null +++ b/src/vue-i18n-routing.d.ts @@ -0,0 +1,8 @@ +declare module 'vue-i18n-routing' { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + export interface LocaleObject extends Record { + files?: string[] + } +} + +export {} diff --git a/test/__snapshots__/gen.test.ts.snap b/test/__snapshots__/gen.test.ts.snap index 1b9a4f28e..24c06cc83 100644 --- a/test/__snapshots__/gen.test.ts.snap +++ b/test/__snapshots__/gen.test.ts.snap @@ -7,9 +7,9 @@ import locale_fr from \\"../locales/fr.json\\" assert { type: \\"json\\" }; export const localeCodes = [\\"en\\",\\"ja\\",\\"fr\\"] export const localeMessages = { - \\"en\\": () => Promise.resolve(locale_en), - \\"ja\\": () => Promise.resolve(locale_ja), - \\"fr\\": () => Promise.resolve(locale_fr), + \\"en\\": [{ key: \\"../locales/en.json\\", load: () => Promise.resolve(locale_en) }], + \\"ja\\": [{ key: \\"../locales/ja.json\\", load: () => Promise.resolve(locale_ja) }], + \\"fr\\": [{ key: \\"../locales/fr.json\\", load: () => Promise.resolve(locale_fr) }], } export const additionalMessages = Object({}) @@ -37,9 +37,39 @@ exports[`lazy 1`] = ` "export const localeCodes = [\\"en\\",\\"ja\\",\\"fr\\"] export const localeMessages = { - \\"en\\": () => import(\\"../locales/en.json\\" /* webpackChunkName: \\"lang-en\\" */), - \\"ja\\": () => import(\\"../locales/ja.json\\" /* webpackChunkName: \\"lang-ja\\" */), - \\"fr\\": () => import(\\"../locales/fr.json\\" /* webpackChunkName: \\"lang-fr\\" */), + \\"en\\": [{ key: \\"../locales/en.json\\", load: () => import(\\"../locales/en.json\\" /* webpackChunkName: \\"lang-en.json\\" */) }], + \\"ja\\": [{ key: \\"../locales/ja.json\\", load: () => import(\\"../locales/ja.json\\" /* webpackChunkName: \\"lang-ja.json\\" */) }], + \\"fr\\": [{ key: \\"../locales/fr.json\\", load: () => import(\\"../locales/fr.json\\" /* webpackChunkName: \\"lang-fr.json\\" */) }], +} + +export const additionalMessages = Object({}) + +export const resolveNuxtI18nOptions = async (context) => { + const nuxtI18nOptions = Object({}) + nuxtI18nOptions.defaultLocale = \\"en\\" + const vueI18nOptionsLoader = async (context) => Object({\\"locale\\":\\"en\\",\\"fallbackLocale\\":\\"fr\\",\\"messages\\": Object({\\"en\\":{ + \\"hello\\": (()=>{const fn=(ctx) => {const { normalize: _normalize } = ctx;return _normalize([\\"Hello!\\"])};fn.source=\\"Hello!\\";return fn;})() +},}),}) + nuxtI18nOptions.vueI18n = await vueI18nOptionsLoader(context) + return nuxtI18nOptions +} + +export const nuxtI18nInternalOptions = Object({__normalizedLocales: [Object({\\"code\\":\\"en\\"})]}) +export const NUXT_I18N_MODULE_ID = \\"@nuxtjs/i18n\\" +export const isSSG = false +export const isSSR = true +" +`; + +exports[`multiple files 1`] = ` +"export const localeCodes = [\\"en\\",\\"ja\\",\\"fr\\",\\"es\\",\\"es-AR\\"] + +export const localeMessages = { + \\"en\\": [{ key: \\"../locales/en.json\\", load: () => import(\\"../locales/en.json\\" /* webpackChunkName: \\"lang-en.json\\" */) }], + \\"ja\\": [{ key: \\"../locales/ja.json\\", load: () => import(\\"../locales/ja.json\\" /* webpackChunkName: \\"lang-ja.json\\" */) }], + \\"fr\\": [{ key: \\"../locales/fr.json\\", load: () => import(\\"../locales/fr.json\\" /* webpackChunkName: \\"lang-fr.json\\" */) }], + \\"es\\": [{ key: \\"../locales/es.json\\", load: () => import(\\"../locales/es.json\\" /* webpackChunkName: \\"lang-es.json\\" */) }], + \\"es-AR\\": [{ key: \\"../locales/es.json\\", load: () => import(\\"../locales/es.json\\" /* webpackChunkName: \\"lang-es.json\\" */) },{ key: \\"../locales/es-AR.json\\", load: () => import(\\"../locales/es-AR.json\\" /* webpackChunkName: \\"lang-es-AR.json\\" */) }], } export const additionalMessages = Object({}) @@ -130,9 +160,9 @@ import locale_fr from \\"../locales/fr.json\\" assert { type: \\"json\\" }; export const localeCodes = [\\"en\\",\\"ja\\",\\"fr\\"] export const localeMessages = { - \\"en\\": () => Promise.resolve(locale_en), - \\"ja\\": () => Promise.resolve(locale_ja), - \\"fr\\": () => Promise.resolve(locale_fr), + \\"en\\": [{ key: \\"../locales/en.json\\", load: () => Promise.resolve(locale_en) }], + \\"ja\\": [{ key: \\"../locales/ja.json\\", load: () => Promise.resolve(locale_ja) }], + \\"fr\\": [{ key: \\"../locales/fr.json\\", load: () => Promise.resolve(locale_fr) }], } export const additionalMessages = Object({\\"en\\":[() => Promise.resolve({ diff --git a/test/gen.test.ts b/test/gen.test.ts index 4e3edf1ed..11ac4b4dc 100644 --- a/test/gen.test.ts +++ b/test/gen.test.ts @@ -1,4 +1,4 @@ -import { it, expect } from 'vitest' +import { test, expect } from 'vitest' import { parse } from '@babel/parser' import { generateLoaderOptions } from '../src/gen' import { DEFAULT_OPTIONS } from '../src/constants' @@ -68,7 +68,7 @@ function validateSyntax(code: string): boolean { return ret } -it('basic', () => { +test('basic', () => { const code = generateLoaderOptions( false, 'locales', @@ -87,7 +87,7 @@ it('basic', () => { expect(code).toMatchSnapshot() }) -it('lazy', () => { +test('lazy', () => { const code = generateLoaderOptions( true, 'locales', @@ -105,7 +105,39 @@ it('lazy', () => { expect(code).toMatchSnapshot() }) -it('vueI18n: path', () => { +test('multiple files', () => { + const code = generateLoaderOptions( + true, + 'locales', + '..', + { + localeCodes: [...LOCALE_CODES, 'es', 'es-AR'], + localeInfo: [ + ...LOCALE_INFO, + ...[ + { + code: 'es', + file: 'es.json', + path: '/path/to/es.json' + }, + { + code: 'es-AR', + files: ['es.json', 'es-AR.json'], + paths: ['/path/to/es.json', '/path/to/es-AR.json'] + } + ] + ], + additionalMessages: {}, + nuxtI18nOptions: NUXT_I18N_OPTIONS, + nuxtI18nInternalOptions: NUXT_I18N_INTERNAL_OPTIONS + }, + { ssg: false, ssr: true, dev: true } + ) + expect(validateSyntax(code)).toBe(true) + expect(code).toMatchSnapshot() +}) + +test('vueI18n: path', () => { const code = generateLoaderOptions( false, 'locales', @@ -125,7 +157,7 @@ it('vueI18n: path', () => { expect(code).toMatchSnapshot() }) -it('toCode: function (arrow)', () => { +test('toCode: function (arrow)', () => { const code = generateLoaderOptions( false, 'locales', @@ -151,7 +183,7 @@ it('toCode: function (arrow)', () => { expect(code).toMatchSnapshot() }) -it('toCode: function (named)', () => { +test('toCode: function (named)', () => { const code = generateLoaderOptions(false, 'locales', '..', { localeCodes: LOCALE_CODES, additionalMessages: {}, diff --git a/test/utils.test.ts b/test/utils.test.ts index ead0fdb7f..2ab612129 100644 --- a/test/utils.test.ts +++ b/test/utils.test.ts @@ -1,4 +1,56 @@ -import { parseSegment, getRoutePath } from '../src/utils' +import { parseSegment, getRoutePath, resolveLocales } from '../src/utils' +import type { LocaleObject } from 'vue-i18n-routing' + +vi.mock('@nuxt/kit', () => { + const resolveFiles = () => { + return ['en', 'ja', 'es', 'es-AR'].map(l => `/path/to/project/locales/${l}.json`) + } + return { resolveFiles } +}) + +test('resolveLocales', async () => { + const locales = [ + { + code: 'en', + file: 'en.json' + }, + { + code: 'ja', + file: 'ja.json' + }, + { + code: 'es', + file: 'es.json' + }, + { + code: 'es-AR', + files: ['es.json', 'es-AR.json'] + } + ] as LocaleObject[] + const resolvedLocales = await resolveLocales('/path/to/project/locales', locales) + expect(resolvedLocales).toEqual([ + { + path: '/path/to/project/locales/en.json', + code: 'en', + file: 'en.json' + }, + { + path: '/path/to/project/locales/ja.json', + code: 'ja', + file: 'ja.json' + }, + { + path: '/path/to/project/locales/es.json', + code: 'es', + file: 'es.json' + }, + { + paths: ['/path/to/project/locales/es.json', '/path/to/project/locales/es-AR.json'], + code: 'es-AR', + files: ['es.json', 'es-AR.json'] + } + ]) +}) test('parseSegment', () => { const tokens = parseSegment('[foo]_[bar]:[...buz]_buz_[[qux]]')