From d841358f1179a1c9944a57245d86bb488e4c4daa Mon Sep 17 00:00:00 2001 From: Jowo Date: Mon, 10 Feb 2025 23:52:48 +0100 Subject: [PATCH 1/4] feat(i18n) WIP YAML support --- src/runtime/server/routes/get.ts | 99 ++++++++++++++++++--------- src/runtime/server/types/js-yaml.d.ts | 1 + src/runtime/server/utils/load-yaml.ts | 16 +++++ 3 files changed, 85 insertions(+), 31 deletions(-) create mode 100644 src/runtime/server/types/js-yaml.d.ts create mode 100644 src/runtime/server/utils/load-yaml.ts diff --git a/src/runtime/server/routes/get.ts b/src/runtime/server/routes/get.ts index 61e84752..593508c4 100644 --- a/src/runtime/server/routes/get.ts +++ b/src/runtime/server/routes/get.ts @@ -1,7 +1,9 @@ import { resolve, join } from 'node:path' import { readFile } from 'node:fs/promises' import { defineEventHandler } from 'h3' +import { globby } from 'globby' import type { Translations, ModuleOptionsExtend, ModulePrivateOptionsExtend } from 'nuxt-i18n-micro-types' +import { loadYaml } from '../utils/load-yaml' // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore import { useRuntimeConfig, createError, useStorage } from '#imports' @@ -12,6 +14,10 @@ function deepMerge(target: Translations, source: Translations): Translations { for (const key in source) { if (key === '__proto__' || key === 'constructor') continue + if (source[key] === undefined) { + continue + } + if (Array.isArray(source[key])) { target[key] = source[key] } @@ -27,19 +33,70 @@ function deepMerge(target: Translations, source: Translations): Translations { return target } +async function loadTranslations(rootDirs: string[], translationDir: string, locale: string, page: string): Promise { + const getTranslationPath = (locale: string, page: string) => + page === 'general' ? `${locale}.json` : `pages/${page}/${locale}.json` + + let translations: Translations = {} + + const createPaths = (locale: string) => + rootDirs.map(dir => ({ + translationPath: resolve(dir, translationDir!, getTranslationPath(locale, page)), + name: `_locales/${getTranslationPath(locale, page)}`, + })) + + const paths = [createPaths(locale)[0]] + + for (const { translationPath } of paths) { + try { + const content = await readFile(translationPath, 'utf-8') + const fileContent = JSON.parse(content!) as Translations + + translations = deepMerge(translations, fileContent) + } + catch (e) { + console.error('[nuxt-i18n-micro] load locale error', e) + } + } + + return translations +} + +async function loadYamlTranslations(rootDirs: string[], translationDir: string, locale: string, page: string): Promise { + const getTranslationPath = (locale: string, page: string) => { + return page === 'general' ? `${locale}` : `pages/${page}/${locale}` + } + + let translations: Translations = {} + const baseDir = resolve(rootDirs[0], translationDir!) + const yamlFiles = await globby([`**/${getTranslationPath(locale, page)}.yaml`, `**/${getTranslationPath(locale, page)}.yml`], { cwd: baseDir }) + + for (const file of yamlFiles) { + try { + const filePath = resolve(baseDir, file) + console.log('[nuxt-i18n-micro] load yaml locale', filePath) + const fileContent = await loadYaml(filePath) as Translations + if (fileContent) { + translations = deepMerge(translations, fileContent) + } + } + catch (e) { + console.error('[nuxt-i18n-micro] load yaml locale error', e) + } + } + return translations +} + export default defineEventHandler(async (event) => { const { page, locale } = event.context.params as { page: string, locale: string } const config = useRuntimeConfig() - const { rootDirs, debug, translationDir, fallbackLocale, customRegexMatcher } = config.i18nConfig as ModulePrivateOptionsExtend + const { rootDirs, debug, translationDir, customRegexMatcher } = config.i18nConfig as ModulePrivateOptionsExtend const { locales } = config.public.i18nConfig as unknown as ModuleOptionsExtend if (customRegexMatcher && locales && !locales.map(l => l.code).includes(locale)) { throw createError({ statusCode: 404 }) } - const getTranslationPath = (locale: string, page: string) => - page === 'general' ? `${locale}.json` : `pages/${page}/${locale}.json` - let translations: Translations = {} const serverStorage = useStorage('assets:server') @@ -49,40 +106,20 @@ export default defineEventHandler(async (event) => { storageInit = true } - const cacheName = join('_locales', getTranslationPath(locale, page)) + const cacheName = join('_locales', `${locale}-${page}`) const isThereAsset = await serverStorage.hasItem(cacheName) if (isThereAsset) { const rawContent = await serverStorage.getItem(cacheName) ?? {} - return typeof rawContent === 'string' ? JSON.parse(rawContent) : rawContent + translations = typeof rawContent === 'string' ? JSON.parse(rawContent) : rawContent } + else { + const jsonTranslations = await loadTranslations(rootDirs, translationDir!, locale, page) + const yamlTranslations = await loadYamlTranslations(rootDirs, translationDir!, locale, page) - const createPaths = (locale: string) => - rootDirs.map(dir => ({ - translationPath: resolve(dir, translationDir!, getTranslationPath(locale, page)), - name: `_locales/${getTranslationPath(locale, page)}`, - })) - - const paths = [ - ...(fallbackLocale && fallbackLocale !== locale ? createPaths(fallbackLocale) : []), - ...createPaths(locale), - ] - - for (const { translationPath, name } of paths) { - try { - if (debug) console.log('[nuxt-i18n-micro] load locale', translationPath, name) - - const content = await readFile(translationPath, 'utf-8') - const fileContent = JSON.parse(content!) as Translations - - translations = deepMerge(translations, fileContent) - } - catch (e) { - if (debug) console.error('[nuxt-i18n-micro] load locale error', e) - } + translations = deepMerge(jsonTranslations, yamlTranslations) + await serverStorage.setItem(cacheName, translations) } - await serverStorage.setItem(cacheName, translations) - return translations }) diff --git a/src/runtime/server/types/js-yaml.d.ts b/src/runtime/server/types/js-yaml.d.ts new file mode 100644 index 00000000..326410bf --- /dev/null +++ b/src/runtime/server/types/js-yaml.d.ts @@ -0,0 +1 @@ +declare module 'js-yaml'; // Declare to allow optional dependency import diff --git a/src/runtime/server/utils/load-yaml.ts b/src/runtime/server/utils/load-yaml.ts new file mode 100644 index 00000000..dfa65051 --- /dev/null +++ b/src/runtime/server/utils/load-yaml.ts @@ -0,0 +1,16 @@ +import { readFile } from 'node:fs/promises' + +export async function loadYaml(filePath: string) { + try { + const yaml = await import('js-yaml') + const content = await readFile(filePath, 'utf-8') + return yaml.load(content) + } + catch (e: unknown) { + console.error(e) + console.warn( + 'js-yaml is not installed, please install it if you want to use YAML files for translations.', + ) + return null + } +} From 3d73223654e2b296462d27ca3b75400d5a9a5839 Mon Sep 17 00:00:00 2001 From: Jowo Date: Tue, 11 Feb 2025 17:11:37 +0100 Subject: [PATCH 2/4] feat(i18n): Merged load translation into one function, conditionally loading yaml or JSON --- src/runtime/server/routes/get.ts | 66 +++++++++++++------------------- 1 file changed, 26 insertions(+), 40 deletions(-) diff --git a/src/runtime/server/routes/get.ts b/src/runtime/server/routes/get.ts index 593508c4..5aa75629 100644 --- a/src/runtime/server/routes/get.ts +++ b/src/runtime/server/routes/get.ts @@ -1,7 +1,6 @@ import { resolve, join } from 'node:path' import { readFile } from 'node:fs/promises' import { defineEventHandler } from 'h3' -import { globby } from 'globby' import type { Translations, ModuleOptionsExtend, ModulePrivateOptionsExtend } from 'nuxt-i18n-micro-types' import { loadYaml } from '../utils/load-yaml' // eslint-disable-next-line @typescript-eslint/ban-ts-comment @@ -12,7 +11,9 @@ let storageInit = false function deepMerge(target: Translations, source: Translations): Translations { for (const key in source) { - if (key === '__proto__' || key === 'constructor') continue + if (key === '__proto__' || key === 'constructor') { + continue + } if (source[key] === undefined) { continue @@ -35,58 +36,43 @@ function deepMerge(target: Translations, source: Translations): Translations { async function loadTranslations(rootDirs: string[], translationDir: string, locale: string, page: string): Promise { const getTranslationPath = (locale: string, page: string) => - page === 'general' ? `${locale}.json` : `pages/${page}/${locale}.json` + page === 'general' ? `${locale}` : `pages/${page}/${locale}` let translations: Translations = {} - const createPaths = (locale: string) => + const createPaths = (locale: string, ext: string) => rootDirs.map(dir => ({ - translationPath: resolve(dir, translationDir!, getTranslationPath(locale, page)), - name: `_locales/${getTranslationPath(locale, page)}`, + translationPath: resolve(dir, translationDir!, `${getTranslationPath(locale, page)}${ext}`), + name: `_locales/${getTranslationPath(locale, page)}${ext}`, })) - const paths = [createPaths(locale)[0]] + const extensions = ['.json', '.yaml', '.yml'] + const paths = extensions.flatMap(ext => createPaths(locale, ext)) for (const { translationPath } of paths) { try { const content = await readFile(translationPath, 'utf-8') - const fileContent = JSON.parse(content!) as Translations + let fileContent: Translations = {} + + if (translationPath.endsWith('.json')) { + fileContent = JSON.parse(content) as Translations + } + else if (translationPath.endsWith('.yaml') || translationPath.endsWith('.yml')) { + fileContent = await loadYaml(translationPath) as Translations + } translations = deepMerge(translations, fileContent) } + // eslint-disable-next-line @typescript-eslint/no-unused-vars catch (e) { - console.error('[nuxt-i18n-micro] load locale error', e) + // We ignore the error here, as it just means that the file doesn't exist which is fine + // console.error('[nuxt-i18n-micro] load locale error', e) } } return translations } -async function loadYamlTranslations(rootDirs: string[], translationDir: string, locale: string, page: string): Promise { - const getTranslationPath = (locale: string, page: string) => { - return page === 'general' ? `${locale}` : `pages/${page}/${locale}` - } - - let translations: Translations = {} - const baseDir = resolve(rootDirs[0], translationDir!) - const yamlFiles = await globby([`**/${getTranslationPath(locale, page)}.yaml`, `**/${getTranslationPath(locale, page)}.yml`], { cwd: baseDir }) - - for (const file of yamlFiles) { - try { - const filePath = resolve(baseDir, file) - console.log('[nuxt-i18n-micro] load yaml locale', filePath) - const fileContent = await loadYaml(filePath) as Translations - if (fileContent) { - translations = deepMerge(translations, fileContent) - } - } - catch (e) { - console.error('[nuxt-i18n-micro] load yaml locale error', e) - } - } - return translations -} - export default defineEventHandler(async (event) => { const { page, locale } = event.context.params as { page: string, locale: string } const config = useRuntimeConfig() @@ -97,11 +83,12 @@ export default defineEventHandler(async (event) => { throw createError({ statusCode: 404 }) } - let translations: Translations = {} const serverStorage = useStorage('assets:server') if (!storageInit) { - if (debug) console.log('[nuxt-i18n-micro] clear storage cache') + if (debug) { + console.log('[nuxt-i18n-micro] clear storage cache') + } await Promise.all((await serverStorage.getKeys('_locales')).map((key: string) => serverStorage.removeItem(key))) storageInit = true } @@ -109,15 +96,14 @@ export default defineEventHandler(async (event) => { const cacheName = join('_locales', `${locale}-${page}`) const isThereAsset = await serverStorage.hasItem(cacheName) + let translations: Translations = {} + if (isThereAsset) { const rawContent = await serverStorage.getItem(cacheName) ?? {} translations = typeof rawContent === 'string' ? JSON.parse(rawContent) : rawContent } else { - const jsonTranslations = await loadTranslations(rootDirs, translationDir!, locale, page) - const yamlTranslations = await loadYamlTranslations(rootDirs, translationDir!, locale, page) - - translations = deepMerge(jsonTranslations, yamlTranslations) + translations = await loadTranslations(rootDirs, translationDir!, locale, page) await serverStorage.setItem(cacheName, translations) } From 3d69b45d3458e101d1e343bdb99969c1b04ea1b0 Mon Sep 17 00:00:00 2001 From: Jowo Date: Tue, 11 Feb 2025 21:26:47 +0100 Subject: [PATCH 3/4] fix(i18n): fallbackLocale reimplementation --- src/runtime/server/routes/get.ts | 58 ++++++++++++++++++-------------- 1 file changed, 33 insertions(+), 25 deletions(-) diff --git a/src/runtime/server/routes/get.ts b/src/runtime/server/routes/get.ts index 5aa75629..33c9e000 100644 --- a/src/runtime/server/routes/get.ts +++ b/src/runtime/server/routes/get.ts @@ -34,49 +34,57 @@ function deepMerge(target: Translations, source: Translations): Translations { return target } -async function loadTranslations(rootDirs: string[], translationDir: string, locale: string, page: string): Promise { +async function loadTranslations(rootDirs: string[], translationDir: string, locale: string, page: string, fallbackLocale?: string): Promise { const getTranslationPath = (locale: string, page: string) => page === 'general' ? `${locale}` : `pages/${page}/${locale}` let translations: Translations = {} - const createPaths = (locale: string, ext: string) => - rootDirs.map(dir => ({ - translationPath: resolve(dir, translationDir!, `${getTranslationPath(locale, page)}${ext}`), - name: `_locales/${getTranslationPath(locale, page)}${ext}`, - })) + const loadLocale = async (currentLocale: string) => { + const extensions = ['.json', '.yaml', '.yml'] + const paths = extensions.flatMap(ext => createPaths(currentLocale, ext)) - const extensions = ['.json', '.yaml', '.yml'] - const paths = extensions.flatMap(ext => createPaths(locale, ext)) + for (const { translationPath } of paths) { + try { + const content = await readFile(translationPath, 'utf-8') + let fileContent: Translations = {} - for (const { translationPath } of paths) { - try { - const content = await readFile(translationPath, 'utf-8') - let fileContent: Translations = {} + if (translationPath.endsWith('.json')) { + fileContent = JSON.parse(content) as Translations + } + else if (translationPath.endsWith('.yaml') || translationPath.endsWith('.yml')) { + fileContent = await loadYaml(translationPath) as Translations + } - if (translationPath.endsWith('.json')) { - fileContent = JSON.parse(content) as Translations + translations = deepMerge(translations, fileContent) } - else if (translationPath.endsWith('.yaml') || translationPath.endsWith('.yml')) { - fileContent = await loadYaml(translationPath) as Translations + // eslint-disable-next-line @typescript-eslint/no-unused-vars + catch (e) { + // We ignore the error here, as it just means that the file doesn't exist which is fine + // console.error('[nuxt-i18n-micro] load locale error', e) } - - translations = deepMerge(translations, fileContent) - } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - catch (e) { - // We ignore the error here, as it just means that the file doesn't exist which is fine - // console.error('[nuxt-i18n-micro] load locale error', e) } } + const createPaths = (currentLocale: string, ext: string) => + rootDirs.map(dir => ({ + translationPath: resolve(dir, translationDir!, `${getTranslationPath(currentLocale, page)}${ext}`), + name: `_locales/${getTranslationPath(currentLocale, page)}${ext}`, + })) + + if (fallbackLocale && fallbackLocale !== locale) { + await loadLocale(fallbackLocale) + } + + await loadLocale(locale) + return translations } export default defineEventHandler(async (event) => { const { page, locale } = event.context.params as { page: string, locale: string } const config = useRuntimeConfig() - const { rootDirs, debug, translationDir, customRegexMatcher } = config.i18nConfig as ModulePrivateOptionsExtend + const { rootDirs, debug, translationDir, fallbackLocale, customRegexMatcher } = config.i18nConfig as ModulePrivateOptionsExtend const { locales } = config.public.i18nConfig as unknown as ModuleOptionsExtend if (customRegexMatcher && locales && !locales.map(l => l.code).includes(locale)) { @@ -103,7 +111,7 @@ export default defineEventHandler(async (event) => { translations = typeof rawContent === 'string' ? JSON.parse(rawContent) : rawContent } else { - translations = await loadTranslations(rootDirs, translationDir!, locale, page) + translations = await loadTranslations(rootDirs, translationDir, locale, page, fallbackLocale) await serverStorage.setItem(cacheName, translations) } From 4f854beed3c726dec4895c461f6c0c2316b451a7 Mon Sep 17 00:00:00 2001 From: Jowo Date: Tue, 11 Feb 2025 22:23:40 +0100 Subject: [PATCH 4/4] docs(i18n): YAML documentation --- docs/examples.md | 24 ++++++++++++++++++++++++ docs/guide/folder-structure.md | 15 ++++++++++++--- docs/guide/getting-started.md | 10 ++++++++++ docs/guide/performance.md | 4 ++++ docs/index.md | 2 +- 5 files changed, 51 insertions(+), 4 deletions(-) diff --git a/docs/examples.md b/docs/examples.md index b05b5177..db1c68a7 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -461,3 +461,27 @@ import { useNuxtApp } from '#imports' const { $tdr } = useNuxtApp() ``` + +## YAML Examples + +Here are examples of how to structure your translation files using YAML: + +### Example YAML File (`en.yml`) + +```yaml +greeting: "Hello, {username}!" +welcome: + title: "Welcome to our site" + description: "We offer a variety of services." +nav: + home: "Home" + about: "About Us" + contact: "Contact" +``` + +### Example YAML File with Page-Specific Translations (`pages/index/en.yml`) +```yaml +title: "Welcome to the Home Page" +introduction: "This is the introduction text for the home page." + +``` diff --git a/docs/guide/folder-structure.md b/docs/guide/folder-structure.md index e83b623e..ffdbe1ae 100644 --- a/docs/guide/folder-structure.md +++ b/docs/guide/folder-structure.md @@ -14,7 +14,7 @@ Organizing your translation files effectively is essential for maintaining a sca ### πŸ”§ Basic Structure -Here’s a basic example of the folder structure you should follow: +Here’s a basic example of the folder structure you should follow. Note that `.json`, `.yaml`, and `.yml` files are supported. ```plaintext /locales @@ -22,14 +22,23 @@ Here’s a basic example of the folder structure you should follow: β”‚ β”œβ”€β”€ /index β”‚ β”‚ β”œβ”€β”€ en.json β”‚ β”‚ β”œβ”€β”€ fr.json - β”‚ β”‚ └── ar.json + β”‚ β”‚ β”œβ”€β”€ ar.json + β”‚ β”‚ β”œβ”€β”€ en.yml + β”‚ β”‚ β”œβ”€β”€ fr.yml + β”‚ β”‚ └── ar.yml β”‚ β”œβ”€β”€ /about β”‚ β”‚ β”œβ”€β”€ en.json β”‚ β”‚ β”œβ”€β”€ fr.json - β”‚ β”‚ └── ar.json + β”‚ β”‚ β”œβ”€β”€ ar.json + β”‚ β”‚ β”œβ”€β”€ en.yml + β”‚ β”‚ β”œβ”€β”€ fr.yml + β”‚ β”‚ └── ar.yml β”œβ”€β”€ en.json β”œβ”€β”€ fr.json └── ar.json + β”œβ”€β”€ en.yml + β”œβ”€β”€ fr.yml + └── ar.yml ``` ### πŸ“„ Explanation of Structure diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index b99152fb..a835f116 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -46,6 +46,16 @@ export default defineNuxtConfig({ }) ``` +### YAML Support + +In addition to JSON, `nuxt-i18n-micro` also supports YAML files for translations. To use YAML, you need to install the `js-yaml` package as a dev dependency: + +```bash +npm install -D js-yaml +``` + +After installing `js-yaml`, you can create YAML files (with `.yaml` or `.yml` extensions) in your `translationDir` alongside your JSON files. + ### πŸ“‚ Folder Structure Translation files are organized into global and page-specific directories: diff --git a/docs/guide/performance.md b/docs/guide/performance.md index b3907b7a..1a1ed02b 100644 --- a/docs/guide/performance.md +++ b/docs/guide/performance.md @@ -87,4 +87,8 @@ Here are a few tips to ensure you get the best performance out of `Nuxt I18n Mic - πŸ’Ύ **Enable Caching**: Make use of the caching features to reduce server load and improve response times. - 🏁 **Leverage Pre-rendering**: Pre-render your translations to speed up page loads and reduce runtime overhead. +### πŸ“„ Translation Format + +While `nuxt-i18n-micro` supports both JSON and YAML translation files, **JSON is recommended for optimal performance**. JSON parsing is generally faster and more efficient than YAML parsing. If performance is a priority, use JSON for your translations. + For detailed results of the performance tests, please refer to the [Performance Test Results](/guide/performance-results). diff --git a/docs/index.md b/docs/index.md index e5f04b5f..8dfd2c5d 100755 --- a/docs/index.md +++ b/docs/index.md @@ -85,7 +85,7 @@ These results clearly demonstrate that `Nuxt I18n Micro` significantly outperfor - ⚑ **Optimized Build and Runtime**: Reduces build times, memory usage, and server load, making it ideal for **high-traffic applications**. - πŸ› οΈ **Minimalist Design**: The module is structured around just **5 components** (1 module and 4 plugins), making it easy to understand, extend, and maintain. - πŸ“ **Efficient Routing**: Generates only **2 routes** regardless of the number of locales, thanks to dynamic regex-based routing, unlike other i18n modules that generate separate routes for each locale. -- πŸ—‚ **Streamlined Translation Loading**: Only **JSON files** are supported, with translations split between a global file for common texts (e.g., menus) and page-specific files, which are auto-generated in the `dev` mode if not present. +- πŸ—‚ **Streamlined Translation Loading**: **JSON** and **YAML** files are supported, with translations split between a global file for common texts (e.g., menus) and page-specific files, which are auto-generated in the `dev` mode if not present. See the [getting started guide](/guide/getting-started) for details on YAML support and the required `js-yaml` dependency. ## βš™οΈ Quick Setup