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 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> fileNameByNamespace: Map namespaceByFileName: Map } export type AnalyzeUnusedTranslationsOptions = { webRoot?: string defaultLocale?: string files?: string[] sourceFiles?: string[] } export type AnalyzeUnusedTranslationsResult = { webRoot: string defaultLocale: string unusedKeysByNamespace: Record usedKeysByNamespace: Record allKeysByNamespace: Record dynamicKeyPatterns: DynamicKeyPattern[] protectedNamespaces: string[] unresolvedUsages: UnresolvedUsage[] namespaceFiles: Record } 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> patterns: DynamicKeyPattern[] protectedNamespaces: Set unresolvedUsages: UnresolvedUsage[] } type Scope = { values: Map translationFunctions: Map } 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) { 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() 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 => 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(), ): 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(), ): 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, 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>() const fileNameByNamespace = new Map() const namespaceByFileName = new Map() 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>) { const record: Record = {} 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 { 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>() for (const [namespace, keys] of catalog.keysByNamespace.entries()) { if (collector.protectedNamespaces.has(namespace)) continue const exactKeys = collector.exactKeys.get(namespace) ?? new Set() 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 { 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 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 } }