Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,10 @@ jobs:
matrix:
os: [ubuntu-latest]
node: [22, 24]
framework: ['vite', 'webpack']
include:
- { framework: 'vite', vite_type: 'vite8' }
- { framework: 'vite', vite_type: 'vite6' }
- { framework: 'webpack', vite_type: '' }

steps:
- name: Checkout
Expand Down Expand Up @@ -137,3 +140,4 @@ jobs:
run: pnpm test
env:
TEST_FRAMEWORK: ${{ matrix.framework }}
TEST_VITE_TYPE: ${{ matrix.vite_type }}
2 changes: 2 additions & 0 deletions eslint.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ export default defineConfig(
'examples/**',
'CHANGELOG.md',
'.unmaintained/**',
'.claude/**',
'.plans/**',
'packages/**/CHANGELOG.md',
'packages/unplugin-vue-i18n/README.md',
'**/*.md/*.ts',
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@
"test:unit:utils": "vitest run packages/bundle-utils",
"test:unit:unplugin": "vitest run packages/unplugin-vue-i18n/test",
"test:unit:unplugin:vite": "TEST_FRAMEWORK=vite vitest run packages/unplugin-vue-i18n/test",
"test:unit:unplugin:vite:vite8": "TEST_FRAMEWORK=vite TEST_VITE_TYPE=vite8 vitest run packages/unplugin-vue-i18n/test/vite",
"test:unit:unplugin:vite:vite6": "TEST_FRAMEWORK=vite TEST_VITE_TYPE=vite6 vitest run packages/unplugin-vue-i18n/test/vite",
"test:e2e": "pnpm check-install && vitest -c ./vitest.e2e.config.ts run",
"test:e2e:unplugin": "pnpm --filter @intlify/unplugin-vue-i18n test:e2e"
},
Expand Down
1 change: 1 addition & 0 deletions packages/bundle-utils/src/js.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import { isBoolean, isNumber, isString } from '@intlify/shared'
import { parse as parseJavaScript } from 'acorn'
import { generate as generateJavaScript } from 'escodegen'
// @ts-ignore -- estree-walker package.json `exports` field lacks a `types` subpath; works at runtime
import { walk } from 'estree-walker'
import {
createCodeGenerator,
Expand Down
2 changes: 1 addition & 1 deletion packages/unplugin-vue-i18n/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@
"source-map-js": "catalog:",
"ts-loader": "catalog:webpack",
"unbuild": "catalog:",
"vite": "catalog:vite",
"vite": "catalog:vite8",
"rollup-vite": "npm:vite@6.3.5",
"vue-loader": "catalog:webpack",
"webpack": "catalog:webpack",
Expand Down
194 changes: 181 additions & 13 deletions packages/unplugin-vue-i18n/src/core/resource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { createFilter } from '@rollup/pluginutils'
import createDebug from 'debug'
import fg from 'fast-glob'
import { promises as fs } from 'node:fs'
import { parse as parsePath } from 'node:path'
import { dirname, parse as parsePath, resolve } from 'pathe'
import { parse } from 'vue/compiler-sfc'
import { checkVuePlugin, error, getVitePlugin, raiseError, resolveNamespace, warn } from '../utils'
import { getVueCompiler, parseVueRequest } from '../vue'
Expand All @@ -23,13 +23,10 @@ import type { VueQuery } from '../vue'

const INTLIFY_BUNDLE_IMPORT_ID = '@intlify/unplugin-vue-i18n/messages'
const VIRTUAL_PREFIX = '\0'
const RE_INTLIFY_BUNDLE_IMPORT_ID = new RegExp(`^${INTLIFY_BUNDLE_IMPORT_ID}$`)
const RE_VIRTUAL_PREFIXED_INTLIFY_BUNDLE_IMPORT_ID = new RegExp(
`^${VIRTUAL_PREFIX}${INTLIFY_BUNDLE_IMPORT_ID}$`
)
const RE_RESOURCE_FORMAT = /\.(json5?|ya?ml|[c|m]?[j|t]s)$/
const RE_SFC_I18N_CUSTOM_BLOCK = /\?vue&type=i18n/
const RE_SFC_I18N_WEBPACK_CUSTOM_BLOCK = /blockType=i18n/
const VITE_VIRTUAL_PREFIX = '\0intlify-i18n-'

const debug = createDebug(resolveNamespace('resource'))

Expand Down Expand Up @@ -109,6 +106,39 @@ export function resourcePlugin(
return vuePlugin ? getVueCompiler(vuePlugin).parse : parse
}

/**
* Virtual id machinery for vite 8+ (rolldown-based) builds.
*
* vite 8 ships `builtin:vite-json` as a Rust plugin whose `transform` cannot
* be overridden from JS — replacing the JS-side `.transform` is silently
* ignored because rolldown invokes the native binding directly. Without
* intervention, our `enforce: 'pre'` transform converts JSON to JS and then
* `builtin:vite-json` runs anyway and fails to parse our JS as JSON.
*
* Workaround: in `resolveId`, remap each matching resource file to a
* virtual id like `\0intlify-i18n-N`. The virtual id has no `.json` /
* `.json5` suffix, so `builtin:vite-json` (which matches by extension) does
* not claim it. Our `load` reads the original file from disk, runs the
* generator, and returns the compiled JS.
*/
const virtualIdToRealPath = new Map<string, string>()
const realPathToVirtualId = new Map<string, string>()
let virtualCounter = 0

function intlifyVirtualize(realPath: string): string {
let virtualId = realPathToVirtualId.get(realPath)
if (!virtualId) {
virtualId = `${VITE_VIRTUAL_PREFIX}${virtualCounter++}`
virtualIdToRealPath.set(virtualId, realPath)
realPathToVirtualId.set(realPath, virtualId)
}
return virtualId
}

function isIntlifyVirtualId(id: string): boolean {
return virtualIdToRealPath.has(id)
}

return {
name: resolveNamespace('resource'),

Expand Down Expand Up @@ -275,18 +305,52 @@ export function resourcePlugin(
},

resolveId: {
filter: {
id: RE_INTLIFY_BUNDLE_IMPORT_ID
},
handler(id: string) {
return asVirtualId(id, meta.framework)
async handler(id: string, importer: string | undefined) {
if (id === INTLIFY_BUNDLE_IMPORT_ID) {
return asVirtualId(id, meta.framework)
}

// For vite 8+ (no `vite:json` plugin), virtualize matching resource files
// so `builtin:vite-json` does not claim them. See VITE_VIRTUAL_PREFIX above.
if (meta.framework === 'vite' && !hasViteJsonPlugin) {
// SFC custom-block requests with `lang.json` / `lang.json5` (e.g.
// `Foo.vue?vue&type=i18n&index=0&lang.json`) also end in `.json` and
// would be claimed by `builtin:vite-json`. Virtualize them too — our
// `load` re-parses the SFC and extracts the block content.
if (id.includes('?vue&type=i18n') && /[?&]lang\.(?:json|json5)(?:$|&)/.test(id)) {
return intlifyVirtualize(id)
}

const idPath = id.split('?')[0]
if (!RE_RESOURCE_FORMAT.test(idPath)) return

// unplugin v2.x does not expose `this.resolve` reliably across
// bundlers, so resolve relative and absolute paths manually. Bare
// specifiers (package imports, aliases) are left to vite's normal
// resolution.
let resolvedPath: string
if (idPath.startsWith('.')) {
const realImporter =
importer && isIntlifyVirtualId(importer)
? virtualIdToRealPath.get(importer)!
: importer
if (!realImporter) return
resolvedPath = resolve(dirname(realImporter), idPath)
} else if (idPath.startsWith('/') || /^[a-z]:[/\\]/i.test(idPath)) {
resolvedPath = idPath
} else {
return
}

const filter = await getFilter()
if (!filter(resolvedPath)) return

return intlifyVirtualize(resolvedPath)
}
}
},

load: {
filter: {
id: RE_VIRTUAL_PREFIXED_INTLIFY_BUNDLE_IMPORT_ID
},
async handler(id: string) {
debug('load', id)
if (INTLIFY_BUNDLE_IMPORT_ID === getVirtualId(id, meta.framework) && include) {
Expand All @@ -311,11 +375,115 @@ export function resourcePlugin(
map: { mappings: '' }
}
}

// vite 8+ virtualized resource — read the real file, run the i18n
// generator, and return pre-compiled JS so `builtin:vite-json` never sees it.
if (isIntlifyVirtualId(id)) {
const realId = virtualIdToRealPath.get(id)!

// SFC custom-block: parse the SFC and extract the block content
// (mirrors what vite-plugin-vue's load would do, but yields a
// virtual id whose extension does not trigger `builtin:vite-json`).
if (realId.includes('?vue&type=i18n')) {
const { filename, query } = parseVueRequest(realId)
this.addWatchFile(filename)
const sfcSource = await fs.readFile(filename, 'utf-8')
const { descriptor } = getSfcParser()(sfcSource, {
sourceMap,
filename
})
const block = descriptor.customBlocks[query.index!]
if (!block) return

let source = block.src ? await fs.readFile(block.src, 'utf-8') : block.content
if (typeof transformI18nBlock === 'function') {
const modifiedSource = transformI18nBlock(source)
if (modifiedSource && typeof modifiedSource === 'string') {
source = modifiedSource
} else {
warn('transformI18nBlock should return a string')
}
}

let langInfo = defaultSFCLang as Required<PluginOptions>['defaultSFCLang']
if (isString(query.lang)) {
langInfo = (
query.src ? (query.lang === 'i18n' ? defaultSFCLang : query.lang) : query.lang
) as Required<PluginOptions>['defaultSFCLang']
}

// For JS/TS custom blocks, wrap source with `export default`
if (/\.?[cm]?[jt]s$/.test(langInfo)) {
source = `export default ${source.replace(/^[\s;]+/, '')}`
}

const generate = getGenerator(langInfo, generateYAML)
const isGlobalBlock = globalSFCScope || !!query.global
const parseOptions = getOptions(
filename,
isProduction,
query as Record<string, unknown>,
sourceMap,
{
isGlobal: globalSFCScope,
allowDynamic,
jit: true,
strictMessage,
escapeHtml,
onlyLocales,
forceStringify,
usedKeyFilter:
collector && isGlobalBlock
? (keyPath: string) => collector.shouldKeepKey(keyPath)
: undefined
}
) as CodeGenOptions
const { code: generatedCode } = await generate(source, parseOptions)
return {
moduleType: 'js',
code: generatedCode,
map: { mappings: '' }
}
}

// Plain resource file
this.addWatchFile(realId)
const code = await fs.readFile(realId, 'utf-8')
const langInfo = parsePath(realId).ext as Required<PluginOptions>['defaultSFCLang']
const generate = getGenerator(langInfo)
const parseOptions = getOptions(
realId,
isProduction,
{} as Record<string, unknown>,
sourceMap,
{
isGlobal: globalSFCScope,
allowDynamic,
strictMessage,
escapeHtml,
jit: true,
onlyLocales,
forceStringify,
usedKeyFilter: collector
? (keyPath: string) => collector.shouldKeepKey(keyPath)
: undefined
}
) as CodeGenOptions
const { code: generatedCode } = await generate(code, parseOptions)
return {
moduleType: 'js',
code: generatedCode,
map: { mappings: '' }
}
}
}
},

transform: {
async handler(code: string, id: string) {
// Skip vite 8 virtualized resources — already loaded as compiled JS.
if (isIntlifyVirtualId(id)) return

const filter = await getFilter()
if (!filter(id)) {
return
Expand Down
23 changes: 13 additions & 10 deletions packages/unplugin-vue-i18n/test/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ import fg from 'fast-glob'
import { JSDOM, VirtualConsole } from 'jsdom'
import memoryfs from 'memory-fs'
import { resolve } from 'node:path'
import { build as buildRollupVite } from 'rollup-vite'
import { build as buildRolldownVite } from 'vite'
import { build as buildVite6 } from 'rollup-vite'
import { build as buildVite8 } from 'vite'
import { VueLoaderPlugin } from 'vue-loader'
import webpack from 'webpack'
import merge from 'webpack-merge'
Expand All @@ -23,23 +23,27 @@ type BundleResolve = {
stats?: webpack.Stats
}

type ViteBulderType = 'rollup' | 'rolldown'
type ViteBuilderType = 'vite6' | 'vite8'

type BundleFunction = (
fixture: string,
options: Record<string, unknown>,
viteType: ViteBulderType
viteType: ViteBuilderType
) => Promise<BundleResolve>

const VITE_BUILDERS: Record<ViteBulderType, typeof buildRollupVite> = {
rollup: buildRollupVite,
rolldown: buildRolldownVite
const VITE_BUILDERS: Record<ViteBuilderType, typeof buildVite8> = {
vite6: buildVite6 as unknown as typeof buildVite8,
vite8: buildVite8
}

function getCurrentViteType(): ViteBuilderType {
return (process.env.TEST_VITE_TYPE as ViteBuilderType) || 'vite8'
}

export async function bundleVite(
fixture: string,
options: Record<string, unknown> = {},
viteType: ViteBulderType = 'rolldown'
viteType: ViteBuilderType = getCurrentViteType()
): Promise<BundleResolve> {
const input = (options.input as string) || './fixtures/entry.ts'
const target = (options.target as string) || './fixtures'
Expand Down Expand Up @@ -187,8 +191,7 @@ export async function bundleAndRun(
options.escapeHtml = !!options.escapeHtml
options.optimizeTranslationDirective = !!options.optimizeTranslationDirective

const rolldownVersion = ((await import('vite')) as any).rolldownVersion
const { code, map } = await bundler(fixture, options, !!rolldownVersion ? 'rolldown' : 'rollup')
const { code, map } = await bundler(fixture, options, getCurrentViteType())

let dom: JSDOM | null = null
let jsdomError
Expand Down
Loading
Loading