Skip to content
Draft
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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"devDependencies": {
"@kazupon/eslint-config": "^0.22.0",
"@kazupon/prettier-config": "^0.1.1",
"@types/debug": "^4.1.12",
"@types/node": "^20.14.5",
"bumpp": "^10.0.3",
"eslint": "^9.22.0",
Expand Down
5 changes: 3 additions & 2 deletions packages/bundle-utils/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@
// "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
// "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
// "typeRoots": [], /* List of folders to include type definitions from. */
// "types": [], /* Type declaration files to be included in compilation. */
"types": ["vitest/globals"], /* Type declaration files to be included in compilation. */
// "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
"esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
// "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
Expand All @@ -68,6 +68,7 @@
// "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */
},
"include": [
"src/**/*"
"src/**/*",
"test"
]
}
11 changes: 10 additions & 1 deletion packages/unplugin-vue-i18n/build.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,16 @@ export default defineBuildConfig({
rollup: {
emitCJS: true
},
externals: ['vite', 'webpack', '@rspack/core'],
externals: [
'ufo',
'vite',
'webpack',
'@rspack/core',
'magic-string',
'estree-walker',
'@vue/compiler-sfc',
'@jridgewell/sourcemap-codec'
],
hooks: {
'build:done': async () => {
await Promise.all([
Expand Down
23 changes: 13 additions & 10 deletions packages/unplugin-vue-i18n/package.json
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FYI:
We've just managed the some deps with pnpm catalogs on pnpm-workspace.yaml

catalogs:
default:
'@types/node': ^22.13.11
unbuild: ^3.5.0
intlify:
'@intlify/shared': next
vue-i18n: next
vite:
'@vitejs/plugin-vue': ^5.2.3
vite: ^6.2.2
vue:
vue: ^3.5.13
webpack:
ts-loader: ^9.5.2
vue-loader: ^16.8.3
webpack: ^5.92.0

We can use pnpm catalogs to manage the packages in our workspace centrally.
We can also group together several packages into named catalogs.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if you are using vscode, I recommend you would use pnpm catalog lens.
https://github.com/antfu/vscode-pnpm-catalog-lens

You can see versions :)

Original file line number Diff line number Diff line change
Expand Up @@ -34,24 +34,27 @@
"mlly": "^1.2.0",
"picocolors": "^1.0.0",
"unplugin": "^2.2.0",
"vite": "^6.2.2",
"vue": "^3.4"
},
"devDependencies": {
"@babel/preset-typescript": "^7.26.0",
"@rspack/core": "^1.2.8",
"@types/estree": "^1.0.0",
"@types/node": "^20.14.8",
"@types/jsdom": "^16.2.5",
"@types/memory-fs": "^0.3.2",
"@types/debug": "^4.1.5",
"@rspack/core": "^1.2.7",
"@intlify/core-base": "next",
"vite": "^6.2.2",
"@vitejs/plugin-vue": "^5.2.3",
"jsdom": "^25.0.1",
"@vue/compiler-sfc": "^3.5.13",
"estree-walker": "^2.0.2",
"jsdom": "^26.0.0",
"magic-string": "^0.30.17",
"memory-fs": "^0.5.0",
"vue-loader": "^16.3.0",
"rollup": "^4.36.0",
"ts-loader": "^9.5.2",
"ufo": "^1.5.4",
"unbuild": "^2.0.0",
"webpack": "^5.88.2",
"webpack-merge": "^5.9.0"
"vue-loader": "^16.3.0",
"webpack": "^5.92.0",
"webpack-merge": "^6.0.1"
},
"engines": {
"node": ">= 20"
Expand Down
293 changes: 293 additions & 0 deletions packages/unplugin-vue-i18n/src/core/auto-declare.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,293 @@
/**
* This code is adapted from the composable keys transformation in Nuxt:
* - original code url: https://github.com/nuxt/nuxt/blob/a80d1a0d6349bf1003666fc79a513c0d7370c931/packages/vite/src/plugins/composable-keys.ts
* - author: Nuxt Framework Team
* - license: MIT
*/

import { parse as parseSFC } from '@vue/compiler-sfc'
import createDebug from 'debug'
import type { CallExpression, Pattern, Program } from 'estree'
import { walk } from 'estree-walker'
import MagicString from 'magic-string'
import { pathToFileURL } from 'node:url'
import { parseQuery, parseURL } from 'ufo'
import { UnpluginOptions } from 'unplugin'
import { resolveNamespace } from '../utils'
const debug = createDebug(resolveNamespace('auto-declare'))

const TRANSLATION_FUNCTIONS = ['$t', '$rt', '$d', '$n', '$tm', '$te']
const TRANSLATION_FUNCTIONS_RE = /\$(t|rt|d|n|tm|te)\s*\(\s*/
const TRANSLATION_FUNCTIONS_MAP: Record<(typeof TRANSLATION_FUNCTIONS)[number], string> = {
$t: 't: $t',
$rt: 'rt: $rt',
$d: 'd: $d',
$n: 'n: $n',
$tm: 'tm: $tm',
$te: 'te: $te'
}

// from https://github.com/nuxt/nuxt/blob/a80d1a0d6349bf1003666fc79a513c0d7370c931/packages/nuxt/src/core/utils/plugins.ts#L4-L35
function isVue(id: string, opts: { type?: Array<'template' | 'script' | 'style'> } = {}) {
// Bare `.vue` file (in Vite)
const { search } = parseURL(decodeURIComponent(pathToFileURL(id).href))
if (id.endsWith('.vue') && !search) {
return true
}

if (!search) {
return false
}

const query = parseQuery(search)

// Component async/lazy wrapper
if (query.nuxt_component) {
return false
}

// Macro
if (query.macro && (search === '?macro=true' || !opts.type || opts.type.includes('script'))) {
return true
}

// Non-Vue or Styles
const type = 'setup' in query ? 'script' : (query.type as 'script' | 'template' | 'style')
if (!('vue' in query) || (opts.type && !opts.type.includes(type))) {
return false
}

// Query `?vue&type=template` (in Webpack or external template)
return true
}

export function autoDeclarePlugin(options: { sourcemap: boolean }): UnpluginOptions {
return {
name: resolveNamespace('auto-declare'),
enforce: 'pre',

transformInclude(id) {
return isVue(id, { type: ['script'] })
},

transform(code, id) {
debug('transform', id)

// only transform if translation functions are present
const script = extractScriptContent(code)
if (!script || !TRANSLATION_FUNCTIONS_RE.test(script)) {
return
}

// only transform <script setup> and if translation functions are present
const scriptSetup = parseSFC(code, { sourceMap: false }).descriptor.scriptSetup
if (!scriptSetup) {
return
}

// strip types and typescript specific features for ast parsing
// const ast = parseSync(id, script, { lang: 'tsx' })
const ast = this.parse(script, {
// ecmaVersion: 'latest',
sourceType: 'module',
sourceFile: id,
allowImportExportEverywhere: true
})
console.log(ast)

// collect variable and function declarations with scope info.
let scopeTracker = new ScopeTracker()
const varCollector = new ScopedVarsCollector()
walk(ast, {
enter(_node) {
if (_node.type === 'BlockStatement') {
scopeTracker.enterScope()
varCollector.refresh(scopeTracker.curScopeKey)
// @ts-expect-error type mismatch
} else if (_node.type === 'FunctionDeclaration' && _node.id) {
// @ts-expect-error type mismatch
varCollector.addVar(_node.id.name)
} else if (_node.type === 'VariableDeclarator') {
// @ts-expect-error type mismatch
varCollector.collect(_node.id)
}
},
leave(_node) {
if (_node.type === 'BlockStatement') {
scopeTracker.leaveScope()
varCollector.refresh(scopeTracker.curScopeKey)
}
}
})

const missingFunctionDeclarators = new Set<string>()
scopeTracker = new ScopeTracker()
walk(ast as unknown as Program, {
enter(_node) {
if (_node.type === 'BlockStatement') {
scopeTracker.enterScope()
}

if (
_node.type !== 'CallExpression' ||
(_node as CallExpression).callee.type !== 'Identifier'
) {
return
}

const node: CallExpression = _node as CallExpression
const name = 'name' in node.callee && node.callee.name

if (!name || !TRANSLATION_FUNCTIONS.includes(name)) {
return
}

// check if function is used without having been declared
if (varCollector.hasVar(scopeTracker.curScopeKey, name)) {
return
}

missingFunctionDeclarators.add(name)
},
leave(_node) {
if (_node.type === 'BlockStatement') {
scopeTracker.leaveScope()
}
}
})

const s = new MagicString(code)
if (missingFunctionDeclarators.size > 0) {
debug(`injecting ${Array.from(missingFunctionDeclarators).join(', ')} declaration to ${id}`)

// only add variables when used without having been declared
const assignments: string[] = []
for (const missing of missingFunctionDeclarators) {
assignments.push(TRANSLATION_FUNCTIONS_MAP[missing])
}

// add variable declaration at the start of <script>, `autoImports` does the rest
s.overwrite(
scriptSetup.loc.start.offset,
scriptSetup.loc.end.offset,
`\nconst { ${assignments.join(', ')} } = useI18n()\n` + scriptSetup.content
)
}

if (s.hasChanged()) {
debug('transformed: id -> ', id)
debug('transformed: code -> ', s.toString())
return {
code: s.toString(),
map: options.sourcemap ? s.generateMap({ hires: true }) : undefined
}
}
}
}
}

// from https://github.com/nuxt/nuxt/blob/a80d1a0d6349bf1003666fc79a513c0d7370c931/packages/nuxt/src/pages/utils.ts#L138-L147
const SFC_SCRIPT_RE = /<script\s*[^>]*>([\s\S]*?)<\/script\s*[^>]*>/i
function extractScriptContent(html: string) {
const match = html.match(SFC_SCRIPT_RE)

if (match && match[1]) {
return match[1].trim()
}

return null
}

// from https://github.com/nuxt/nuxt/blob/a80d1a0d6349bf1003666fc79a513c0d7370c931/packages/vite/src/plugins/composable-keys.ts#L148-L184
/*
* track scopes with unique keys. for example
* ```js
* // root scope, marked as ''
* function a () { // '0'
* function b () {} // '0-0'
* function c () {} // '0-1'
* }
* function d () {} // '1'
* // ''
* ```
* */
class ScopeTracker {
// the top of the stack is not a part of current key, it is used for next level
scopeIndexStack: number[]
curScopeKey: string

constructor() {
this.scopeIndexStack = [0]
this.curScopeKey = ''
}

getKey() {
return this.scopeIndexStack.slice(0, -1).join('-')
}

enterScope() {
this.scopeIndexStack.push(0)
this.curScopeKey = this.getKey()
}

leaveScope() {
this.scopeIndexStack.pop()
this.curScopeKey = this.getKey()
this.scopeIndexStack[this.scopeIndexStack.length - 1]++
}
}

// from https://github.com/nuxt/nuxt/blob/a80d1a0d6349bf1003666fc79a513c0d7370c931/packages/vite/src/plugins/composable-keys.ts#L186-L238
class ScopedVarsCollector {
curScopeKey: string
all: Map<string, Set<string>>

constructor() {
this.all = new Map()
this.curScopeKey = ''
}

refresh(scopeKey: string) {
this.curScopeKey = scopeKey
}

addVar(name: string) {
let vars = this.all.get(this.curScopeKey)
if (!vars) {
vars = new Set()
this.all.set(this.curScopeKey, vars)
}
vars.add(name)
}

hasVar(scopeKey: string, name: string) {
const indices = scopeKey.split('-').map(Number)
for (let i = indices.length; i >= 0; i--) {
if (this.all.get(indices.slice(0, i).join('-'))?.has(name)) {
return true
}
}
return false
}

collect(n: Pattern) {
const t = n.type
if (t === 'Identifier') {
this.addVar(n.name)
} else if (t === 'RestElement') {
this.collect(n.argument)
} else if (t === 'AssignmentPattern') {
this.collect(n.left)
} else if (t === 'ArrayPattern') {
n.elements.forEach(e => e && this.collect(e))
} else if (t === 'ObjectPattern') {
n.properties.forEach(p => {
if (p.type === 'RestElement') {
this.collect(p)
} else {
this.collect(p.value)
}
})
}
}
}
2 changes: 2 additions & 0 deletions packages/unplugin-vue-i18n/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import createDebug from 'debug'
import { createUnplugin } from 'unplugin'
import { resolveOptions, resourcePlugin } from './core'
import { autoDeclarePlugin } from './core/auto-declare'
import { raiseError, resolveNamespace } from './utils'

import type { UnpluginFactory, UnpluginInstance } from 'unplugin'
Expand All @@ -22,6 +23,7 @@ export const unpluginFactory: UnpluginFactory<PluginOptions | undefined> = (opti
debug('plugin options (resolved):', resolvedOptions)

const plugins = [resourcePlugin(resolvedOptions, meta)]
plugins.push(autoDeclarePlugin({ sourcemap: true }))

return plugins
}
Expand Down
Loading
Loading