mirror of
https://github.com/langgenius/dify.git
synced 2026-10-16 15:35:32 +08:00
refactor(i18n): report route namespace dependencies (#42503)
This commit is contained in:
+132
-45
@@ -2,9 +2,9 @@
|
||||
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { build, createBuilder, createServer } from 'vite'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vite-plus/test'
|
||||
import { i18nPrunePlugin } from '../i18n-prune'
|
||||
import { build, createBuilder, createLogger, createServer } from 'vite'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'
|
||||
import { i18nAnalysisPlugin } from '../i18n-analysis'
|
||||
|
||||
describe('i18n build check', () => {
|
||||
let root: string
|
||||
@@ -33,13 +33,96 @@ describe('i18n build check', () => {
|
||||
root,
|
||||
configFile: false,
|
||||
logLevel: 'silent',
|
||||
plugins: [i18nPrunePlugin()],
|
||||
plugins: [i18nAnalysisPlugin()],
|
||||
build: {
|
||||
write: false,
|
||||
lib: { entry: path.join(root, 'entry.ts'), formats: ['es'] },
|
||||
},
|
||||
})
|
||||
|
||||
it('reports each route with inherited, lazy and environment-specific namespaces', async () => {
|
||||
const files: Record<string, string> = {
|
||||
'entry.ts': `export { page } from './app/(group)/items/[id]/page'
|
||||
export { other } from './app/other/page'
|
||||
export { layout } from './app/layout'
|
||||
export { group } from './app/(group)/layout'`,
|
||||
'app/layout.ts': `export function layout(t: (key: string) => string) { return t('common:title') }`,
|
||||
'app/(group)/layout.ts': `export function group(t: (key: string) => string) { return t('group:title') }`,
|
||||
'app/(group)/items/[id]/page.ts': `export { page } from '../../../../component'`,
|
||||
'app/other/page.ts': `export function other(t: (key: string) => string) { return t('other:title') }`,
|
||||
'component.ts': `export function page(t: (key: string) => string) { return t('app:used') }
|
||||
export const lazy = () => import('./lazy')`,
|
||||
'lazy.ts': `export function lazy(t: (key: string) => string) { return t('lazy:title') }`,
|
||||
'unimported.ts': `export function ignored(t: (key: string) => string) { return t('ignored:title') }`,
|
||||
}
|
||||
for (const [file, source] of Object.entries(files)) {
|
||||
mkdirSync(path.dirname(path.join(root, file)), { recursive: true })
|
||||
writeFileSync(path.join(root, file), source)
|
||||
}
|
||||
for (const namespace of ['common', 'group', 'other', 'lazy', 'client', 'ssr'])
|
||||
writeFileSync(
|
||||
path.join(root, `i18n/locales/en-US/${namespace}.json`),
|
||||
JSON.stringify({ title: 'Title' }),
|
||||
)
|
||||
writeFileSync(localeFile, '{}')
|
||||
const logger = createLogger('silent')
|
||||
const info = vi.spyOn(logger, 'info')
|
||||
const builder = await createBuilder({
|
||||
root,
|
||||
configFile: false,
|
||||
customLogger: logger,
|
||||
plugins: [
|
||||
{
|
||||
name: 'environment-translation',
|
||||
enforce: 'pre',
|
||||
transform(code, id) {
|
||||
if (id === path.join(root, 'component.ts'))
|
||||
return code.replace('app:used', `${this.environment.name}:title`)
|
||||
},
|
||||
},
|
||||
i18nAnalysisPlugin(),
|
||||
],
|
||||
environments: { client: {}, ssr: {} },
|
||||
build: { write: true, lib: { entry: path.join(root, 'entry.ts'), formats: ['es'] } },
|
||||
})
|
||||
await builder.buildApp()
|
||||
const reports = info.mock.calls
|
||||
.map(([message]) => message)
|
||||
.filter((message) => message.startsWith('[i18n] Route'))
|
||||
expect(reports).toHaveLength(1)
|
||||
expect(reports[0]).toContain(
|
||||
'/items/[id] (app/(group)/items/[id]/page.ts): client, common, group, lazy, ssr',
|
||||
)
|
||||
expect(reports[0]).toContain('/other (app/other/page.ts): common, other')
|
||||
expect(reports[0]).toContain(' page: client, ssr')
|
||||
expect(reports[0]).toContain(' shared: common, group')
|
||||
expect(reports[0]).toContain(' lazy: lazy')
|
||||
expect(reports[0]).not.toContain('ignored')
|
||||
const artifact: unknown = JSON.parse(
|
||||
readFileSync(path.join(root, 'dist/i18n-routes.json'), 'utf8'),
|
||||
)
|
||||
expect(artifact).toMatchObject({
|
||||
version: 1,
|
||||
routes: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
route: '/items/[id]',
|
||||
groups: {
|
||||
page: [
|
||||
{ namespace: 'client', sources: ['component.ts'] },
|
||||
{ namespace: 'ssr', sources: ['component.ts'] },
|
||||
],
|
||||
shared: [
|
||||
{ namespace: 'common', sources: ['app/layout.ts'] },
|
||||
{ namespace: 'group', sources: ['app/(group)/layout.ts'] },
|
||||
],
|
||||
lazy: [{ namespace: 'lazy', sources: ['lazy.ts'] }],
|
||||
slots: [],
|
||||
},
|
||||
}),
|
||||
]),
|
||||
})
|
||||
})
|
||||
|
||||
it('fails builds with actionable findings without modifying translations', async () => {
|
||||
const content = JSON.stringify({ used: 'Used', unused: 'Unused' })
|
||||
writeFileSync(localeFile, content)
|
||||
@@ -101,51 +184,55 @@ describe('i18n build check', () => {
|
||||
await expect(buildFixture()).resolves.toBeDefined()
|
||||
})
|
||||
|
||||
// Two rounds of three real builds need headroom under CI coverage and parallel load.
|
||||
it('checks the union of client, SSR and RSC graphs only after every environment builds', async () => {
|
||||
writeFileSync(localeFile, JSON.stringify({ client: 'Client', ssr: 'SSR', rsc: 'RSC' }))
|
||||
for (const name of ['client', 'ssr', 'rsc']) {
|
||||
writeFileSync(
|
||||
path.join(root, `${name}.ts`),
|
||||
`
|
||||
// Six real environment builds plus graph analysis need headroom under CI contention.
|
||||
it(
|
||||
'checks the union of client, SSR and RSC graphs only after every environment builds',
|
||||
{ timeout: 15_000 },
|
||||
async () => {
|
||||
writeFileSync(localeFile, JSON.stringify({ client: 'Client', ssr: 'SSR', rsc: 'RSC' }))
|
||||
for (const name of ['client', 'ssr', 'rsc']) {
|
||||
writeFileSync(
|
||||
path.join(root, `${name}.ts`),
|
||||
`
|
||||
export function translate(t: (key: string) => string) { return t('app:${name}') }
|
||||
`,
|
||||
)
|
||||
}
|
||||
const completed: string[] = []
|
||||
const builder = await createBuilder({
|
||||
root,
|
||||
configFile: false,
|
||||
logLevel: 'silent',
|
||||
plugins: [i18nPrunePlugin()],
|
||||
environments: Object.fromEntries(
|
||||
['client', 'ssr', 'rsc'].map((name) => [
|
||||
name,
|
||||
{
|
||||
build: {
|
||||
write: false,
|
||||
lib: { entry: path.join(root, `${name}.ts`), formats: ['es'] },
|
||||
)
|
||||
}
|
||||
const completed: string[] = []
|
||||
const builder = await createBuilder({
|
||||
root,
|
||||
configFile: false,
|
||||
logLevel: 'silent',
|
||||
plugins: [i18nAnalysisPlugin()],
|
||||
environments: Object.fromEntries(
|
||||
['client', 'ssr', 'rsc'].map((name) => [
|
||||
name,
|
||||
{
|
||||
build: {
|
||||
write: false,
|
||||
lib: { entry: path.join(root, `${name}.ts`), formats: ['es'] },
|
||||
},
|
||||
},
|
||||
]),
|
||||
),
|
||||
builder: {
|
||||
async buildApp(builder) {
|
||||
for (const environment of Object.values(builder.environments)) {
|
||||
await builder.build(environment)
|
||||
completed.push(environment.name)
|
||||
}
|
||||
},
|
||||
]),
|
||||
),
|
||||
builder: {
|
||||
async buildApp(builder) {
|
||||
for (const environment of Object.values(builder.environments)) {
|
||||
await builder.build(environment)
|
||||
completed.push(environment.name)
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
await expect(builder.buildApp()).resolves.toBeUndefined()
|
||||
expect(completed).toEqual(['client', 'ssr', 'rsc'])
|
||||
await expect(builder.buildApp()).resolves.toBeUndefined()
|
||||
expect(completed).toEqual(['client', 'ssr', 'rsc'])
|
||||
|
||||
// A second build must not inherit the first build's server usage.
|
||||
writeFileSync(path.join(root, 'ssr.ts'), 'export const value = 1')
|
||||
await expect(builder.buildApp()).rejects.toThrow('app:ssr')
|
||||
}, 15_000)
|
||||
// A second build must not inherit the first build's server usage.
|
||||
writeFileSync(path.join(root, 'ssr.ts'), 'export const value = 1')
|
||||
await expect(builder.buildApp()).rejects.toThrow('app:ssr')
|
||||
},
|
||||
)
|
||||
|
||||
it('retains usages from environment-specific versions of the same module', async () => {
|
||||
writeFileSync(localeFile, JSON.stringify({ client: 'Client', ssr: 'SSR' }))
|
||||
@@ -162,7 +249,7 @@ describe('i18n build check', () => {
|
||||
return code.replace('app:used', `app:${this.environment.name}`)
|
||||
},
|
||||
},
|
||||
i18nPrunePlugin(),
|
||||
i18nAnalysisPlugin(),
|
||||
],
|
||||
environments: {
|
||||
client: {},
|
||||
@@ -210,7 +297,7 @@ describe('i18n build check', () => {
|
||||
return code.replace('$.placeholder', `$.${this.environment.name}`)
|
||||
},
|
||||
},
|
||||
i18nPrunePlugin(),
|
||||
i18nAnalysisPlugin(),
|
||||
],
|
||||
environments: { client: {}, ssr: {} },
|
||||
builder: {
|
||||
@@ -238,7 +325,7 @@ describe('i18n build check', () => {
|
||||
root,
|
||||
configFile: false,
|
||||
logLevel: 'silent',
|
||||
plugins: [i18nPrunePlugin()],
|
||||
plugins: [i18nAnalysisPlugin()],
|
||||
server: { middlewareMode: true, watch: null },
|
||||
})
|
||||
try {
|
||||
@@ -3,7 +3,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vite-plus/test'
|
||||
import { checkTranslationGraph } from '../i18n-prune/graph'
|
||||
import { checkTranslationGraph } from '../i18n-analysis/graph'
|
||||
|
||||
let webRoot: string
|
||||
let modules: Map<string, string>
|
||||
@@ -28,7 +28,7 @@ function sortedUnusedKeysByNamespace(result: ReturnType<typeof checkTranslationG
|
||||
describe('translation graph analysis', () => {
|
||||
beforeEach(() => {
|
||||
modules = new Map()
|
||||
webRoot = mkdtempSync(path.join(tmpdir(), 'dify-i18n-prune-'))
|
||||
webRoot = mkdtempSync(path.join(tmpdir(), 'dify-i18n-analysis-'))
|
||||
writeSource('placeholder.ts', '')
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
// @vitest-environment node
|
||||
import { describe, expect, it } from 'vite-plus/test'
|
||||
import { analyzeRouteNamespaces } from '../i18n-analysis/routes'
|
||||
|
||||
describe('route namespace analysis', () => {
|
||||
it('includes ancestor boundaries and parallel slots without leaking sibling layouts', () => {
|
||||
const usage = new Map(
|
||||
Object.entries({
|
||||
'/web/app/layout.tsx': ['common'],
|
||||
'/web/app/(console)/layout.tsx': ['console'],
|
||||
'/web/app/(console)/items/page.tsx': ['items'],
|
||||
'/web/app/(console)/items/loading.tsx': ['loading'],
|
||||
'/web/app/(console)/items/error.tsx': ['error'],
|
||||
'/web/app/(console)/@sidebar/default.tsx': ['sidebar'],
|
||||
'/web/app/(console)/@sidebar/detail/page.tsx': ['detail'],
|
||||
'/web/app/(public)/layout.tsx': ['public'],
|
||||
'/web/app/(public)/page.tsx': [],
|
||||
'/web/shared.ts': ['shared'],
|
||||
'/web/lazy.ts': ['items', 'lazy'],
|
||||
}).map(([id, namespaces]) => [id, new Set(namespaces)]),
|
||||
)
|
||||
const dependencies = new Map([
|
||||
['/web/lazy.ts', { static: new Set(['/web/shared.ts']), dynamic: new Set<string>() }],
|
||||
[
|
||||
'/web/app/(console)/items/page.tsx',
|
||||
{ static: new Set(['\0virtual-component']), dynamic: new Set(['/web/lazy.ts']) },
|
||||
],
|
||||
['\0virtual-component', { static: new Set(['/web/shared.ts']), dynamic: new Set<string>() }],
|
||||
['/web/shared.ts', { static: new Set(['\0virtual-component']), dynamic: new Set<string>() }],
|
||||
])
|
||||
const report = analyzeRouteNamespaces('/web', dependencies, usage)
|
||||
expect(report.find((item) => item.page === 'app/(console)/items/page.tsx')).toMatchObject({
|
||||
route: '/items',
|
||||
page: 'app/(console)/items/page.tsx',
|
||||
namespaces: [
|
||||
'common',
|
||||
'console',
|
||||
'detail',
|
||||
'error',
|
||||
'items',
|
||||
'lazy',
|
||||
'loading',
|
||||
'shared',
|
||||
'sidebar',
|
||||
],
|
||||
})
|
||||
const groups = report.find((item) => item.page === 'app/(console)/items/page.tsx')!.groups
|
||||
expect(groups.page).toEqual([
|
||||
{ namespace: 'items', sources: ['app/(console)/items/page.tsx'] },
|
||||
{ namespace: 'shared', sources: ['shared.ts'] },
|
||||
])
|
||||
expect(groups.lazy).toEqual([
|
||||
{ namespace: 'items', sources: ['lazy.ts'] },
|
||||
{ namespace: 'lazy', sources: ['lazy.ts'] },
|
||||
])
|
||||
expect(groups.slots).toEqual([
|
||||
{ namespace: 'detail', sources: ['app/(console)/@sidebar/detail/page.tsx'] },
|
||||
{ namespace: 'sidebar', sources: ['app/(console)/@sidebar/default.tsx'] },
|
||||
])
|
||||
expect(report.find((item) => item.page === 'app/(public)/page.tsx')).toMatchObject({
|
||||
route: '/',
|
||||
page: 'app/(public)/page.tsx',
|
||||
namespaces: ['common', 'public'],
|
||||
groups: { page: [], lazy: [], slots: [] },
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,18 +1,38 @@
|
||||
import type { Plugin } from 'vite'
|
||||
import type { Logger, Plugin } from 'vite'
|
||||
import type { ModuleDependencies } from './i18n-analysis/routes'
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { normalizePath } from 'vite'
|
||||
import { analyzeRouteNamespaces } from './i18n-analysis/routes'
|
||||
|
||||
const SOURCE_META = 'dify:i18n-source'
|
||||
|
||||
// Vite shares these instances across client, SSR, and RSC environments. Each
|
||||
// completed graph replaces its preceding scan build; only buildApp finalizes it.
|
||||
export function i18nPrunePlugin(): Plugin[] {
|
||||
export function i18nAnalysisPlugin(): Plugin[] {
|
||||
let root: string
|
||||
let logger: Logger
|
||||
let reportPath: string | undefined
|
||||
let buildingApp = false
|
||||
const graphs = new Map<string, Map<string, string>>()
|
||||
const edges = new Map<string, Map<string, ModuleDependencies>>()
|
||||
const moduleId = (id: string) => normalizePath(id.split('?')[0]!)
|
||||
|
||||
const check = async () => {
|
||||
const { checkTranslationGraph } = await import('./i18n-prune/graph')
|
||||
const { checkTranslationGraph } = await import('./i18n-analysis/graph')
|
||||
const usage = new Map<string, Set<string>>()
|
||||
const dependencies = new Map<string, ModuleDependencies>()
|
||||
for (const graph of edges.values()) {
|
||||
for (const [id, imports] of graph) {
|
||||
const merged = dependencies.get(id) ?? {
|
||||
static: new Set<string>(),
|
||||
dynamic: new Set<string>(),
|
||||
}
|
||||
for (const dependency of imports.static) merged.static.add(dependency)
|
||||
for (const dependency of imports.dynamic) merged.dynamic.add(dependency)
|
||||
dependencies.set(id, merged)
|
||||
}
|
||||
}
|
||||
let unused: Record<string, string[]> | undefined
|
||||
const protectedNamespaces = new Set<string>()
|
||||
for (const graph of graphs.values()) {
|
||||
@@ -21,6 +41,11 @@ export function i18nPrunePlugin(): Plugin[] {
|
||||
const modules = new Map<string, string>()
|
||||
for (const [id, code] of graph) modules.set(normalizePath(id.split('?')[0]!), code)
|
||||
const result = checkTranslationGraph(root, modules)
|
||||
for (const [id, namespaces] of result.moduleNamespaces) {
|
||||
const merged = usage.get(id) ?? new Set<string>()
|
||||
for (const namespace of namespaces) merged.add(namespace)
|
||||
usage.set(id, merged)
|
||||
}
|
||||
for (const namespace of result.protectedNamespaces) protectedNamespaces.add(namespace)
|
||||
// Intersect unused sets: usage (or protection) in any environment keeps a key.
|
||||
unused =
|
||||
@@ -33,6 +58,27 @@ export function i18nPrunePlugin(): Plugin[] {
|
||||
}),
|
||||
)
|
||||
}
|
||||
const routes = analyzeRouteNamespaces(normalizePath(root), dependencies, usage)
|
||||
if (routes.length) {
|
||||
logger.info(
|
||||
[
|
||||
'[i18n] Route namespace analysis (static build graph; may overestimate or miss runtime usage):',
|
||||
'Includes ancestor boundaries, dynamic imports and all built parallel-slot branches. Interception segments remain in route labels.',
|
||||
...routes.flatMap(({ route, page, namespaces, groups }) => [
|
||||
` ${route} (${page}): ${namespaces.join(', ') || '(none detected)'}`,
|
||||
...Object.entries(groups).map(
|
||||
([name, entries]) =>
|
||||
` ${name}: ${entries.map((entry) => entry.namespace).join(', ') || '(none detected)'}`,
|
||||
),
|
||||
]),
|
||||
].join('\n'),
|
||||
)
|
||||
}
|
||||
if (routes.length && reportPath) {
|
||||
await fs.mkdir(path.dirname(reportPath), { recursive: true })
|
||||
await fs.writeFile(reportPath, `${JSON.stringify({ version: 1, routes }, null, 2)}\n`)
|
||||
logger.info(`[i18n] Full namespace sources: ${reportPath}`)
|
||||
}
|
||||
const keys = Object.entries(unused ?? {}).flatMap(([namespace, unused]) =>
|
||||
unused.map((key) => `${namespace}:${key}`),
|
||||
)
|
||||
@@ -56,19 +102,27 @@ export function i18nPrunePlugin(): Plugin[] {
|
||||
|
||||
return [
|
||||
{
|
||||
name: 'i18n-prune:collect',
|
||||
name: 'i18n-analysis:collect',
|
||||
apply: 'build',
|
||||
enforce: 'pre',
|
||||
sharedDuringBuild: true,
|
||||
configResolved(config) {
|
||||
root = config.root
|
||||
logger = config.logger
|
||||
reportPath = config.build.write
|
||||
? path.resolve(root, config.build.outDir, 'i18n-routes.json')
|
||||
: undefined
|
||||
},
|
||||
async buildApp() {
|
||||
graphs.clear()
|
||||
edges.clear()
|
||||
buildingApp = true
|
||||
},
|
||||
buildStart() {
|
||||
if (!buildingApp) graphs.clear()
|
||||
if (!buildingApp) {
|
||||
graphs.clear()
|
||||
edges.clear()
|
||||
}
|
||||
},
|
||||
transform(code, id) {
|
||||
const file = normalizePath(id.split('?')[0]!)
|
||||
@@ -85,16 +139,29 @@ export function i18nPrunePlugin(): Plugin[] {
|
||||
},
|
||||
async generateBundle() {
|
||||
const graph = new Map<string, string>()
|
||||
const dependencies = new Map<string, ModuleDependencies>()
|
||||
for (const id of this.getModuleIds()) {
|
||||
const source: unknown = this.getModuleInfo(id)?.meta[SOURCE_META]
|
||||
const info = this.getModuleInfo(id)
|
||||
if (info) {
|
||||
const imports = dependencies.get(moduleId(id)) ?? {
|
||||
static: new Set<string>(),
|
||||
dynamic: new Set<string>(),
|
||||
}
|
||||
for (const dependency of info.importedIds) imports.static.add(moduleId(dependency))
|
||||
for (const dependency of info.dynamicallyImportedIds)
|
||||
imports.dynamic.add(moduleId(dependency))
|
||||
dependencies.set(moduleId(id), imports)
|
||||
}
|
||||
const source: unknown = info?.meta[SOURCE_META]
|
||||
if (typeof source === 'string') graph.set(id, source)
|
||||
}
|
||||
graphs.set(this.environment.name, graph)
|
||||
edges.set(this.environment.name, dependencies)
|
||||
if (!buildingApp) await check()
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'i18n-prune:check',
|
||||
name: 'i18n-analysis:check',
|
||||
apply: 'build',
|
||||
sharedDuringBuild: true,
|
||||
buildApp: {
|
||||
@@ -111,6 +178,7 @@ export function i18nPrunePlugin(): Plugin[] {
|
||||
} finally {
|
||||
buildingApp = false
|
||||
graphs.clear()
|
||||
edges.clear()
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -0,0 +1,53 @@
|
||||
# i18n build analysis
|
||||
|
||||
`i18nAnalysisPlugin()` prints a route namespace report during production Vite builds,
|
||||
before the existing unused-key check. With Vinext it reports once, after combining
|
||||
the completed client, SSR and RSC graphs. Development does not run the analysis.
|
||||
|
||||
Each route contains its pattern, source page, sorted namespaces and four groups:
|
||||
|
||||
```text
|
||||
/items/[id] (app/(console)/items/[id]/page.tsx): app, common, workflow
|
||||
page: app
|
||||
shared: common
|
||||
lazy: workflow
|
||||
slots: (none detected)
|
||||
```
|
||||
|
||||
The report follows resolved static and dynamic imports, including virtual modules,
|
||||
and adds ancestor layouts, templates, loading/error/not-found boundaries. Route
|
||||
groups are omitted from the URL pattern. Parallel slots include all built branches
|
||||
because their active page can depend on navigation history. Interception segments
|
||||
remain visible in labels; the source page disambiguates entries with the same URL.
|
||||
|
||||
This is a conservative module-level estimate, not a runtime loading manifest.
|
||||
It shares the unused-key analyzer's translation recognition and dynamic-key
|
||||
fallbacks. Unresolved namespaces can include the entire catalog. Unused exports,
|
||||
conditional branches, lazy features and parallel slots can overestimate usage;
|
||||
runtime-only imports or unrecognized translation APIs can be missed. Only pages
|
||||
and boundaries present in the build graph are reported. Translation loading is
|
||||
unchanged, and this report does not introduce additional build failures.
|
||||
|
||||
## Groups and source files
|
||||
|
||||
- `page`: the page and its transitive static imports.
|
||||
- `shared`: ancestor layouts, templates and loading/error/not-found boundaries,
|
||||
including their transitive static imports. This includes same-segment boundaries.
|
||||
- `lazy`: modules reached through dynamic imports from the page or shared entries,
|
||||
excluding modules already statically reachable from either. Dynamic import does
|
||||
not prove that a feature loads only after user interaction.
|
||||
- `slots`: modules reachable from all built ancestor parallel-slot branches,
|
||||
excluding modules already reachable from the page/shared entries (including lazy).
|
||||
|
||||
Page and shared groups can overlap. A namespace can appear in multiple groups
|
||||
when different modules use it; group counts must not be added to obtain the total.
|
||||
Environment graphs are combined conservatively, so a static path in any environment
|
||||
makes a module static for this report. This is not a client-only bundle analysis.
|
||||
|
||||
When build output writing is enabled, `<build.outDir>/i18n-routes.json` contains
|
||||
`{ version: 1, routes }`. Every route retains `route`, `page` and `namespaces`, with
|
||||
`groups` mapping each category to `{ namespace, sources }` entries. Sources are
|
||||
sorted paths relative to the Vite root and identify modules containing recognized
|
||||
translation usage, not necessarily the entire chain of imports leading to them.
|
||||
The report is written before the unused-key check, so it remains available if that
|
||||
check fails. `build.write: false` only prints the summary and writes no report.
|
||||
@@ -101,6 +101,8 @@ export function checkTranslationGraph(root: string, modules: ReadonlyMap<string,
|
||||
const checker = program.getTypeChecker()
|
||||
const used = new Map<string, Set<string>>()
|
||||
const protectedNamespaces = new Set<string>()
|
||||
const moduleNamespaces = new Map<string, Set<string>>()
|
||||
let currentNamespaces = new Set<string>()
|
||||
|
||||
function declarations(node: ts.Node): readonly ts.Declaration[] {
|
||||
const symbol = checker.getSymbolAtLocation(node)
|
||||
@@ -418,6 +420,7 @@ export function checkTranslationGraph(root: string, modules: ReadonlyMap<string,
|
||||
const text = separator > 0 && !key.namespace ? key.text.slice(separator + 1) : key.text
|
||||
for (const rawNamespace of namespaces) {
|
||||
const namespace = camelCase(rawNamespace)
|
||||
currentNamespaces.add(namespace)
|
||||
if (!catalog.has(namespace)) continue
|
||||
if (key.wildcard && text === '.*' && !info.prefix) {
|
||||
protectedNamespaces.add(namespace)
|
||||
@@ -513,6 +516,8 @@ export function checkTranslationGraph(root: string, modules: ReadonlyMap<string,
|
||||
}
|
||||
for (const id of modules.keys()) {
|
||||
const source = program.getSourceFile(id)
|
||||
currentNamespaces = new Set<string>()
|
||||
moduleNamespaces.set(id, currentNamespaces)
|
||||
if (source) visit(source)
|
||||
}
|
||||
const unused: Record<string, string[]> = {}
|
||||
@@ -521,5 +526,10 @@ export function checkTranslationGraph(root: string, modules: ReadonlyMap<string,
|
||||
const missing = [...keys].filter((key) => !used.get(namespace)?.has(key)).sort()
|
||||
if (missing.length) unused[namespace] = missing
|
||||
}
|
||||
return { unused, protectedNamespaces: [...protectedNamespaces].sort(), moduleCount: modules.size }
|
||||
return {
|
||||
unused,
|
||||
protectedNamespaces: [...protectedNamespaces].sort(),
|
||||
moduleCount: modules.size,
|
||||
moduleNamespaces,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import path from 'node:path'
|
||||
|
||||
export type ModuleDependencies = { static: Set<string>; dynamic: Set<string> }
|
||||
type NamespaceSources = { namespace: string; sources: string[] }
|
||||
export type RouteNamespaceReport = {
|
||||
route: string
|
||||
page: string
|
||||
namespaces: string[]
|
||||
groups: Record<'page' | 'shared' | 'lazy' | 'slots', NamespaceSources[]>
|
||||
}
|
||||
|
||||
// Use resolved build edges (including virtual modules), not an independent
|
||||
// resolver which could disagree with aliases, exports or environment conditions.
|
||||
export function analyzeRouteNamespaces(
|
||||
root: string,
|
||||
dependencies: ReadonlyMap<string, ModuleDependencies>,
|
||||
usage: ReadonlyMap<string, ReadonlySet<string>>,
|
||||
): RouteNamespaceReport[] {
|
||||
const app = `${root.replaceAll('\\', '/')}/app/`
|
||||
const files = [...usage.keys()].filter((file) => file.startsWith(app))
|
||||
const boundaries = files.filter((file) =>
|
||||
/\/(?:layout|template|loading|error|not-found|global-error)\.[jt]sx?$/.test(file),
|
||||
)
|
||||
return files
|
||||
.filter((file) => /\/page\.[jt]sx?$/.test(file))
|
||||
.sort()
|
||||
.map((page) => {
|
||||
const segments = page.slice(app.length).split('/').slice(0, -1)
|
||||
const ancestors = new Set([app.slice(0, -1)])
|
||||
for (let i = 1; i <= segments.length; i++) ancestors.add(app + segments.slice(0, i).join('/'))
|
||||
const sharedEntries = boundaries.filter((file) => ancestors.has(path.posix.dirname(file)))
|
||||
const slotEntries: string[] = []
|
||||
// Parallel slots can retain previously active pages on navigation.
|
||||
for (const file of files) {
|
||||
const parts = file.slice(app.length).split('/')
|
||||
if (
|
||||
parts.some(
|
||||
(part, index) =>
|
||||
part.startsWith('@') &&
|
||||
ancestors.has(index ? app + parts.slice(0, index).join('/') : app.slice(0, -1)),
|
||||
)
|
||||
)
|
||||
slotEntries.push(file)
|
||||
}
|
||||
function reachable(entries: string[], includeDynamic: boolean) {
|
||||
const visited = new Set<string>()
|
||||
const pending = [...entries]
|
||||
while (pending.length) {
|
||||
const id = pending.pop()!
|
||||
if (visited.has(id)) continue
|
||||
visited.add(id)
|
||||
const imports = dependencies.get(id)
|
||||
for (const dependency of imports?.static ?? []) pending.push(dependency)
|
||||
if (includeDynamic)
|
||||
for (const dependency of imports?.dynamic ?? []) pending.push(dependency)
|
||||
}
|
||||
return visited
|
||||
}
|
||||
function sources(modules: Set<string>): NamespaceSources[] {
|
||||
const byNamespace = new Map<string, Set<string>>()
|
||||
for (const id of modules) {
|
||||
for (const namespace of usage.get(id) ?? []) {
|
||||
const files = byNamespace.get(namespace) ?? new Set<string>()
|
||||
files.add(path.posix.relative(root.replaceAll('\\', '/'), id))
|
||||
byNamespace.set(namespace, files)
|
||||
}
|
||||
}
|
||||
return [...byNamespace]
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([namespace, files]) => ({
|
||||
namespace,
|
||||
sources: [...files].sort(),
|
||||
}))
|
||||
}
|
||||
const pageModules = reachable([page], false)
|
||||
const sharedModules = reachable(sharedEntries, false)
|
||||
const mainModules = reachable([page, ...sharedEntries], true)
|
||||
const lazyModules = new Set(
|
||||
[...mainModules].filter((id) => !pageModules.has(id) && !sharedModules.has(id)),
|
||||
)
|
||||
const slotModules = new Set(
|
||||
[...reachable(slotEntries, true)].filter((id) => !mainModules.has(id)),
|
||||
)
|
||||
const groups = {
|
||||
page: sources(pageModules),
|
||||
shared: sources(sharedModules),
|
||||
lazy: sources(lazyModules),
|
||||
slots: sources(slotModules),
|
||||
}
|
||||
const namespaces = new Set(
|
||||
Object.values(groups).flatMap((group) => group.map((item) => item.namespace)),
|
||||
)
|
||||
return {
|
||||
// Keep interception segments visible; their URL depends on navigation.
|
||||
route: `/${segments.filter((segment) => !/^\([^)]*\)$/.test(segment) && !segment.startsWith('@')).join('/')}`,
|
||||
page: page.slice(root.replaceAll('\\', '/').length + 1),
|
||||
namespaces: [...namespaces].sort(),
|
||||
groups,
|
||||
}
|
||||
})
|
||||
}
|
||||
+2
-2
@@ -2,7 +2,7 @@ import { fileURLToPath } from 'node:url'
|
||||
import { configDefaults, defineConfig, lazyPlugins } from 'vite-plus'
|
||||
import { playwright } from 'vite-plus/test/browser-playwright'
|
||||
import { customI18nHmrPlugin } from './plugins/vite/custom-i18n-hmr.ts'
|
||||
import { i18nPrunePlugin } from './plugins/vite/i18n-prune.ts'
|
||||
import { i18nAnalysisPlugin } from './plugins/vite/i18n-analysis.ts'
|
||||
import { getRootClientInjectTarget } from './plugins/vite/inject-target.ts'
|
||||
import { nextStaticImageTestPlugin } from './plugins/vite/next-static-image-test.ts'
|
||||
|
||||
@@ -40,7 +40,7 @@ export default defineConfig(({ command, mode, isPreview }) => {
|
||||
: undefined
|
||||
|
||||
return [
|
||||
i18nPrunePlugin(),
|
||||
i18nAnalysisPlugin(),
|
||||
Inspect(),
|
||||
inspector,
|
||||
tailwindcss(),
|
||||
|
||||
Reference in New Issue
Block a user