diff --git a/.eslintignore b/.eslintignore index 475f73b29..e60165ed7 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,5 +1,6 @@ dist playground specs/fixtures +test/fixtures coverage docs/components/content/Logo.vue \ No newline at end of file diff --git a/TODO.md b/TODO.md index ece7e89c1..eee1e4946 100644 --- a/TODO.md +++ b/TODO.md @@ -14,8 +14,8 @@ This todo is based on [nuxt/i18n](https://i18n.nuxtjs.org/) docs. - [x] prefix - [x] prefix_and_default - [x] Configurations -- [ ] Custom route paths - - [ ] In-component options +- [x] Custom route paths + - [x] In-component options - [x] Module's configuration - [ ] Ignoring localized routes - [ ] Pick localized routes @@ -83,6 +83,10 @@ This todo is based on [nuxt/i18n](https://i18n.nuxtjs.org/) docs. - [x] useLocaleHead (same `$nuxtI18nHead` ) - [x] useBrowserLocale (same `getBrowserLocale`) +### Compiler Macros (NWE!) + +- [x] defineI18nRoute (same `nuxtI18n` component options) + ### Extension of Vuex - [ ] $i18n diff --git a/build.config.ts b/build.config.ts index acd530519..2786e70e2 100644 --- a/build.config.ts +++ b/build.config.ts @@ -1,5 +1,5 @@ import { defineBuildConfig } from 'unbuild' export default defineBuildConfig({ - externals: ['node:fs', '@intlify/vue-i18n-bridge', 'webpack'] + externals: ['node:fs', 'node:url', '@intlify/vue-i18n-bridge', 'webpack'] }) diff --git a/docs/content/30.guide/4.custom-paths.md b/docs/content/30.guide/4.custom-paths.md index 1e06442ad..3b549ff5c 100644 --- a/docs/content/30.guide/4.custom-paths.md +++ b/docs/content/30.guide/4.custom-paths.md @@ -3,31 +3,49 @@ title: Custom route paths description: 'Customize the names of the paths for specific locale.' --- -::alert{type="warning"} -// TODO: -🚧 This feature is not implemented **fully** yet. -:: - -In some cases, you might want to translate URLs in addition to having them prefixed with the locale code. There are 2 ways of configuring custom paths for your pages: [in-component options](#in-component-options) or via the [module's configuration](#modules-configuration). +In some cases, you might want to translate URLs in addition to having them prefixed with the locale code. There are 2 ways of configuring custom paths for your pages: [Compiler macro](#compiler-macro) or via the [Nuxt configuration](#nuxt-configuration). ::alert{type="warning"} Custom paths are not supported when using the `no-prefix` [strategy](/strategies). :: -### In-component options +### Compiler macro -::alert{type="warning"} -// TODO: -🚧 This feature is not implemented yet. +You can use the `defineI18nRoute` compiler macro to set some custom paths for each page component. + +```html {}[pages/about.vue] + +``` + +To configure a custom path for a dynamic route, you need to put the params in the URI similarly to how you would do it in vue-router. + +```html {}[pages/articles/[name].vue] + +``` + +## Type + +```ts +defineI18nRoute(route: I18nRoute) => void + +interface I18nRoute { + paths?: Record +} +``` + +## Parameters + +### `paths` + +- **Type**: `I18nRoute` + + An object accepting the following i18n route settings: + + **`paths`** + + - **Type**: `Record` + + Customize page component routes per locale. You can specify static and dynamic paths for vue-router. diff --git a/docs/content/50.API/2.vue-i18n.md b/docs/content/50.API/3.vue-i18n.md similarity index 100% rename from docs/content/50.API/2.vue-i18n.md rename to docs/content/50.API/3.vue-i18n.md diff --git a/docs/content/50.API/3.vue.md b/docs/content/50.API/4.vue.md similarity index 100% rename from docs/content/50.API/3.vue.md rename to docs/content/50.API/4.vue.md diff --git a/docs/content/50.API/4.pinia.md b/docs/content/50.API/6.pinia.md similarity index 100% rename from docs/content/50.API/4.pinia.md rename to docs/content/50.API/6.pinia.md diff --git a/docs/content/50.API/6.nuxt.md b/docs/content/50.API/7.nuxt.md similarity index 100% rename from docs/content/50.API/6.nuxt.md rename to docs/content/50.API/7.nuxt.md diff --git a/package.json b/package.json index 38c5f9fb4..d43212339 100644 --- a/package.json +++ b/package.json @@ -76,9 +76,11 @@ "is-https": "^4.0.0", "js-cookie": "^3.0.1", "knitwork": "^0.1.2", + "magic-string": "^0.26.5", "mlly": "^0.5.4", "pathe": "^0.3.2", "ufo": "^0.8.5", + "unplugin": "^0.9.6", "vue-i18n": "^9.3.0-beta.6", "vue-i18n-routing": "^0.6.0" }, diff --git a/playground/nuxt.config.ts b/playground/nuxt.config.ts index b2e7f9b3f..810b05819 100644 --- a/playground/nuxt.config.ts +++ b/playground/nuxt.config.ts @@ -45,20 +45,21 @@ export default defineNuxtConfig({ // strategy: 'no_prefix', // strategy: 'prefix', // strategy: 'prefix_and_default', + parsePages: true, pages: { about: { ja: '/about-ja' } }, - differentDomains: true, - // detectBrowserLanguage: false, - detectBrowserLanguage: { - // alwaysRedirect: true, - useCookie: false - // cookieKey: 'i18n_redirected', - // cookieKey: 'my_custom_cookie_name', - // redirectOn: 'root' - }, + // differentDomains: true, + detectBrowserLanguage: false, + // detectBrowserLanguage: { + // // alwaysRedirect: true, + // useCookie: false + // // cookieKey: 'i18n_redirected', + // // cookieKey: 'my_custom_cookie_name', + // // redirectOn: 'root' + // }, onBeforeLanguageSwitch: (oldLocale, newLocale, initial, context) => { console.log('onBeforeLanguageSwitch', oldLocale, newLocale, initial) }, diff --git a/playground/pages/about/index.vue b/playground/pages/about/index.vue index 4318dc9bd..422afbeb1 100644 --- a/playground/pages/about/index.vue +++ b/playground/pages/about/index.vue @@ -23,17 +23,21 @@ export default defineComponent({ useHead({ meta: [{ property: 'og:title', content: 'this is og title' }] }) + defineI18nRoute({ + paths: { + en: '/about-us', + fr: '/a-propos', + ja: '/about-ja' + } + }) + definePageMeta({ + title: 'pages.title.about' + }) return {} } }) - - diff --git a/src/bundler.ts b/src/bundler.ts index d0d9046e1..700f2ec5f 100644 --- a/src/bundler.ts +++ b/src/bundler.ts @@ -3,6 +3,7 @@ import { resolve } from 'pathe' import { extendWebpackConfig, extendViteConfig, addWebpackPlugin, addVitePlugin } from '@nuxt/kit' import VueI18nWebpackPlugin from '@intlify/unplugin-vue-i18n/webpack' import VueI18nVitePlugin from '@intlify/unplugin-vue-i18n/vite' +import { TransformMacroPlugin, TransformMacroPluginOptions } from './macros' import type { Nuxt } from '@nuxt/schema' import type { NuxtI18nOptions } from './types' @@ -25,6 +26,15 @@ export async function extendBundler( } debug('nitro.replace', nuxt.options.nitro.replace) + // extract macros from components + const macroOptions: TransformMacroPluginOptions = { + dev: nuxt.options.dev, + sourcemap: nuxt.options.sourcemap.server || nuxt.options.sourcemap.client, + macros: { + defineI18nRoute: 'i18n' + } + } + try { // @ts-ignore NOTE: use webpack which is installed by nuxt const webpack = await import('webpack').then(m => m.default || m) @@ -39,6 +49,8 @@ export async function extendBundler( ) } + addWebpackPlugin(TransformMacroPlugin.webpack(macroOptions)) + extendWebpackConfig(config => { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- `config.plugins` is safe, so it's assigned with nuxt! config.plugins!.push( @@ -64,6 +76,8 @@ export async function extendBundler( ) } + addVitePlugin(TransformMacroPlugin.vite(macroOptions)) + extendViteConfig(config => { if (config.define) { config.define['__DEBUG__'] = JSON.stringify(options.debug) diff --git a/src/macros.ts b/src/macros.ts new file mode 100644 index 000000000..ca2ec72e8 --- /dev/null +++ b/src/macros.ts @@ -0,0 +1,67 @@ +/** + * This unplugin is compiler macro transform for `defineI18nRoute` + * This code is forked from the below: + * - original code url: https://github.com/nuxt/framework/blob/e2212ee106500acfd51e9e501428d7ef718364c2/packages/nuxt/src/pages/macros.ts + * - author: Nuxt Framework Team + * - license: MIT + */ + +import createDebug from 'debug' +import { pathToFileURL } from 'node:url' +import { createUnplugin } from 'unplugin' +import { parseQuery, parseURL } from 'ufo' +import MagicString from 'magic-string' + +export interface TransformMacroPluginOptions { + dev?: boolean + sourcemap?: boolean +} + +const debug = createDebug('@nuxtjs/i18n:macros') + +export const TransformMacroPlugin = createUnplugin((options: TransformMacroPluginOptions) => { + return { + name: 'nuxtjs:i18n-macros-transform', + enforce: 'post', + + transformInclude(id) { + if (!id || id.startsWith('\x00')) { + return false + } + const { pathname, search } = parseURL(decodeURIComponent(pathToFileURL(id).href)) + return pathname.endsWith('.vue') || !!parseQuery(search).macro + }, + + transform(code, id) { + debug('transform', id) + + const s = new MagicString(code) + const { search } = parseURL(decodeURIComponent(pathToFileURL(id).href)) + + function result() { + if (s.hasChanged()) { + debug('transformed: id -> ', id) + debug('transformed: code -> ', s.toString()) + return { + code: s.toString(), + map: options.sourcemap ? s.generateMap({ source: id, includeContent: true }) : undefined + } + } + } + + // tree-shake out any runtime references to the macro. + // we do this first as it applies to all files, not just those with the query + const match = code.match(new RegExp(`\\b${'defineI18nRoute'}\\s*\\(\\s*`)) + if (match?.[0]) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + s.overwrite(match.index!, match.index! + match[0].length, `/*#__PURE__*/ false && ${match[0]}`) + } + + if (!parseQuery(search).macro) { + return result() + } + + return result() + } + } +}) diff --git a/src/module.ts b/src/module.ts index 0bb3503e2..b8ff872b0 100644 --- a/src/module.ts +++ b/src/module.ts @@ -142,7 +142,8 @@ export default defineNuxtModule({ 'useSwitchLocalePath', 'useLocaleHead', 'useBrowserLocale', - 'useCookieLocale' + 'useCookieLocale', + 'defineI18nRoute' ].map(key => ({ name: key, as: key, diff --git a/src/pages.ts b/src/pages.ts index a112fe46a..8f5cb9556 100644 --- a/src/pages.ts +++ b/src/pages.ts @@ -57,9 +57,10 @@ export function getRouteOptionsResolver( const { pages, defaultLocale, parsePages } = options debug('parsePages on getRouteOptionsResolver', parsePages) return (route, localeCodes): ComputedRouteOptions | null => { - const ret = getRouteOptionsFromPages(pagesDir, route, localeCodes, pages, defaultLocale) - const com = getRouteOptionsFromComponent(route) - debug('getRouteOptionsFromComponent', com) + const ret = !parsePages + ? getRouteOptionsFromPages(pagesDir, route, localeCodes, pages, defaultLocale) + : getRouteOptionsFromComponent(route, localeCodes) + debug('getRouteOptionsResolver resolved', route.path, route.name, ret) return ret } } @@ -114,9 +115,10 @@ function getRouteOptionsFromPages( return options } -function getRouteOptionsFromComponent(route: I18nRoute) { +function getRouteOptionsFromComponent(route: I18nRoute, localeCodes: string[]) { + debug('getRouteOptionsFromComponent', route) const options: ComputedRouteOptions = { - locales: [], + locales: localeCodes, paths: {} } @@ -125,20 +127,52 @@ function getRouteOptionsFromComponent(route: I18nRoute) { return null } - const contents = readComponent(file) - if (contents == null) { - return null + const componentOptions = readComponent(file) + if (componentOptions != null) { + options.paths = componentOptions.paths } return options } function readComponent(target: string) { - let contents: string | null = null + let options: ComputedRouteOptions | null = null try { - contents = fs.readFileSync(target, 'utf8').toString() + const content = fs.readFileSync(target, 'utf8').toString() + const { 0: match, index = 0 } = + content.match(new RegExp(`\\b${'defineI18nRoute'}\\s*\\(\\s*`)) || ({} as RegExpMatchArray) + const macroContent = match ? extractObject(content.slice(index + match.length)) : 'undefined' + options = new Function(`return (${macroContent})`)() } catch (e: unknown) { console.warn(formatMessage(`Couldn't read component data at ${target}: (${(e as Error).message})`)) } - return contents + return options +} + +const starts = { + '{': '}', + '[': ']', + '(': ')', + '<': '>', + '"': '"', + "'": "'" +} +const QUOTE_RE = /["']/ + +function extractObject(code: string) { + // Strip comments + code = code.replace(/^\s*\/\/.*$/gm, '') + + const stack: string[] = [] + let result = '' + do { + if (stack[0] === code[0] && result.slice(-1) !== '\\') { + stack.shift() + } else if (code[0] in starts && !QUOTE_RE.test(stack[0])) { + stack.unshift(starts[code[0] as keyof typeof starts]) + } + result += code[0] + code = code.slice(1) + } while (stack.length && code.length) + return result } diff --git a/src/runtime/composables.ts b/src/runtime/composables.ts index 36b99b2b6..b3ee89ed6 100644 --- a/src/runtime/composables.ts +++ b/src/runtime/composables.ts @@ -17,6 +17,7 @@ import type { DetectBrowserLanguageOptions } from '#build/i18n.options.mjs' export * from '@intlify/vue-i18n-bridge' export type { LocaleObject } from 'vue-i18n-routing' +import type { Locale } from '@intlify/vue-i18n-bridge' export function useRouteBaseName( route: NonNullable[0]> = useRoute() @@ -121,3 +122,35 @@ export function useCookieLocale({ return locale } + +const warnRuntimeUsage = (method: string) => + console.warn( + method + + '() is a compiler-hint helper that is only usable inside ' + + 'the script block of a single file component. Its arguments should be ' + + 'compiled away and passing it at runtime has no effect.' + ) + +/** + * The i18n custom route for page components + */ +export interface I18nRoute { + /** + * Customize page component routes per locale. + * + * @description You can specify static and dynamic paths for vue-router. + */ + paths?: Record +} + +/** + * Deinfe custom route for page component + * + * @param route - The custou route + */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export function defineI18nRoute(route: I18nRoute): void { + if (process.dev) { + warnRuntimeUsage('defineI18nRoute') + } +} diff --git a/test/__snapshots__/pages.test.ts.snap b/test/__snapshots__/pages.test.ts.snap index c24dabb1a..f0ee30809 100644 --- a/test/__snapshots__/pages.test.ts.snap +++ b/test/__snapshots__/pages.test.ts.snap @@ -59,6 +59,60 @@ exports[`basic 1`] = ` ] `; +exports[`custom route > component > dynami route 1`] = ` +[ + { + "children": [], + "name": "articles-name___en", + "path": "/articles/:name", + }, + { + "children": [], + "name": "articles-name___ja", + "path": "/ja/記事/:name", + }, + { + "children": [], + "name": "articles-name___fr", + "path": "/fr/articles/:name", + }, +] +`; + +exports[`custom route > component > script-setup 1`] = ` +[ + { + "children": [], + "path": "/about-us", + }, + { + "children": [], + "path": "/ja/about-ja", + }, + { + "children": [], + "path": "/fr/a-propos", + }, +] +`; + +exports[`custom route > component > simple 1`] = ` +[ + { + "children": [], + "path": "/about-us", + }, + { + "children": [], + "path": "/ja/about-ja", + }, + { + "children": [], + "path": "/fr/a-propos", + }, +] +`; + exports[`custom route > configuration > nested complex route 1`] = ` [ { diff --git a/test/fixtures/dynamic/pages/articles/[name].vue b/test/fixtures/dynamic/pages/articles/[name].vue new file mode 100644 index 000000000..0132cd18e --- /dev/null +++ b/test/fixtures/dynamic/pages/articles/[name].vue @@ -0,0 +1,11 @@ + diff --git a/test/fixtures/script-setup/pages/about.vue b/test/fixtures/script-setup/pages/about.vue new file mode 100644 index 000000000..6051516d4 --- /dev/null +++ b/test/fixtures/script-setup/pages/about.vue @@ -0,0 +1,11 @@ + diff --git a/test/fixtures/simple/pages/about.vue b/test/fixtures/simple/pages/about.vue new file mode 100644 index 000000000..a5e640953 --- /dev/null +++ b/test/fixtures/simple/pages/about.vue @@ -0,0 +1,17 @@ + diff --git a/test/pages.test.ts b/test/pages.test.ts index fb671822b..d29aeccef 100644 --- a/test/pages.test.ts +++ b/test/pages.test.ts @@ -2,13 +2,18 @@ import { vi, describe, test, expect } from 'vitest' import { localizeRoutes } from 'vue-i18n-routing' import { getRouteOptionsResolver } from '../src/pages' import fs from 'node:fs' +import { fileURLToPath } from 'node:url' +import { resolve } from 'node:path' import type { NuxtI18nOptions } from '../src/types' +import { I18nRoute } from 'vue-i18n-routing' import type { NuxtHooks } from '@nuxt/schema' type ExtractArrayType = T extends (infer U)[] ? U : never type NuxtPage = ExtractArrayType[0]> +const __dirname = fileURLToPath(new URL('.', import.meta.url)) + function getNuxtOptions(pages: Required['pages'], parsePages = false): NuxtI18nOptions { return { parsePages, @@ -26,6 +31,16 @@ function getNuxtOptions(pages: Required['pages'], parsePages = } } +function stripFilePropertyFromPages(routes: I18nRoute[]) { + return routes.map(route => { + delete route.file + if (route.children) { + route.children = stripFilePropertyFromPages(route.children) + } + return route + }) +} + test('basic', async () => { vi.spyOn(fs, 'readFileSync').mockReturnValue('') @@ -217,7 +232,57 @@ describe('custom route', () => { }) }) - describe.todo('component', () => { - // TODO: + describe('component', () => { + test('simple', async () => { + const options = getNuxtOptions({}, true) + const pages: NuxtPage[] = [ + { + path: '/about', + file: resolve(__dirname, './fixtures/simple/pages/about.vue'), + children: [] + } + ] + const localizedPages = localizeRoutes(pages, { + ...options, + includeUprefixedFallback: false, + optionsResolver: getRouteOptionsResolver('pages', options as Required) + }) + expect(stripFilePropertyFromPages(localizedPages)).toMatchSnapshot() + }) + + test('script-setup', async () => { + const options = getNuxtOptions({}, true) + const pages: NuxtPage[] = [ + { + path: '/about', + file: resolve(__dirname, './fixtures/script-setup/pages/about.vue'), + children: [] + } + ] + const localizedPages = localizeRoutes(pages, { + ...options, + includeUprefixedFallback: false, + optionsResolver: getRouteOptionsResolver('pages', options as Required) + }) + expect(stripFilePropertyFromPages(localizedPages)).toMatchSnapshot() + }) + + test('dynami route', async () => { + const options = getNuxtOptions({}, true) + const pages: NuxtPage[] = [ + { + name: 'articles-name', + path: '/articles/:name', + file: resolve(__dirname, './fixtures/dynamic/pages/articles/[name].vue'), + children: [] + } + ] + const localizedPages = localizeRoutes(pages, { + ...options, + includeUprefixedFallback: false, + optionsResolver: getRouteOptionsResolver('pages', options as Required) + }) + expect(stripFilePropertyFromPages(localizedPages)).toMatchSnapshot() + }) }) }) diff --git a/vitest.config.ts b/vitest.config.ts index 33e75002c..1aee70d8c 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -4,6 +4,7 @@ export default defineConfig({ test: { globals: true, clearMocks: true, + restoreMocks: true, testTimeout: 300000, deps: { inline: [/@nuxt\/test-utils-edge/] diff --git a/yarn.lock b/yarn.lock index 815e613ad..777712894 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1305,6 +1305,7 @@ __metadata: jsdom: ^20.0.0 knitwork: ^0.1.2 lint-staged: ^12.1.2 + magic-string: ^0.26.5 mlly: ^0.5.4 npm-run-all: ^4.1.5 nuxt: ^3.0.0-rc.9 @@ -1315,6 +1316,7 @@ __metadata: ts-essentials: ^9.1.2 typescript: ^4.8.4 ufo: ^0.8.5 + unplugin: ^0.9.6 vitest: ^0.23.4 vue-i18n: ^9.3.0-beta.6 vue-i18n-routing: ^0.6.0 @@ -7377,6 +7379,15 @@ __metadata: languageName: node linkType: hard +"magic-string@npm:^0.26.5": + version: 0.26.5 + resolution: "magic-string@npm:0.26.5" + dependencies: + sourcemap-codec: ^1.4.8 + checksum: b09d989586b3f16924559bda75a2b12253c9874a0f2969749a672755d84e3cce13276bc3e6c6e5e9e740973caf446725a4cb102ed5c9a8f012497974c15f6d65 + languageName: node + linkType: hard + "make-dir@npm:^3.1.0, make-dir@npm:~3.1.0": version: 3.1.0 resolution: "make-dir@npm:3.1.0" @@ -12150,6 +12161,18 @@ __metadata: languageName: node linkType: hard +"unplugin@npm:^0.9.6": + version: 0.9.6 + resolution: "unplugin@npm:0.9.6" + dependencies: + acorn: ^8.8.0 + chokidar: ^3.5.3 + webpack-sources: ^3.2.3 + webpack-virtual-modules: ^0.4.5 + checksum: 6f544f7c53511e0b9064112353562159efd6fc0f38a78ff7e9ac5d3c09b298b59ea565165bbfab0eec08b016d50c5e453673581712bfcf875924d06e04bd77cd + languageName: node + linkType: hard + "unstorage@npm:^0.4.1": version: 0.4.2 resolution: "unstorage@npm:0.4.2" @@ -12833,6 +12856,13 @@ __metadata: languageName: node linkType: hard +"webpack-virtual-modules@npm:^0.4.5": + version: 0.4.5 + resolution: "webpack-virtual-modules@npm:0.4.5" + checksum: 0ae9a8b50d0cb1e43da5ff8acaa7b99c34a42f0d6cc83a82908fb6e131e574a949d19948df4fdd3de0dbfdbadb2b93ceb4a740c55727a4236eb3b2bbc8f785a6 + languageName: node + linkType: hard + "whatwg-encoding@npm:^2.0.0": version: 2.0.0 resolution: "whatwg-encoding@npm:2.0.0"