dify/web/scripts/i18n-prune/core.ts
Stephen Zhou b9fde89781
refactor(web): migrate i18n to selector optimize mode (#38657)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-10 09:35:31 +00:00

1520 lines
50 KiB
TypeScript

import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import * as ts from 'typescript'
const DEFAULT_LOCALE = 'en-US'
const DEFAULT_NAMESPACE = 'app'
const MAX_EXPANDED_VALUES = 200
const SOURCE_EXTENSIONS = new Set(['.js', '.jsx', '.ts', '.tsx'])
const PLURAL_SUFFIXES = ['_zero', '_one', '_two', '_few', '_many', '_other']
const SKIPPED_DIRECTORIES = new Set([
'.next',
'.turbo',
'.vinext',
'.vscode',
'coverage',
'dist',
'i18n',
'node_modules',
'public',
])
type StaticValue
= | { kind: 'strings', values: string[] }
| { kind: 'object', properties: Map<string, StaticValue>, unknownKeyValues: StaticValue[], complete: boolean }
| { kind: 'array', elements: StaticValue[] }
| { kind: 'identityFunction' }
| { kind: 'selector', expression: ts.Expression }
| { kind: 'selectors', expressions: ts.Expression[] }
| { kind: 'unknown' }
type KeyExpressionUsage
= | { kind: 'keys', keys: string[] }
| { kind: 'patterns', patterns: Array<{ prefix: string, suffix: string }> }
| { kind: 'mixed', keys: string[], patterns: Array<{ prefix: string, suffix: string }> }
| { kind: 'unknown', namespace?: string }
type TranslationFunctionInfo = {
namespaces: string[]
keyPrefix?: string
selectorArgumentIndex?: number
}
type UsageLocation = {
file: string
line: number
expression: string
}
export type DynamicKeyPattern = UsageLocation & {
namespace: string
prefix: string
suffix: string
}
export type UnresolvedUsage = UsageLocation & {
namespace?: string
reason: string
}
type Catalog = {
keysByNamespace: Map<string, Set<string>>
fileNameByNamespace: Map<string, string>
namespaceByFileName: Map<string, string>
}
export type AnalyzeUnusedTranslationsOptions = {
webRoot?: string
defaultLocale?: string
files?: string[]
sourceFiles?: string[]
}
export type AnalyzeUnusedTranslationsResult = {
webRoot: string
defaultLocale: string
unusedKeysByNamespace: Record<string, string[]>
usedKeysByNamespace: Record<string, string[]>
allKeysByNamespace: Record<string, string[]>
dynamicKeyPatterns: DynamicKeyPattern[]
protectedNamespaces: string[]
unresolvedUsages: UnresolvedUsage[]
namespaceFiles: Record<string, string>
}
export type RemoveUnusedTranslationsOptions = {
webRoot?: string
locales?: string[]
analysis: AnalyzeUnusedTranslationsResult
}
export type RemovedKey = {
locale: string
namespace: string
key: string
}
export type RemoveUnusedTranslationsResult = {
removedKeys: RemovedKey[]
}
// The analyzer keeps the pruning pipeline explicit: load the default catalog,
// collect statically provable usage, protect ambiguous dynamic usage, then prune.
type UsageCollector = {
exactKeys: Map<string, Set<string>>
patterns: DynamicKeyPattern[]
protectedNamespaces: Set<string>
unresolvedUsages: UnresolvedUsage[]
}
type Scope = {
values: Map<string, StaticValue>
translationFunctions: Map<string, TranslationFunctionInfo>
}
function toPosixPath(filePath: string) {
return filePath.split(path.sep).join('/')
}
function fileNameToNamespace(fileName: string) {
return fileName.replace(/[-_]+([a-z0-9])/gi, (_, char: string) => char.toUpperCase())
}
function namespaceToFileName(namespace: string, catalog: Catalog) {
return catalog.fileNameByNamespace.get(namespace) ?? namespace.replace(/[A-Z0-9]/g, char => `-${char.toLowerCase()}`)
}
function isStringNode(node: ts.Node): node is ts.StringLiteral | ts.NoSubstitutionTemplateLiteral {
return ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)
}
function uniqueSorted(values: Iterable<string>) {
return Array.from(new Set(values)).sort()
}
function normalizeNamespace(namespace: string, catalog: Catalog) {
if (catalog.keysByNamespace.has(namespace))
return namespace
const fromFileName = catalog.namespaceByFileName.get(namespace)
if (fromFileName)
return fromFileName
const camelNamespace = fileNameToNamespace(namespace)
return catalog.keysByNamespace.has(camelNamespace) ? camelNamespace : namespace
}
function unwrapExpression(expression: ts.Expression): ts.Expression {
let current = expression
let changed = true
while (changed) {
changed = false
if (
ts.isParenthesizedExpression(current)
|| ts.isAsExpression(current)
|| ts.isTypeAssertionExpression(current)
|| ts.isNonNullExpression(current)
|| ts.isSatisfiesExpression(current)
) {
current = current.expression
changed = true
}
}
return current
}
function combineStringValues(left: string[], right: string[]) {
const combined: string[] = []
for (const leftValue of left) {
for (const rightValue of right) {
combined.push(`${leftValue}${rightValue}`)
if (combined.length > MAX_EXPANDED_VALUES)
return []
}
}
return uniqueSorted(combined)
}
function staticStrings(values: string[]): StaticValue {
return { kind: 'strings', values: uniqueSorted(values) }
}
function unknownStaticValue(): StaticValue {
return { kind: 'unknown' }
}
function getPropertyNameText(name: ts.PropertyName) {
if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name))
return name.text
return undefined
}
function isFunctionLikeWithParameters(node: ts.Node): node is ts.FunctionDeclaration | ts.FunctionExpression | ts.ArrowFunction | ts.MethodDeclaration | ts.ConstructorDeclaration {
return ts.isFunctionDeclaration(node)
|| ts.isFunctionExpression(node)
|| ts.isArrowFunction(node)
|| ts.isMethodDeclaration(node)
|| ts.isConstructorDeclaration(node)
}
function isLikelyTranslationFunctionType(typeNode: ts.TypeNode | undefined) {
const typeText = typeNode?.getText()
return Boolean(typeText && (/\bTFunction\b/.test(typeText) || /\buseTranslation\b/.test(typeText)))
}
function getTranslationFunctionInfoFromType(typeNode: ts.TypeNode | undefined, catalog: Catalog): TranslationFunctionInfo | undefined {
if (!typeNode)
return undefined
if (ts.isTypeReferenceNode(typeNode) && /(?:^|\.)TFunction$/.test(typeNode.typeName.getText())) {
const namespace = getStringLiteralTypeValue(typeNode.typeArguments?.[0])
return {
namespaces: [namespace ? normalizeNamespace(namespace, catalog) : DEFAULT_NAMESPACE],
keyPrefix: undefined,
}
}
if (isLikelyTranslationFunctionType(typeNode))
return { namespaces: [DEFAULT_NAMESPACE], keyPrefix: undefined }
return undefined
}
function addTranslationFunction(scope: Scope, name: string, info: TranslationFunctionInfo = { namespaces: [DEFAULT_NAMESPACE], keyPrefix: undefined }) {
scope.translationFunctions.set(name, info)
}
function lookupValue(name: string, scopes: Scope[]) {
for (let index = scopes.length - 1; index >= 0; index--) {
const value = scopes[index]!.values.get(name)
if (value)
return value
}
return undefined
}
function lookupTranslationFunction(name: string, scopes: Scope[]) {
for (let index = scopes.length - 1; index >= 0; index--) {
const value = scopes[index]!.translationFunctions.get(name)
if (value)
return value
}
return undefined
}
function evaluateStaticExpression(expression: ts.Expression, scopes: Scope[]): StaticValue {
const unwrapped = unwrapExpression(expression)
if (isStringNode(unwrapped))
return staticStrings([unwrapped.text])
if (ts.isTemplateExpression(unwrapped)) {
let values = [unwrapped.head.text]
for (const span of unwrapped.templateSpans) {
const expressionValue = evaluateStaticExpression(span.expression, scopes)
if (expressionValue.kind !== 'strings')
return unknownStaticValue()
values = combineStringValues(values, expressionValue.values)
if (!values.length)
return unknownStaticValue()
values = values.map(value => `${value}${span.literal.text}`)
}
return staticStrings(values)
}
if (ts.isBinaryExpression(unwrapped) && unwrapped.operatorToken.kind === ts.SyntaxKind.PlusToken) {
const left = evaluateStaticExpression(unwrapped.left, scopes)
const right = evaluateStaticExpression(unwrapped.right, scopes)
if (left.kind === 'strings' && right.kind === 'strings') {
const values = combineStringValues(left.values, right.values)
return values.length ? staticStrings(values) : unknownStaticValue()
}
return unknownStaticValue()
}
if (
ts.isBinaryExpression(unwrapped)
&& (unwrapped.operatorToken.kind === ts.SyntaxKind.BarBarToken || unwrapped.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken)
) {
const left = evaluateStaticExpression(unwrapped.left, scopes)
const right = evaluateStaticExpression(unwrapped.right, scopes)
if (left.kind === 'strings' && right.kind === 'strings')
return staticStrings([...left.values, ...right.values])
return unknownStaticValue()
}
if (ts.isConditionalExpression(unwrapped)) {
const whenTrue = evaluateStaticExpression(unwrapped.whenTrue, scopes)
const whenFalse = evaluateStaticExpression(unwrapped.whenFalse, scopes)
if (whenTrue.kind === 'strings' && whenFalse.kind === 'strings')
return staticStrings([...whenTrue.values, ...whenFalse.values])
const trueExpression = unwrapExpression(unwrapped.whenTrue)
const falseExpression = unwrapExpression(unwrapped.whenFalse)
if (ts.isIdentifier(falseExpression) && falseExpression.text === 'undefined' && (whenTrue.kind === 'selector' || whenTrue.kind === 'selectors'))
return whenTrue
if (ts.isIdentifier(trueExpression) && trueExpression.text === 'undefined' && (whenFalse.kind === 'selector' || whenFalse.kind === 'selectors'))
return whenFalse
return unknownStaticValue()
}
if (ts.isIdentifier(unwrapped))
return lookupValue(unwrapped.text, scopes) ?? unknownStaticValue()
if (ts.isPropertyAccessExpression(unwrapped)) {
const container = evaluateStaticExpression(unwrapped.expression, scopes)
if (container.kind === 'object') {
const value = container.properties.get(unwrapped.name.text)
if (value && container.complete && !container.unknownKeyValues.length)
return value
if (value || !container.complete || container.unknownKeyValues.length)
return unknownStaticValue()
}
return unknownStaticValue()
}
if (ts.isElementAccessExpression(unwrapped) && unwrapped.argumentExpression) {
const container = evaluateStaticExpression(unwrapped.expression, scopes)
const argument = evaluateStaticExpression(unwrapped.argumentExpression, scopes)
if (container.kind === 'object') {
if (argument.kind !== 'strings') {
if (!container.complete)
return unknownStaticValue()
return collectIndexedValues([...container.properties.values(), ...container.unknownKeyValues])
}
const selectedValues: StaticValue[] = []
for (const key of argument.values) {
const value = container.properties.get(key)
if (value && container.complete && !container.unknownKeyValues.length)
selectedValues.push(value)
else
return unknownStaticValue()
}
return collectIndexedValues(selectedValues)
}
if (container.kind === 'array') {
if (argument.kind !== 'strings')
return collectIndexedValues(container.elements)
return collectIndexedValues(argument.values.flatMap((key) => {
const index = Number(key)
return Number.isInteger(index) ? container.elements[index] ?? [] : []
}))
}
}
if (ts.isObjectLiteralExpression(unwrapped)) {
const properties = new Map<string, StaticValue>()
const unknownKeyValues: StaticValue[] = []
let complete = true
for (const property of unwrapped.properties) {
if (!ts.isPropertyAssignment(property)) {
complete = false
continue
}
const propertyValue = evaluateStaticExpression(property.initializer, scopes)
if (ts.isComputedPropertyName(property.name)) {
const propertyNames = evaluateStaticExpression(property.name.expression, scopes)
if (propertyNames.kind === 'strings') {
for (const propertyName of propertyNames.values)
properties.set(propertyName, propertyValue)
}
else {
unknownKeyValues.push(propertyValue)
}
continue
}
const propertyName = getPropertyNameText(property.name)
if (propertyName)
properties.set(propertyName, propertyValue)
else
complete = false
}
return { kind: 'object', properties, unknownKeyValues, complete }
}
if (ts.isArrayLiteralExpression(unwrapped))
return { kind: 'array', elements: unwrapped.elements.map(element => evaluateStaticExpression(element, scopes)) }
if (ts.isArrowFunction(unwrapped)) {
if (getSelectorKeyExpression(unwrapped))
return { kind: 'selector', expression: unwrapped }
const parameter = unwrapped.parameters[0]
const body = ts.isBlock(unwrapped.body) ? undefined : unwrapExpression(unwrapped.body)
if (
parameter
&& ts.isIdentifier(parameter.name)
&& body
&& ts.isIdentifier(body)
&& body.text === parameter.name.text
) {
return { kind: 'identityFunction' }
}
}
if (ts.isCallExpression(unwrapped) && ts.isIdentifier(unwrapped.expression) && unwrapped.arguments.length === 1) {
const callee = lookupValue(unwrapped.expression.text, scopes)
if (callee?.kind === 'identityFunction')
return evaluateStaticExpression(unwrapped.arguments[0]! as ts.Expression, scopes)
}
return unknownStaticValue()
}
function collectStringValues(value: StaticValue): string[] {
if (value.kind === 'strings')
return value.values
if (value.kind === 'array')
return uniqueSorted(value.elements.flatMap(element => collectStringValues(element)))
if (value.kind === 'object')
return uniqueSorted([...value.properties.values(), ...value.unknownKeyValues].flatMap(property => collectStringValues(property)))
return []
}
function collectSelectorExpressions(value: StaticValue): ts.Expression[] {
if (value.kind === 'selector')
return [value.expression]
if (value.kind === 'selectors')
return value.expressions
if (value.kind === 'array')
return value.elements.flatMap(element => collectSelectorExpressions(element))
if (value.kind === 'object')
return [...value.properties.values(), ...value.unknownKeyValues].flatMap(property => collectSelectorExpressions(property))
return []
}
function hasUnknownAggregateValue(value: StaticValue): boolean {
if (value.kind === 'unknown' || value.kind === 'identityFunction')
return true
if (value.kind === 'array')
return value.elements.some(element => hasUnknownAggregateValue(element))
if (value.kind === 'object')
return !value.complete || [...value.properties.values(), ...value.unknownKeyValues].some(property => hasUnknownAggregateValue(property))
return false
}
function collectIndexedValues(values: StaticValue[]): StaticValue {
if (!values.length)
return unknownStaticValue()
if (values.every((value): value is Extract<StaticValue, { kind: 'object' }> => value.kind === 'object')) {
const propertyNames = Array.from(values[0]!.properties.keys())
const haveMatchingShape = values.every((value) => {
return value.complete
&& !value.unknownKeyValues.length
&& value.properties.size === propertyNames.length
&& propertyNames.every(propertyName => value.properties.has(propertyName))
})
if (!haveMatchingShape)
return unknownStaticValue()
return {
kind: 'object',
properties: new Map(propertyNames.map((propertyName) => {
return [propertyName, collectIndexedValues(values.map(value => value.properties.get(propertyName)!))]
})),
unknownKeyValues: [],
complete: true,
}
}
if (values.some(value => hasUnknownAggregateValue(value)))
return unknownStaticValue()
const selectors = values.flatMap(value => collectSelectorExpressions(value))
const strings = values.flatMap(value => collectStringValues(value))
if (selectors.length && strings.length)
return unknownStaticValue()
if (selectors.length)
return selectors.length === 1 ? { kind: 'selector', expression: selectors[0]! } : { kind: 'selectors', expressions: selectors }
return strings.length ? staticStrings(strings) : unknownStaticValue()
}
function getObjectProperty(objectLiteral: ts.ObjectLiteralExpression, name: string) {
return objectLiteral.properties.find((property): property is ts.PropertyAssignment => {
if (!ts.isPropertyAssignment(property))
return false
const propertyName = getPropertyNameText(property.name)
return propertyName === name
})
}
function evaluateNamespaceExpression(expression: ts.Expression | undefined, scopes: Scope[], catalog: Catalog) {
if (!expression)
return []
const value = evaluateStaticExpression(expression, scopes)
if (value.kind === 'strings')
return uniqueSorted(value.values.map(namespace => normalizeNamespace(namespace, catalog)))
if (value.kind === 'array') {
const namespaces: string[] = []
for (const element of value.elements) {
if (element.kind === 'strings')
namespaces.push(...element.values.map(namespace => normalizeNamespace(namespace, catalog)))
}
return uniqueSorted(namespaces)
}
return []
}
function getStringLiteralTypeValue(typeNode: ts.TypeNode | undefined) {
if (
typeNode
&& ts.isLiteralTypeNode(typeNode)
&& ts.isStringLiteral(typeNode.literal)
) {
return typeNode.literal.text
}
return undefined
}
function evaluateAssertedTypeKeyUsage(expression: ts.Expression): KeyExpressionUsage | undefined {
const unwrapped = expression
if (!ts.isAsExpression(unwrapped) && !ts.isTypeAssertionExpression(unwrapped))
return undefined
const literalValue = getStringLiteralTypeValue(unwrapped.type)
if (literalValue)
return { kind: 'keys', keys: [literalValue] }
if (!ts.isTypeReferenceNode(unwrapped.type))
return undefined
const typeName = unwrapped.type.typeName.getText()
if (typeName !== 'I18nKeysByPrefix' && typeName !== 'I18nKeysWithPrefix')
return undefined
const prefix = getStringLiteralTypeValue(unwrapped.type.typeArguments?.[1])
if (!prefix)
return undefined
return {
kind: 'patterns',
patterns: [{ prefix, suffix: '' }],
}
}
function extractStringValuesFromType(type: ts.Type, seen = new Set<ts.Type>()): string[] | undefined {
if (seen.has(type))
return undefined
seen.add(type)
if (type.isStringLiteral())
return [type.value]
if (type.isUnion()) {
const values: string[] = []
for (const subtype of type.types) {
if (subtype.flags & (ts.TypeFlags.Undefined | ts.TypeFlags.Null | ts.TypeFlags.Void))
continue
const subtypeValues = extractStringValuesFromType(subtype, seen)
if (!subtypeValues)
return undefined
values.push(...subtypeValues)
if (values.length > MAX_EXPANDED_VALUES)
return undefined
}
return values.length ? uniqueSorted(values) : undefined
}
return undefined
}
function evaluateCheckerKeyUsage(expression: ts.Expression, checker?: ts.TypeChecker): KeyExpressionUsage | undefined {
if (!checker)
return undefined
const values = extractStringValuesFromType(checker.getTypeAtLocation(expression))
if (!values?.length)
return undefined
return { kind: 'keys', keys: values }
}
function isSelectorParamType(type: ts.Type, checker: ts.TypeChecker, seen = new Set<ts.Type>()): boolean {
if (seen.has(type))
return false
seen.add(type)
if (type.aliasSymbol?.getName() === 'SelectorParam')
return true
const constraint = checker.getBaseConstraintOfType(type)
return Boolean(constraint && constraint !== type && isSelectorParamType(constraint, checker, seen))
}
function selectorNamespacesFromTypes(types: Array<ts.Type | undefined>, catalog: Catalog) {
for (const type of types) {
const namespaceType = type?.aliasTypeArguments?.[0]
if (!namespaceType)
continue
const namespaces = extractStringValuesFromType(namespaceType)
?.map(namespace => normalizeNamespace(namespace, catalog))
.filter(namespace => catalog.keysByNamespace.has(namespace))
if (namespaces?.length && namespaces.length < catalog.keysByNamespace.size)
return uniqueSorted(namespaces)
}
return []
}
function selectorNamespacesFromTFunctionBrand(
type: ts.Type,
expression: ts.Node,
checker: ts.TypeChecker,
catalog: Catalog,
) {
const brand = type.getProperty('$TFunctionBrand')
if (!brand)
return []
const brandType = checker.getTypeOfSymbolAtLocation(brand, expression)
return uniqueSorted(
(extractStringValuesFromType(brandType) ?? [])
.map(namespace => normalizeNamespace(namespace, catalog))
.filter(namespace => catalog.keysByNamespace.has(namespace)),
)
}
function getCheckerTranslationFunctionInfo(expression: ts.Node, checker: ts.TypeChecker | undefined, catalog: Catalog): TranslationFunctionInfo | undefined {
if (!checker)
return undefined
const calleeType = checker.getTypeAtLocation(expression)
const brandedNamespaces = selectorNamespacesFromTFunctionBrand(calleeType, expression, checker, catalog)
if (brandedNamespaces.length) {
return {
namespaces: brandedNamespaces,
keyPrefix: undefined,
selectorArgumentIndex: 0,
}
}
for (const signature of calleeType.getCallSignatures()) {
for (const [parameterIndex, parameter] of signature.parameters.entries()) {
const parameterType = checker.getTypeOfSymbolAtLocation(parameter, expression)
const constraint = checker.getBaseConstraintOfType(parameterType)
if (!isSelectorParamType(parameterType, checker))
continue
return {
namespaces: selectorNamespacesFromTypes([calleeType, parameterType, constraint], catalog),
keyPrefix: undefined,
selectorArgumentIndex: parameterIndex,
}
}
}
return undefined
}
function combineKeyExpressionUsages(left: KeyExpressionUsage, right: KeyExpressionUsage): KeyExpressionUsage {
if (left.kind === 'unknown' || right.kind === 'unknown')
return { kind: 'unknown' }
const keys = [
...(left.kind === 'keys' || left.kind === 'mixed' ? left.keys : []),
...(right.kind === 'keys' || right.kind === 'mixed' ? right.keys : []),
]
const patterns = [
...(left.kind === 'patterns' || left.kind === 'mixed' ? left.patterns : []),
...(right.kind === 'patterns' || right.kind === 'mixed' ? right.patterns : []),
]
if (keys.length && patterns.length)
return { kind: 'mixed', keys: uniqueSorted(keys), patterns }
if (keys.length)
return { kind: 'keys', keys: uniqueSorted(keys) }
return { kind: 'patterns', patterns }
}
function getSelectorNamespace(source: ts.Expression, parameterName: string) {
const unwrapped = unwrapExpression(source)
if (
ts.isPropertyAccessExpression(unwrapped)
&& ts.isIdentifier(unwrapped.expression)
&& unwrapped.expression.text === parameterName
) {
return unwrapped.name.text
}
if (
ts.isElementAccessExpression(unwrapped)
&& ts.isIdentifier(unwrapped.expression)
&& unwrapped.expression.text === parameterName
&& unwrapped.argumentExpression
&& isStringNode(unwrapExpression(unwrapped.argumentExpression))
) {
return (unwrapExpression(unwrapped.argumentExpression) as ts.StringLiteralLike).text
}
return undefined
}
function getSelectorKeyExpression(expression: ts.Expression) {
const selector = unwrapExpression(expression)
if (!ts.isArrowFunction(selector) || ts.isBlock(selector.body))
return undefined
const parameter = selector.parameters[0]
if (!parameter || !ts.isIdentifier(parameter.name))
return undefined
const body = unwrapExpression(selector.body)
let keyExpression: ts.Expression | undefined
let sourceExpression: ts.Expression | undefined
if (ts.isPropertyAccessExpression(body)) {
keyExpression = ts.factory.createStringLiteral(body.name.text)
sourceExpression = body.expression
}
else if (ts.isElementAccessExpression(body) && body.argumentExpression) {
keyExpression = body.argumentExpression
sourceExpression = body.expression
}
if (!keyExpression || !sourceExpression)
return undefined
const source = unwrapExpression(sourceExpression)
if (ts.isIdentifier(source) && source.text === parameter.name.text)
return { keyExpression }
const namespace = getSelectorNamespace(source, parameter.name.text)
if (namespace) {
return {
keyExpression,
namespace,
}
}
return undefined
}
function prependNamespace(usage: KeyExpressionUsage, namespace?: string): KeyExpressionUsage {
if (!namespace)
return usage
if (usage.kind === 'unknown')
return { kind: 'unknown', namespace }
const prefix = `${namespace}:`
if (usage.kind === 'keys')
return { kind: 'keys', keys: usage.keys.map(key => `${prefix}${key}`) }
if (usage.kind === 'patterns') {
return {
kind: 'patterns',
patterns: usage.patterns.map(pattern => ({
prefix: `${prefix}${pattern.prefix}`,
suffix: pattern.suffix,
})),
}
}
return {
kind: 'mixed',
keys: usage.keys.map(key => `${prefix}${key}`),
patterns: usage.patterns.map(pattern => ({
prefix: `${prefix}${pattern.prefix}`,
suffix: pattern.suffix,
})),
}
}
function evaluateKeyExpression(expression: ts.Expression, scopes: Scope[], checker?: ts.TypeChecker): KeyExpressionUsage {
const selector = getSelectorKeyExpression(expression)
if (selector) {
return prependNamespace(
evaluateKeyExpression(selector.keyExpression, scopes, checker),
selector.namespace,
)
}
const value = evaluateStaticExpression(expression, scopes)
if (value.kind === 'strings')
return { kind: 'keys', keys: value.values }
if (value.kind === 'selector')
return evaluateKeyExpression(value.expression, scopes, checker)
if (value.kind === 'selectors') {
return value.expressions
.map(selectorExpression => evaluateKeyExpression(selectorExpression, scopes, checker))
.reduce(combineKeyExpressionUsages)
}
const assertedTypeUsage = evaluateAssertedTypeKeyUsage(expression)
if (assertedTypeUsage)
return assertedTypeUsage
const checkerUsage = evaluateCheckerKeyUsage(expression, checker)
if (checkerUsage)
return checkerUsage
const unwrapped = unwrapExpression(expression)
if (ts.isConditionalExpression(unwrapped)) {
return combineKeyExpressionUsages(
evaluateKeyExpression(unwrapped.whenTrue, scopes, checker),
evaluateKeyExpression(unwrapped.whenFalse, scopes, checker),
)
}
if (!ts.isTemplateExpression(unwrapped))
return { kind: 'unknown' }
const spans = unwrapped.templateSpans
const unknownIndexes = spans
.map((span, index) => evaluateStaticExpression(span.expression, scopes).kind === 'strings' ? -1 : index)
.filter(index => index >= 0)
if (!unknownIndexes.length)
return { kind: 'unknown' }
const firstUnknownIndex = unknownIndexes[0]!
const lastUnknownIndex = unknownIndexes.at(-1)!
let prefixes = [unwrapped.head.text]
for (let index = 0; index < firstUnknownIndex; index++) {
const span = spans[index]!
const spanValue = evaluateStaticExpression(span.expression, scopes)
if (spanValue.kind !== 'strings')
break
prefixes = combineStringValues(prefixes, spanValue.values)
if (!prefixes.length)
return { kind: 'unknown' }
prefixes = prefixes.map(prefix => `${prefix}${span.literal.text}`)
}
let suffixes = [spans[lastUnknownIndex]!.literal.text]
for (let index = lastUnknownIndex + 1; index < spans.length; index++) {
const span = spans[index]!
const spanValue = evaluateStaticExpression(span.expression, scopes)
if (spanValue.kind !== 'strings') {
suffixes = ['']
}
else {
suffixes = combineStringValues(suffixes, spanValue.values)
if (!suffixes.length)
suffixes = ['']
}
suffixes = suffixes.map(suffix => `${suffix}${span.literal.text}`)
}
return {
kind: 'patterns',
patterns: prefixes.flatMap(prefix => suffixes.map(suffix => ({ prefix, suffix }))),
}
}
function prependKeyPrefix(usage: KeyExpressionUsage, keyPrefix?: string): KeyExpressionUsage {
if (!keyPrefix)
return usage
const prefix = `${keyPrefix}.`
if (usage.kind === 'keys')
return { kind: 'keys', keys: usage.keys.map(key => `${prefix}${key}`) }
if (usage.kind === 'patterns') {
return {
kind: 'patterns',
patterns: usage.patterns.map(pattern => ({
prefix: `${prefix}${pattern.prefix}`,
suffix: pattern.suffix,
})),
}
}
return { kind: 'patterns', patterns: [{ prefix, suffix: '' }] }
}
function getLastOptionsObject(callExpression: ts.CallExpression) {
for (let index = callExpression.arguments.length - 1; index >= 1; index--) {
const argument = unwrapExpression(callExpression.arguments[index]! as ts.Expression)
if (ts.isObjectLiteralExpression(argument))
return argument
}
return undefined
}
function getUseTranslationInfo(callExpression: ts.CallExpression, scopes: Scope[], catalog: Catalog): TranslationFunctionInfo {
const namespaces = evaluateNamespaceExpression(callExpression.arguments[0] as ts.Expression | undefined, scopes, catalog)
const options = callExpression.arguments[1]
const optionsObject = options ? unwrapExpression(options as ts.Expression) : undefined
const keyPrefixProperty = optionsObject && ts.isObjectLiteralExpression(optionsObject)
? getObjectProperty(optionsObject, 'keyPrefix')
: undefined
const keyPrefixValue = keyPrefixProperty
? evaluateStaticExpression(keyPrefixProperty.initializer, scopes)
: undefined
return {
namespaces: callExpression.arguments[0] ? namespaces : [DEFAULT_NAMESPACE],
keyPrefix: keyPrefixValue?.kind === 'strings' && keyPrefixValue.values.length === 1
? keyPrefixValue.values[0]
: undefined,
}
}
function addExactKey(collector: UsageCollector, namespace: string, key: string, catalog: Catalog) {
if (!collector.exactKeys.has(namespace))
collector.exactKeys.set(namespace, new Set())
const exactKeys = collector.exactKeys.get(namespace)!
exactKeys.add(key)
const namespaceKeys = catalog.keysByNamespace.get(namespace)
if (!namespaceKeys)
return
for (const suffix of PLURAL_SUFFIXES) {
const pluralKey = `${key}${suffix}`
if (namespaceKeys.has(pluralKey))
exactKeys.add(pluralKey)
}
}
function addPattern(collector: UsageCollector, namespace: string, pattern: { prefix: string, suffix: string }, location: UsageLocation) {
if (!pattern.prefix && !pattern.suffix) {
collector.protectedNamespaces.add(namespace)
return
}
collector.patterns.push({
namespace,
prefix: pattern.prefix,
suffix: pattern.suffix,
...location,
})
}
function splitNamespaceKey(key: string, catalog: Catalog) {
const separatorIndex = key.indexOf(':')
if (separatorIndex <= 0)
return undefined
const namespace = normalizeNamespace(key.slice(0, separatorIndex), catalog)
if (!catalog.keysByNamespace.has(namespace))
return undefined
return {
namespace,
key: key.slice(separatorIndex + 1),
}
}
function inferNamespacesForKey(key: string, catalog: Catalog) {
const namespaces: string[] = []
for (const [namespace, keys] of catalog.keysByNamespace.entries()) {
if (keys.has(key) || PLURAL_SUFFIXES.some(suffix => keys.has(`${key}${suffix}`)))
namespaces.push(namespace)
}
return uniqueSorted(namespaces)
}
function patternMatchesKey(pattern: { prefix: string, suffix: string }, key: string) {
return key.startsWith(pattern.prefix) && key.endsWith(pattern.suffix)
}
function inferNamespacesForPattern(pattern: { prefix: string, suffix: string }, catalog: Catalog) {
const namespaces: string[] = []
for (const [namespace, keys] of catalog.keysByNamespace.entries()) {
if (Array.from(keys).some(key => patternMatchesKey(pattern, key)))
namespaces.push(namespace)
}
return uniqueSorted(namespaces)
}
function locationFor(node: ts.Node, sourceFile: ts.SourceFile): UsageLocation {
return {
file: toPosixPath(path.relative(process.cwd(), sourceFile.fileName)),
line: sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1,
expression: node.getText(sourceFile),
}
}
function recordUnknownUsage(
collector: UsageCollector,
namespaces: string[],
catalog: Catalog,
location: UsageLocation,
reason: string,
) {
const targetNamespaces = namespaces.length ? namespaces : Array.from(catalog.keysByNamespace.keys())
for (const namespace of targetNamespaces)
collector.protectedNamespaces.add(namespace)
collector.unresolvedUsages.push({
...location,
namespace: namespaces.length === 1 ? namespaces[0] : undefined,
reason,
})
}
function recordKeyUsage(
collector: UsageCollector,
usage: KeyExpressionUsage,
namespaces: string[],
catalog: Catalog,
location: UsageLocation,
) {
if (usage.kind === 'unknown') {
const targetNamespaces = usage.namespace
? [normalizeNamespace(usage.namespace, catalog)]
: namespaces
recordUnknownUsage(collector, targetNamespaces, catalog, location, 'Unable to statically resolve translation key')
return
}
if (usage.kind === 'keys' || usage.kind === 'mixed') {
for (const rawKey of usage.keys) {
const namespacedKey = splitNamespaceKey(rawKey, catalog)
if (namespacedKey) {
addExactKey(collector, namespacedKey.namespace, namespacedKey.key, catalog)
continue
}
const targetNamespaces = namespaces.length ? namespaces : inferNamespacesForKey(rawKey, catalog)
if (!targetNamespaces.length) {
collector.unresolvedUsages.push({ ...location, reason: `No namespace contains key "${rawKey}"` })
continue
}
for (const namespace of targetNamespaces)
addExactKey(collector, namespace, rawKey, catalog)
}
}
if (usage.kind === 'keys')
return
const patterns = usage.kind === 'mixed' ? usage.patterns : usage.patterns
for (const rawPattern of patterns) {
const namespacedPattern = splitNamespaceKey(rawPattern.prefix, catalog)
if (namespacedPattern) {
addPattern(collector, namespacedPattern.namespace, {
prefix: namespacedPattern.key,
suffix: rawPattern.suffix,
}, location)
continue
}
const targetNamespaces = namespaces.length ? namespaces : inferNamespacesForPattern(rawPattern, catalog)
if (!targetNamespaces.length)
continue
for (const namespace of targetNamespaces)
addPattern(collector, namespace, rawPattern, location)
}
}
function getJsxAttribute(element: ts.JsxOpeningLikeElement, name: string) {
return element.attributes.properties.find((property): property is ts.JsxAttribute => {
return ts.isJsxAttribute(property) && ts.isIdentifier(property.name) && property.name.text === name
})
}
function getJsxAttributeExpression(attribute: ts.JsxAttribute | undefined) {
if (!attribute?.initializer)
return undefined
if (isStringNode(attribute.initializer))
return attribute.initializer
if (ts.isJsxExpression(attribute.initializer) && attribute.initializer.expression)
return attribute.initializer.expression
return undefined
}
function isSelectorTranslationForwarder(
node: ts.CallExpression,
keyExpression: ts.Expression,
isRegisteredSelectorCallable: boolean,
checker: ts.TypeChecker | undefined,
catalog: Catalog,
) {
if (!ts.isIdentifier(keyExpression))
return false
let current: ts.Node | undefined = node.parent
let functionLike: ts.FunctionLikeDeclaration | undefined
while (current && !ts.isSourceFile(current)) {
if (isFunctionLikeWithParameters(current)) {
functionLike = current
break
}
current = current.parent
}
const selectorParameter = functionLike?.parameters.find((parameter) => {
return ts.isIdentifier(parameter.name) && parameter.name.text === keyExpression.text
})
const forwardsSelectorParameter = Boolean(
selectorParameter
&& checker
&& isSelectorParamType(checker.getTypeAtLocation(selectorParameter), checker),
)
const calleeIsSelectorCallable = isRegisteredSelectorCallable
|| Boolean(getCheckerTranslationFunctionInfo(node.expression, checker, catalog))
return Boolean(
forwardsSelectorParameter
&& calleeIsSelectorCallable,
)
}
function analyzeSourceFile(filePath: string, catalog: Catalog, collector: UsageCollector, checker?: ts.TypeChecker, sourceFileFromProgram?: ts.SourceFile) {
const sourceFile = sourceFileFromProgram ?? (() => {
const sourceText = fs.readFileSync(filePath, 'utf8')
const scriptKind = filePath.endsWith('.tsx') || filePath.endsWith('.jsx')
? ts.ScriptKind.TSX
: ts.ScriptKind.TS
return ts.createSourceFile(filePath, sourceText, ts.ScriptTarget.Latest, true, scriptKind)
})()
const scopes: Scope[] = [{ values: new Map(), translationFunctions: new Map() }]
const withScope = (callback: () => void) => {
scopes.push({ values: new Map(), translationFunctions: new Map() })
callback()
scopes.pop()
}
function visit(node: ts.Node) {
if (isFunctionLikeWithParameters(node)) {
withScope(() => {
const currentScope = scopes.at(-1)!
for (const parameter of node.parameters)
handleFunctionParameter(parameter, currentScope)
ts.forEachChild(node, visit)
})
return
}
if (ts.isBlock(node)) {
withScope(() => ts.forEachChild(node, visit))
return
}
if (ts.isImportDeclaration(node))
handleImportDeclaration(node)
if (ts.isVariableDeclaration(node)) {
handleVariableDeclaration(node)
}
if (isStringNode(node))
handleContextualI18nLiteral(node)
if (ts.isCallExpression(node))
handleCallExpression(node)
if (ts.isJsxSelfClosingElement(node) || ts.isJsxOpeningElement(node))
handleJsxElement(node)
ts.forEachChild(node, visit)
}
function handleImportDeclaration(node: ts.ImportDeclaration) {
if (!ts.isStringLiteral(node.moduleSpecifier) || node.moduleSpecifier.text !== 'i18next')
return
const namedBindings = node.importClause?.namedBindings
if (!namedBindings || !ts.isNamedImports(namedBindings))
return
const currentScope = scopes.at(-1)!
for (const element of namedBindings.elements) {
const importedName = element.propertyName?.text ?? element.name.text
if (importedName === 't')
addTranslationFunction(currentScope, element.name.text)
}
}
function handleFunctionParameter(node: ts.ParameterDeclaration, scope: Scope) {
const translationInfoFromType = getTranslationFunctionInfoFromType(node.type, catalog)
if (ts.isIdentifier(node.name)) {
const checkerTranslationInfo = getCheckerTranslationFunctionInfo(node.name, checker, catalog)
const translationInfo = checkerTranslationInfo ?? translationInfoFromType
if (node.name.text === 't' || translationInfo)
addTranslationFunction(scope, node.name.text, translationInfo)
return
}
if (!ts.isObjectBindingPattern(node.name))
return
for (const element of node.name.elements) {
if (!ts.isIdentifier(element.name))
continue
const propertyName = element.propertyName && ts.isIdentifier(element.propertyName)
? element.propertyName.text
: undefined
if (propertyName === 't' || element.name.text === 't') {
const translationInfo = getCheckerTranslationFunctionInfo(element.name, checker, catalog)
addTranslationFunction(scope, element.name.text, translationInfo)
}
}
}
function handleContextualI18nLiteral(node: ts.StringLiteral | ts.NoSubstitutionTemplateLiteral) {
if (!checker || (!node.text.includes('.') && !node.text.includes(':')))
return
const contextualType = checker.getContextualType(node)
if (!contextualType)
return
if (!/\bI18nKeys?\b|\bI18nKeys?(?:By|With)Prefix\b/.test(checker.typeToString(contextualType)))
return
const contextualKeys = extractStringValuesFromType(contextualType)
if (!contextualKeys?.includes(node.text))
return
recordKeyUsage(collector, { kind: 'keys', keys: [node.text] }, [], catalog, locationFor(node, sourceFile))
}
function handleVariableDeclaration(node: ts.VariableDeclaration) {
if (!node.initializer)
return
const currentScope = scopes.at(-1)!
if (ts.isIdentifier(node.name))
currentScope.values.set(node.name.text, evaluateStaticExpression(node.initializer, scopes))
if (!ts.isObjectBindingPattern(node.name))
return
const initializer = unwrapExpression(node.initializer)
const callExpression = ts.isAwaitExpression(initializer)
? unwrapExpression(initializer.expression)
: initializer
if (!ts.isCallExpression(callExpression) || !ts.isIdentifier(callExpression.expression))
return
const calleeName = callExpression.expression.text
if (calleeName !== 'useTranslation' && calleeName !== 'getTranslation')
return
const translationInfo = calleeName === 'useTranslation'
? getUseTranslationInfo(callExpression, scopes, catalog)
: {
namespaces: callExpression.arguments[1]
? evaluateNamespaceExpression(callExpression.arguments[1] as ts.Expression, scopes, catalog)
: [DEFAULT_NAMESPACE],
keyPrefix: undefined,
}
for (const element of node.name.elements) {
const propertyName = element.propertyName && ts.isIdentifier(element.propertyName)
? element.propertyName.text
: ts.isIdentifier(element.name) ? element.name.text : undefined
if (propertyName !== 't' || !ts.isIdentifier(element.name))
continue
currentScope.translationFunctions.set(element.name.text, translationInfo)
}
}
function handleCallExpression(node: ts.CallExpression) {
const registeredIdentifierTranslationInfo = ts.isIdentifier(node.expression)
? lookupTranslationFunction(node.expression.text, scopes)
: undefined
const registeredTranslationInfo = registeredIdentifierTranslationInfo
?? (ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === 't'
? { namespaces: [DEFAULT_NAMESPACE], keyPrefix: undefined }
: undefined)
const translationInfo = registeredTranslationInfo
?? getCheckerTranslationFunctionInfo(node.expression, checker, catalog)
if (!translationInfo || !node.arguments.length)
return
const options = getLastOptionsObject(node)
const namespaceProperty = options ? getObjectProperty(options, 'ns') : undefined
const optionNamespaces = namespaceProperty
? evaluateNamespaceExpression(namespaceProperty.initializer, scopes, catalog)
: []
const namespaces = optionNamespaces.length ? optionNamespaces : translationInfo.namespaces
const keyExpression = node.arguments[translationInfo.selectorArgumentIndex ?? 0] as ts.Expression | undefined
if (!keyExpression)
return
const keyUsage = prependKeyPrefix(evaluateKeyExpression(keyExpression, scopes, checker), translationInfo.keyPrefix)
if (
keyUsage.kind === 'unknown'
&& isSelectorTranslationForwarder(node, keyExpression, Boolean(registeredIdentifierTranslationInfo), checker, catalog)
) {
return
}
recordKeyUsage(collector, keyUsage, namespaces, catalog, locationFor(node, sourceFile))
}
function handleJsxElement(node: ts.JsxOpeningLikeElement) {
if (node.tagName.getText(sourceFile) !== 'Trans')
return
const keyExpression = getJsxAttributeExpression(getJsxAttribute(node, 'i18nKey'))
if (!keyExpression)
return
const namespaceExpression = getJsxAttributeExpression(getJsxAttribute(node, 'ns'))
const namespaces = namespaceExpression
? evaluateNamespaceExpression(namespaceExpression, scopes, catalog)
: [DEFAULT_NAMESPACE]
const keyUsage = evaluateKeyExpression(keyExpression, scopes, checker)
recordKeyUsage(collector, keyUsage, namespaces, catalog, locationFor(node, sourceFile))
}
visit(sourceFile)
}
function getCatalog(webRoot: string, defaultLocale: string, targetFiles: string[] = []): Catalog {
const localeDir = path.join(webRoot, 'i18n', defaultLocale)
const targetFileNames = new Set(targetFiles.map(file => file.replace(/\.json$/, '')))
const targetNamespaces = new Set(targetFiles.map(file => fileNameToNamespace(file.replace(/\.json$/, ''))))
const keysByNamespace = new Map<string, Set<string>>()
const fileNameByNamespace = new Map<string, string>()
const namespaceByFileName = new Map<string, string>()
for (const file of fs.readdirSync(localeDir).filter(file => file.endsWith('.json')).sort()) {
const fileName = file.replace(/\.json$/, '')
const namespace = fileNameToNamespace(fileName)
if (targetFiles.length && !targetFileNames.has(fileName) && !targetNamespaces.has(namespace))
continue
const content = JSON.parse(fs.readFileSync(path.join(localeDir, file), 'utf8')) as Record<string, unknown>
keysByNamespace.set(namespace, new Set(Object.keys(content)))
fileNameByNamespace.set(namespace, fileName)
namespaceByFileName.set(fileName, namespace)
}
return { keysByNamespace, fileNameByNamespace, namespaceByFileName }
}
function listSourceFiles(webRoot: string, explicitSourceFiles?: string[]) {
if (explicitSourceFiles?.length)
return explicitSourceFiles.map(file => path.isAbsolute(file) ? file : path.join(webRoot, file))
const sourceFiles: string[] = []
const walk = (directory: string) => {
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
if (entry.isDirectory()) {
if (!SKIPPED_DIRECTORIES.has(entry.name))
walk(path.join(directory, entry.name))
continue
}
if (!entry.isFile())
continue
const filePath = path.join(directory, entry.name)
if (filePath.endsWith('.d.ts'))
continue
if (SOURCE_EXTENSIONS.has(path.extname(entry.name)))
sourceFiles.push(filePath)
}
}
walk(webRoot)
return sourceFiles.sort()
}
function mapToRecord(map: Map<string, Set<string>>) {
const record: Record<string, string[]> = {}
for (const [namespace, values] of map.entries()) {
const sortedValues = uniqueSorted(values)
if (sortedValues.length)
record[namespace] = sortedValues
}
return record
}
function createTypeChecker(webRoot: string, sourceFiles: string[]) {
try {
const configPath = ts.findConfigFile(webRoot, ts.sys.fileExists, 'tsconfig.json')
const config = configPath
? ts.readConfigFile(configPath, ts.sys.readFile)
: undefined
const parsedConfig = configPath && config && !config.error
? ts.parseJsonConfigFileContent(config.config, ts.sys, webRoot)
: undefined
const compilerOptions: ts.CompilerOptions = {
...(parsedConfig?.options ?? {}),
allowJs: true,
jsx: ts.JsxEmit.ReactJSX,
noEmit: true,
skipLibCheck: true,
}
const program = ts.createProgram({
rootNames: sourceFiles,
options: compilerOptions,
})
return {
checker: program.getTypeChecker(),
sourceFileByPath: new Map(program.getSourceFiles().map(sourceFile => [path.resolve(sourceFile.fileName), sourceFile])),
}
}
catch {
return undefined
}
}
export async function analyzeUnusedTranslations(options: AnalyzeUnusedTranslationsOptions = {}): Promise<AnalyzeUnusedTranslationsResult> {
const webRoot = options.webRoot ?? path.resolve(fileURLToPath(new URL('../..', import.meta.url)))
const defaultLocale = options.defaultLocale ?? DEFAULT_LOCALE
const catalog = getCatalog(webRoot, defaultLocale, options.files)
const sourceFiles = listSourceFiles(webRoot, options.sourceFiles)
const typeChecker = createTypeChecker(webRoot, sourceFiles)
const collector: UsageCollector = {
exactKeys: new Map(),
patterns: [],
protectedNamespaces: new Set(),
unresolvedUsages: [],
}
for (const file of sourceFiles)
analyzeSourceFile(file, catalog, collector, typeChecker?.checker, typeChecker?.sourceFileByPath.get(path.resolve(file)))
const unusedKeys = new Map<string, Set<string>>()
for (const [namespace, keys] of catalog.keysByNamespace.entries()) {
if (collector.protectedNamespaces.has(namespace))
continue
const exactKeys = collector.exactKeys.get(namespace) ?? new Set<string>()
const patterns = collector.patterns.filter(pattern => pattern.namespace === namespace)
for (const key of keys) {
if (exactKeys.has(key) || patterns.some(pattern => patternMatchesKey(pattern, key)))
continue
if (!unusedKeys.has(namespace))
unusedKeys.set(namespace, new Set())
unusedKeys.get(namespace)!.add(key)
}
}
return {
webRoot,
defaultLocale,
unusedKeysByNamespace: mapToRecord(unusedKeys),
usedKeysByNamespace: mapToRecord(collector.exactKeys),
allKeysByNamespace: mapToRecord(catalog.keysByNamespace),
dynamicKeyPatterns: collector.patterns.sort((left, right) => {
return `${left.namespace}:${left.prefix}:${left.suffix}`.localeCompare(`${right.namespace}:${right.prefix}:${right.suffix}`)
}),
protectedNamespaces: uniqueSorted(collector.protectedNamespaces),
unresolvedUsages: collector.unresolvedUsages,
namespaceFiles: Object.fromEntries(catalog.fileNameByNamespace.entries()),
}
}
function listLocales(webRoot: string) {
return fs.readdirSync(path.join(webRoot, 'i18n'), { withFileTypes: true })
.filter(entry => entry.isDirectory())
.map(entry => entry.name)
.sort()
}
export async function removeUnusedTranslations(options: RemoveUnusedTranslationsOptions): Promise<RemoveUnusedTranslationsResult> {
const webRoot = options.webRoot ?? options.analysis.webRoot
const locales = options.locales?.length ? options.locales : listLocales(webRoot)
const removedKeys: RemovedKey[] = []
for (const locale of locales) {
for (const [namespace, keys] of Object.entries(options.analysis.unusedKeysByNamespace).sort()) {
const fileName = options.analysis.namespaceFiles[namespace] ?? namespaceToFileName(namespace, {
keysByNamespace: new Map(),
fileNameByNamespace: new Map(Object.entries(options.analysis.namespaceFiles).map(([ns, file]) => [ns, file])),
namespaceByFileName: new Map(),
})
const filePath = path.join(webRoot, 'i18n', locale, `${fileName}.json`)
if (!fs.existsSync(filePath))
continue
const content = JSON.parse(fs.readFileSync(filePath, 'utf8')) as Record<string, unknown>
let modified = false
for (const key of keys) {
if (!(key in content))
continue
delete content[key]
modified = true
removedKeys.push({ locale, namespace, key })
}
if (modified)
fs.writeFileSync(filePath, `${JSON.stringify(content, null, 2)}\n`, 'utf8')
}
}
return { removedKeys }
}