Skip to content

Commit 9b9e76f

Browse files
authored
feat(unplugin-vue-i18n): support locale mesasges tree-shaking (#542)
1 parent 77658a3 commit 9b9e76f

22 files changed

Lines changed: 1307 additions & 20 deletions

packages/bundle-utils/src/codegen.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,11 @@ export interface CodeGenOptions {
5252
jit?: boolean
5353
minify?: boolean
5454
transformI18nBlock?: (source: string | Buffer) => string
55+
/**
56+
* When provided, only keys matching this filter will be included in output.
57+
* The function receives a dot-separated key path and returns true to keep.
58+
*/
59+
usedKeyFilter?: (keyPath: string) => boolean
5560
onWarn?: (msg: string) => void
5661
onError?: (
5762
msg: string,
@@ -417,6 +422,51 @@ export function generateResourceAst(
417422
return { code, ast, map, errors }
418423
}
419424

425+
export function filterMessageKeys(
426+
messages: Record<string, unknown>,
427+
shouldKeep: (keyPath: string) => boolean,
428+
parentPath: string[] = []
429+
): Record<string, unknown> {
430+
const result: Record<string, unknown> = {}
431+
for (const [key, value] of Object.entries(messages)) {
432+
const currentPath = [...parentPath, key]
433+
const dotPath = currentPath.join('.')
434+
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
435+
const filtered = filterMessageKeys(value as Record<string, unknown>, shouldKeep, currentPath)
436+
if (Object.keys(filtered).length > 0) {
437+
result[key] = filtered
438+
}
439+
} else {
440+
if (shouldKeep(dotPath)) {
441+
result[key] = value
442+
}
443+
}
444+
}
445+
return result
446+
}
447+
448+
export function filterMultiLocaleMessages(
449+
messages: Record<string, unknown>,
450+
shouldKeep: (keyPath: string) => boolean
451+
): Record<string, unknown> {
452+
const result: Record<string, unknown> = {}
453+
for (const [locale, localeMessages] of Object.entries(messages)) {
454+
if (
455+
typeof localeMessages === 'object' &&
456+
localeMessages !== null &&
457+
!Array.isArray(localeMessages)
458+
) {
459+
const filtered = filterMessageKeys(localeMessages as Record<string, unknown>, shouldKeep)
460+
if (Object.keys(filtered).length > 0) {
461+
result[locale] = filtered
462+
}
463+
} else {
464+
result[locale] = localeMessages
465+
}
466+
}
467+
return result
468+
}
469+
420470
export function excludeLocales({
421471
messages,
422472
onlyLocales

packages/bundle-utils/src/json.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import { getStaticJSONValue, parseJSON, traverseNodes } from 'jsonc-eslint-parse
77
import {
88
createCodeGenerator,
99
excludeLocales,
10+
filterMessageKeys,
11+
filterMultiLocaleMessages,
1012
generateMessageFunction,
1113
generateResourceAst,
1214
mapLinesColumns
@@ -31,7 +33,8 @@ export function generate(
3133
onError = undefined,
3234
strictMessage = true,
3335
escapeHtml = false,
34-
jit = false
36+
jit = false,
37+
usedKeyFilter = undefined
3538
}: CodeGenOptions
3639
): CodeGenResult<JSONProgram> {
3740
let value = Buffer.isBuffer(targetSource) ? targetSource.toString() : targetSource
@@ -76,6 +79,18 @@ export function generate(
7679
options.source = undefined
7780
}
7881

82+
// Tree-shaking: filter unused message keys
83+
if (usedKeyFilter) {
84+
const messages = getStaticJSONValue(ast) as Record<string, unknown>
85+
const isMultiLocale = !locale && type === 'sfc'
86+
const filtered = isMultiLocale
87+
? filterMultiLocaleMessages(messages, usedKeyFilter)
88+
: filterMessageKeys(messages, usedKeyFilter)
89+
value = JSON.stringify(filtered)
90+
ast = parseJSON(value, { filePath: filename })
91+
options.source = value
92+
}
93+
7994
const generator = createCodeGenerator(options)
8095
const codeMaps = _generate(generator, ast, options)
8196

packages/bundle-utils/src/yaml.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import { getStaticYAMLValue, parseYAML, traverseNodes } from 'yaml-eslint-parser
77
import {
88
createCodeGenerator,
99
excludeLocales,
10+
filterMessageKeys,
11+
filterMultiLocaleMessages,
1012
generateMessageFunction,
1113
generateResourceAst,
1214
mapLinesColumns
@@ -34,7 +36,8 @@ export function generate(
3436
onError = undefined,
3537
strictMessage = true,
3638
escapeHtml = false,
37-
jit = false
39+
jit = false,
40+
usedKeyFilter = undefined
3841
}: CodeGenOptions
3942
): CodeGenResult<YAMLProgram> {
4043
let value = Buffer.isBuffer(targetSource) ? targetSource.toString() : targetSource
@@ -76,6 +79,18 @@ export function generate(
7679
options.source = undefined
7780
}
7881

82+
// Tree-shaking: filter unused message keys
83+
if (usedKeyFilter) {
84+
const messages = getStaticYAMLValue(ast) as Record<string, unknown>
85+
const isMultiLocale = !locale && type === 'sfc'
86+
const filtered = isMultiLocale
87+
? filterMultiLocaleMessages(messages, usedKeyFilter)
88+
: filterMessageKeys(messages, usedKeyFilter)
89+
value = JSON.stringify(filtered)
90+
ast = parseYAML(value, { filePath: filename })
91+
options.source = value
92+
}
93+
7994
const generator = createCodeGenerator(options)
8095
const codeMaps = _generate(generator, ast, options)
8196

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import { filterMessageKeys, filterMultiLocaleMessages } from '../../src/codegen'
2+
import { generate as generateJSON } from '../../src/json'
3+
import { generate as generateYAML } from '../../src/yaml'
4+
5+
describe('filterMessageKeys', () => {
6+
test('flat keys: keeps used, removes unused', () => {
7+
const messages = { hello: 'hello!', goodbye: 'goodbye!', unused: 'unused!' }
8+
const usedKeys = new Set(['hello', 'goodbye'])
9+
const result = filterMessageKeys(messages, key => usedKeys.has(key))
10+
expect(result).toEqual({ hello: 'hello!', goodbye: 'goodbye!' })
11+
})
12+
13+
test('nested keys: keeps parent objects for used children', () => {
14+
const messages = {
15+
nav: { home: 'Home', about: 'About', contact: 'Contact' },
16+
footer: { copyright: '2024' }
17+
}
18+
const usedKeys = new Set(['nav.home'])
19+
const shouldKeep = (key: string) =>
20+
usedKeys.has(key) || [...usedKeys].some(k => k.startsWith(key + '.'))
21+
const result = filterMessageKeys(messages, shouldKeep)
22+
expect(result).toEqual({ nav: { home: 'Home' } })
23+
})
24+
25+
test('deeply nested keys', () => {
26+
const messages = {
27+
a: { b: { c: { d: 'deep' }, e: 'shallow' } }
28+
}
29+
const usedKeys = new Set(['a.b.c.d'])
30+
const shouldKeep = (key: string) =>
31+
usedKeys.has(key) || [...usedKeys].some(k => k.startsWith(key + '.'))
32+
const result = filterMessageKeys(messages, shouldKeep)
33+
expect(result).toEqual({ a: { b: { c: { d: 'deep' } } } })
34+
})
35+
36+
test('empty result when no keys match', () => {
37+
const messages = { hello: 'hello!' }
38+
const result = filterMessageKeys(messages, () => false)
39+
expect(result).toEqual({})
40+
})
41+
42+
test('all keys kept when all match', () => {
43+
const messages = { a: '1', b: '2' }
44+
const result = filterMessageKeys(messages, () => true)
45+
expect(result).toEqual(messages)
46+
})
47+
48+
test('array values preserved when key matches', () => {
49+
const messages = { errors: ['E001', 'E002'], unused: 'x' }
50+
const result = filterMessageKeys(messages, key => key === 'errors')
51+
expect(result).toEqual({ errors: ['E001', 'E002'] })
52+
})
53+
54+
test('sibling keys: keeps only matching siblings', () => {
55+
const messages = {
56+
nav: { home: 'Home', about: 'About' },
57+
auth: { login: 'Login', logout: 'Logout' }
58+
}
59+
const usedKeys = new Set(['nav.home', 'auth.login'])
60+
const shouldKeep = (key: string) =>
61+
usedKeys.has(key) || [...usedKeys].some(k => k.startsWith(key + '.'))
62+
const result = filterMessageKeys(messages, shouldKeep)
63+
expect(result).toEqual({
64+
nav: { home: 'Home' },
65+
auth: { login: 'Login' }
66+
})
67+
})
68+
})
69+
70+
describe('filterMultiLocaleMessages', () => {
71+
test('filters keys within each locale independently', () => {
72+
const messages = {
73+
en: { hello: 'Hello', unused: 'X' },
74+
ja: { hello: 'こんにちは', unused: 'Y' }
75+
}
76+
const result = filterMultiLocaleMessages(messages, key => key === 'hello')
77+
expect(result).toEqual({
78+
en: { hello: 'Hello' },
79+
ja: { hello: 'こんにちは' }
80+
})
81+
})
82+
83+
test('removes empty locale entries', () => {
84+
const messages = {
85+
en: { hello: 'Hello' },
86+
ja: { unused: 'Y' }
87+
}
88+
const result = filterMultiLocaleMessages(messages, key => key === 'hello')
89+
expect(result).toEqual({ en: { hello: 'Hello' } })
90+
})
91+
92+
test('preserves non-object locale values', () => {
93+
const messages = {
94+
en: { hello: 'Hello' },
95+
count: 42
96+
} as Record<string, unknown>
97+
const result = filterMultiLocaleMessages(messages, key => key === 'hello')
98+
expect(result).toEqual({ en: { hello: 'Hello' }, count: 42 })
99+
})
100+
})
101+
102+
describe('generateJSON with usedKeyFilter', () => {
103+
test('filters unused keys from output', () => {
104+
const source = JSON.stringify({
105+
hello: 'hello world!',
106+
unused: 'this should be removed',
107+
nested: { greeting: 'hi', farewell: 'bye' }
108+
})
109+
const { code } = generateJSON(source, {
110+
env: 'production',
111+
usedKeyFilter: key => key === 'hello' || key === 'nested.greeting' || key === 'nested' // parent needed
112+
})
113+
expect(code).toContain('hello')
114+
expect(code).toContain('greeting')
115+
expect(code).not.toContain('unused')
116+
expect(code).not.toContain('farewell')
117+
})
118+
119+
test('generates valid code when all keys filtered', () => {
120+
const source = JSON.stringify({ unused: 'remove me' })
121+
const { code } = generateJSON(source, {
122+
env: 'production',
123+
usedKeyFilter: () => false
124+
})
125+
// Empty object generates a resource with no keys
126+
expect(code).not.toContain('unused')
127+
expect(code).not.toContain('remove me')
128+
})
129+
})
130+
131+
describe('generateYAML with usedKeyFilter', () => {
132+
test('filters unused keys from output', () => {
133+
const source = `hello: hello world!
134+
unused: this should be removed
135+
nested:
136+
greeting: hi
137+
farewell: bye`
138+
const { code } = generateYAML(source, {
139+
env: 'production',
140+
usedKeyFilter: key => key === 'hello' || key === 'nested.greeting' || key === 'nested'
141+
})
142+
expect(code).toContain('hello')
143+
expect(code).toContain('greeting')
144+
expect(code).not.toContain('unused')
145+
expect(code).not.toContain('farewell')
146+
})
147+
})

packages/unplugin-vue-i18n/README.md

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ unplugin for Vue I18n
1717
- Locale of i18n resource definition
1818
- Locale of i18n resource definition for global scope
1919
- i18n resource formatting
20+
- Locale message tree-shaking (remove unused message keys)
2021

2122
## 💿 Installation
2223

@@ -249,6 +250,52 @@ If you want type definition of `@intlify/unplugin-vue-i18n/messages`, add `unplu
249250
}
250251
```
251252

253+
### Locale message tree-shaking
254+
255+
unplugin-vue-i18n can statically analyze your application code to detect which locale message keys are used via `t()`, `$t()`, `v-t` directive, etc., and remove unused keys from the production bundle.
256+
257+
This is useful for large applications with many locale message keys where only a subset is actually used.
258+
259+
```ts
260+
// vite.config.ts
261+
import VueI18nPlugin from '@intlify/unplugin-vue-i18n/vite'
262+
import path from 'path'
263+
264+
export default defineConfig({
265+
plugins: [
266+
VueI18nPlugin({
267+
include: [path.resolve(__dirname, './src/locales/**')],
268+
treeShaking: true
269+
})
270+
]
271+
})
272+
```
273+
274+
With advanced options:
275+
276+
```ts
277+
VueI18nPlugin({
278+
include: [path.resolve(__dirname, './src/locales/**')],
279+
treeShaking: {
280+
// Keys to always keep (glob patterns)
281+
safelist: ['errors.*', 'validation.**'],
282+
// Strategy when dynamic keys like t(variable) are detected
283+
// 'keep-all' (default): disable tree-shaking, keep all keys
284+
// 'ignore': continue tree-shaking despite dynamic keys
285+
dynamicKeyStrategy: 'keep-all'
286+
}
287+
})
288+
```
289+
290+
> [!NOTE]
291+
> Tree-shaking only runs in production builds. In development mode, all keys are preserved.
292+
293+
> [!NOTE]
294+
> Tree-shaking currently supports JSON and YAML locale files for global scope. Component-scoped `<i18n>` blocks (without `global` attribute) and JS/TS locale files are not tree-shaken.
295+
296+
> [!WARNING]
297+
> If your application uses dynamic keys (e.g., `t(variable)`, ``t(`prefix.${suffix}`)``), tree-shaking may remove keys that are only referenced dynamically. By default (`dynamicKeyStrategy: 'keep-all'`), tree-shaking is automatically disabled when dynamic key usage is detected. If you set `dynamicKeyStrategy: 'ignore'`, make sure to add dynamically accessed keys to `safelist`.
298+
252299
## 📦 Automatic bundling
253300

254301
### For Vue I18n
@@ -607,6 +654,50 @@ If do you will use this option, you need to enable `jitCompilation` option.
607654

608655
By using it you can exclude from the bundle those localizations that are not specified in this option.
609656

657+
### `treeShaking`
658+
659+
- **Type:** `boolean | TreeShakingOptions`
660+
- **Default:** `false`
661+
662+
Enable locale message tree-shaking to remove unused message keys from the production bundle. The plugin statically analyzes your source files to detect which keys are used via `t()`, `$t()`, `tc()`, `te()`, `d()`, `n()`, `v-t` directive, etc., and removes keys that are not referenced.
663+
664+
When set to `true`, default options are used. You can also pass an object for fine-grained control:
665+
666+
```ts
667+
interface TreeShakingOptions {
668+
/**
669+
* Message key patterns to always keep (never tree-shake).
670+
* Supports glob-like patterns.
671+
* `*` matches a single level, `**` matches multiple levels.
672+
* @example ['errors.*', 'validation.**']
673+
*/
674+
safelist?: string[]
675+
/**
676+
* Strategy when dynamic key usage (e.g., t(variable)) is detected.
677+
* - 'keep-all': Disable tree-shaking and keep all keys (safe default).
678+
* - 'ignore': Continue tree-shaking despite dynamic keys (use with safelist).
679+
* @default 'keep-all'
680+
*/
681+
dynamicKeyStrategy?: 'keep-all' | 'ignore'
682+
/**
683+
* Glob patterns for source files to scan for used message keys.
684+
* @default ['${projectRoot}/src/**\/*.{vue,ts,js,tsx,jsx}']
685+
*/
686+
scanPatterns?: string[]
687+
}
688+
```
689+
690+
**Supported scope:**
691+
- Global locale files (JSON/YAML matched by `include` option)
692+
- SFC `<i18n global>` custom blocks
693+
694+
**Not supported (keys are always preserved):**
695+
- Component-scoped `<i18n>` custom blocks (without `global` attribute)
696+
- JS/TS locale files
697+
698+
> [!NOTE]
699+
> Tree-shaking only runs in production builds.
700+
610701
### `useVueI18nImportName` (Experimental)
611702

612703
- **Type:** `boolean`

0 commit comments

Comments
 (0)