mirror of
https://github.com/langgenius/dify.git
synced 2026-07-21 18:58:35 +08:00
1613 lines
51 KiB
TypeScript
1613 lines
51 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 }
|
|
}
|