From 282df900b9970c9bd104d81a44287bc25a6da7c6 Mon Sep 17 00:00:00 2001 From: Bobbie Goede Date: Thu, 3 Oct 2024 14:57:53 +0200 Subject: [PATCH] feat: basic messages type generation --- build.config.ts | 14 ++- package.json | 3 +- pnpm-lock.yaml | 21 ++-- src/module.ts | 11 ++- src/type-generation.ts | 213 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 250 insertions(+), 12 deletions(-) create mode 100644 src/type-generation.ts diff --git a/build.config.ts b/build.config.ts index 68ff1ed23..a1f2daa9c 100644 --- a/build.config.ts +++ b/build.config.ts @@ -1,5 +1,17 @@ import { defineBuildConfig } from 'unbuild' export default defineBuildConfig({ - externals: ['node:fs', 'node:url', 'webpack', '@babel/parser', 'unplugin-vue-router', 'unplugin-vue-router/options'] + externals: [ + 'node:fs', + 'node:url', + 'webpack', + '@babel/parser', + 'unplugin-vue-router', + 'unplugin-vue-router/options', + 'jiti', + 'confbox', + 'ofetch', + 'destr' + ], + failOnWarn: false }) diff --git a/package.json b/package.json index 84bfb8fdd..e87e869f9 100644 --- a/package.json +++ b/package.json @@ -120,6 +120,7 @@ "@unhead/vue": "^1.8.8", "bumpp": "^9.4.1", "changelogithub": "^0.13.7", + "confbox": "^0.1.7", "consola": "^3", "eslint": "^9.5.0", "eslint-config-prettier": "^9.1.0", @@ -128,7 +129,7 @@ "globals": "^15.6.0", "globby": "^14.0.0", "h3": "^1.12.0", - "jiti": "^1.20.0", + "jiti": "2.0.0", "jsdom": "^24.1.0", "lint-staged": "^15.2.7", "nitropack": "^2.9.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 412d88d2a..81e315f8a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -115,6 +115,9 @@ importers: changelogithub: specifier: ^0.13.7 version: 0.13.7(magicast@0.3.5) + confbox: + specifier: ^0.1.7 + version: 0.1.7 consola: specifier: ^3 version: 3.2.3 @@ -140,8 +143,8 @@ importers: specifier: ^1.12.0 version: 1.12.0 jiti: - specifier: ^1.20.0 - version: 1.21.0 + specifier: 2.0.0 + version: 2.0.0 jsdom: specifier: ^24.1.0 version: 24.1.0 @@ -4354,14 +4357,14 @@ packages: resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==} engines: {node: '>= 10.13.0'} - jiti@1.21.0: - resolution: {integrity: sha512-gFqAIbuKyyso/3G2qhiO2OM6shY6EPP/R0+mkDbyspxKazh8BXDC5FiFsUjlczgdNz/vfra0da2y+aHrusLG/Q==} - hasBin: true - jiti@1.21.6: resolution: {integrity: sha512-2yTgeWTWzMWkHu6Jp9NKgePDaYHbntiwvYuuJLbbN9vl7DC9DvXKOB2BC3ZZ92D3cvV/aflH0osDfwpHepQ53w==} hasBin: true + jiti@2.0.0: + resolution: {integrity: sha512-CJ7e7Abb779OTRv3lomfp7Mns/Sy1+U4pcAx5VbjxCZD5ZM/VJaXPpPjNKjtSvWQy/H86E49REXR34dl1JEz9w==} + hasBin: true + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -12267,10 +12270,10 @@ snapshots: merge-stream: 2.0.0 supports-color: 8.1.1 - jiti@1.21.0: {} - jiti@1.21.6: {} + jiti@2.0.0: {} + js-tokens@4.0.0: {} js-tokens@9.0.0: {} @@ -15225,7 +15228,7 @@ snapshots: esbuild: 0.19.12 globby: 13.2.2 hookable: 5.5.3 - jiti: 1.21.0 + jiti: 1.21.6 magic-string: 0.30.10 mkdist: 1.5.4(typescript@5.6.2)(vue-tsc@2.1.6(typescript@5.6.2)) mlly: 1.7.1 diff --git a/src/module.ts b/src/module.ts index 3f84b5330..7835d0083 100644 --- a/src/module.ts +++ b/src/module.ts @@ -5,7 +5,7 @@ import { setupNitro } from './nitro' import { extendBundler } from './bundler' import { NUXT_I18N_MODULE_ID, DEFAULT_OPTIONS } from './constants' import type { HookResult } from '@nuxt/schema' -import type { I18nPublicRuntimeConfig, LocaleObject, NuxtI18nOptions } from './types' +import type { I18nPublicRuntimeConfig, LocaleInfo, LocaleObject, NuxtI18nOptions } from './types' import type { Locale } from 'vue-i18n' import { createContext } from './context' import { prepareOptions } from './prepare/options' @@ -18,6 +18,7 @@ import { prepareStrategy } from './prepare/strategy' import { prepareLayers } from './prepare/layers' import { prepareTranspile } from './prepare/transpile' import { prepareVite } from './prepare/vite' +import { enableVueI18nTypeGeneration } from './type-generation' export * from './types' @@ -75,6 +76,8 @@ export default defineNuxtModule({ */ prepareRuntime(ctx, nuxt) + enableVueI18nTypeGeneration(ctx, nuxt) + /** * disable preloading/prefetching lazy loaded locales */ @@ -148,6 +151,12 @@ declare module '@nuxt/schema' { } interface NuxtOptions { ['i18n']: UserNuxtI18nOptions + /** + * @internal + */ + _i18n: { + locales: LocaleInfo[] + } } interface NuxtHooks extends ModuleHooks {} interface PublicRuntimeConfig extends ModulePublicRuntimeConfig {} diff --git a/src/type-generation.ts b/src/type-generation.ts new file mode 100644 index 000000000..164d78edf --- /dev/null +++ b/src/type-generation.ts @@ -0,0 +1,213 @@ +import type { Nuxt } from '@nuxt/schema' +import { createJiti } from 'jiti' +import { addTypeTemplate, updateTemplates } from '@nuxt/kit' +import { deepCopy } from '@intlify/shared' +import { readFile } from './utils' +import { extname, resolve } from 'pathe' + +import type { I18nOptions } from 'vue-i18n' +import type { NumberFormatOptions } from '@intlify/core' +import type { I18nNuxtContext } from './context' + +// https://github.com/unjs/c12/blob/main/src/loader.ts#L26 +const PARSERS = { + '.yaml': () => import('confbox/yaml').then(r => r.parseYAML), + '.yml': () => import('confbox/yaml').then(r => r.parseYAML), + '.jsonc': () => import('confbox/jsonc').then(r => r.parseJSONC), + '.json5': () => import('confbox/json5').then(r => r.parseJSON5), + '.toml': () => import('confbox/toml').then(r => r.parseTOML), + '.json': () => JSON.parse +} as const + +const SUPPORTED_EXTENSIONS = [ + // with jiti + '.js', + '.ts', + '.mjs', + '.cjs', + '.mts', + '.cts', + '.json', + // with confbox + '.jsonc', + '.json5', + '.yaml', + '.yml', + '.toml' +] as const + +export function enableVueI18nTypeGeneration( + { options: _options, localeInfo, vueI18nConfigPaths }: I18nNuxtContext, + nuxt: Nuxt +) { + const jiti = createJiti(nuxt.options.rootDir, { + interopDefault: true, + moduleCache: false, + fsCache: false, + requireCache: false, + extensions: [...SUPPORTED_EXTENSIONS] + }) + + const keyTranslationMap = new Map() + + function generateInterface(obj: Record, indentLevel = 1) { + const indent = ' '.repeat(indentLevel) + let str = '' + + for (const key in obj) { + if (!Object.prototype.hasOwnProperty.call(obj, key)) continue + + keyTranslationMap.set(key, String(obj[key])) + + if (typeof obj[key] === 'object' && obj[key] !== null && !Array.isArray(obj[key])) { + str += `${indent}${key}: {\n` + str += generateInterface(obj[key] as Record, indentLevel + 1) + str += `${indent}};\n` + } else { + str += `${indent}/**\n` + str += `${indent} * ${JSON.stringify(obj[key])}\n` + str += `${indent} */\n` + let propertyType = Array.isArray(obj[key]) ? 'unknown[]' : typeof obj[key] + if (propertyType === 'function') { + propertyType = '() => string' + } + str += `${indent}${key}: ${propertyType};\n` + } + } + return str + } + + nuxt.options._i18n = { locales: localeInfo } + + addTypeTemplate({ + filename: 'types/i18n-messages.d.ts', + getContents: async ({ nuxt }) => { + const messages = {} + const dateFormats = {} + const numberFormats = {} + + // @ts-ignore + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + globalThis.defineI18nLocale = val => val + + // @ts-ignore + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + globalThis.defineI18nConfig = val => val + + // @ts-ignore + globalThis.useRuntimeConfig = () => nuxt.options.runtimeConfig + + const fetch = await import('ofetch').then(r => r.ofetch) + globalThis.$fetch = fetch + + async function loadTarget(absPath: string, args: unknown[] = []) { + try { + const configFileExt = extname(absPath) || '' + let result + const contents = await readFile(absPath) + if (configFileExt in PARSERS) { + const asyncLoader = await PARSERS[configFileExt as keyof typeof PARSERS]() + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + result = asyncLoader(contents) + } else { + result = await jiti.import(absPath) + } + + if (result instanceof Function) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access + return (await result.call(undefined, ...args)) as unknown + } + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return result + } catch (err) { + console.log(err) + return undefined + } + } + + for (const config of vueI18nConfigPaths) { + const res = (await loadTarget(config.absolute)) as I18nOptions | undefined + + if (res == null) continue + for (const v of Object.values(res.messages ?? [])) { + deepCopy(v, messages) + } + + for (const v of Object.values(res.numberFormats ?? [])) { + deepCopy(v, numberFormats) + } + + for (const v of Object.values(res.datetimeFormats ?? [])) { + deepCopy(v, dateFormats) + } + } + + for (const l of nuxt.options._i18n?.locales ?? []) { + for (const f of l.files) { + const resolvedPath = resolve(nuxt.options.srcDir, f.path) + // console.log(resolvedPath, f.path) + // const contents = await readFile(resolvedPath) + // console.log(resolvedPath, await loadTarget(resolvedPath, [l.code])) + try { + deepCopy((await loadTarget(resolvedPath, [l.code])) ?? {}, messages) + } catch (err) { + console.log(err) + } + } + + // we could only check one locale's files (serving as master/template) for speed + // break + } + + function getNumberFormatType(v: NumberFormatOptions) { + if (v.style == null) return 'NumberFormatOptions' + + /** + * Generating narrowed types may not be desired as types are only updated on save + */ + // if (v.style === 'currency') return 'CurrencyNumberFormatOptions' + // if (v.style === 'decimal' || v.style === 'percent') return 'CurrencyNumberFormatOptions' + return 'NumberFormatOptions' + } + + return `// generated by @nuxtjs/i18n +import type { DateTimeFormatOptions, NumberFormatOptions, SpecificNumberFormatOptions, CurrencyNumberFormatOptions } from '@intlify/core' + +interface GeneratedLocaleMessage { + ${generateInterface(messages).trim()} +} + +interface GeneratedDateTimeFormat { + ${Object.keys(dateFormats) + .map(k => `${k}: DateTimeFormatOptions;`) + .join(`\n `)} +} + +interface GeneratedNumberFormat { + ${Object.entries(numberFormats) + .map(([k, v]) => `${k}: ${getNumberFormatType(v as NumberFormatOptions)};`) + .join(`\n `)} +} + +declare module 'vue-i18n' { + export interface DefineLocaleMessage extends GeneratedLocaleMessage {} + export interface DefineDateTimeFormat extends GeneratedDateTimeFormat {} + export interface DefineNumberFormat extends GeneratedNumberFormat {} +} + +declare module '@intlify/core' { + export interface DefineCoreLocaleMessage extends GeneratedLocaleMessage {} +} + +export {}` + } + }) + + // watch locale files for changes and update template + nuxt.hook('builder:watch', async (_, path) => { + const paths = nuxt.options._i18n.locales.flatMap(x => x.files.map(f => f.path)) + if (!paths.includes(path) && !vueI18nConfigPaths.some(x => x.absolute.includes(path))) return + + await updateTemplates({ filter: template => template.filename === 'types/i18n-messages.d.ts' }) + }) +}