dify/web/scripts/migrate-i18n-selectors.ts
Stephen Zhou a84c2d36a3
style: format with vp fmt (#38803)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-12 15:57:46 +00:00

1187 lines
39 KiB
TypeScript

import fs from 'node:fs/promises'
import path from 'node:path'
import { pathToFileURL } from 'node:url'
import * as ts from 'typescript'
type TextEdit = {
end: number
replacement: string
start: number
}
export type TransformSourceResult = {
changes: number
output: string
}
const I18N_MODULES = new Set(['#i18n', 'i18next', 'react-i18next'])
const MOCK_PROVIDER_METHODS = new Set([
'mockImplementation',
'mockImplementationOnce',
'mockReturnValue',
'mockReturnValueOnce',
])
const SKIPPED_DIRECTORIES = new Set([
'.next',
'.turbo',
'.vinext',
'coverage',
'dist',
'i18n',
'node_modules',
'public',
])
const SKIPPED_FILES = new Set(['migrate-i18n-selectors.spec.ts'])
const SOURCE_EXTENSIONS = new Set(['.js', '.jsx', '.ts', '.tsx'])
const TRANSLATION_FACTORIES = new Set(['getTranslation', 'useTranslation'])
function unwrapExpression(expression: ts.Expression): ts.Expression {
let current = expression
while (
ts.isAsExpression(current) ||
ts.isNonNullExpression(current) ||
ts.isParenthesizedExpression(current) ||
ts.isSatisfiesExpression(current) ||
ts.isTypeAssertionExpression(current)
) {
current = current.expression
}
return current
}
type ImportBinding = {
importedName: string
moduleName: string
}
function createSourceAnalysis(source: string, fileName: string, scriptKind: ts.ScriptKind) {
const resolvedFileName = path.resolve(fileName)
const sourceFile = ts.createSourceFile(
resolvedFileName,
source,
ts.ScriptTarget.Latest,
true,
scriptKind,
)
const compilerOptions: ts.CompilerOptions = {
allowJs: true,
jsx: ts.JsxEmit.Preserve,
noLib: true,
noResolve: true,
target: ts.ScriptTarget.Latest,
}
const defaultHost = ts.createCompilerHost(compilerOptions)
const host: ts.CompilerHost = {
...defaultHost,
fileExists: (candidate) => path.resolve(candidate) === resolvedFileName,
getSourceFile: (candidate) =>
path.resolve(candidate) === resolvedFileName ? sourceFile : undefined,
readFile: (candidate) => (path.resolve(candidate) === resolvedFileName ? source : undefined),
}
const program = ts.createProgram([resolvedFileName], compilerOptions, host)
return { checker: program.getTypeChecker(), sourceFile }
}
function getImportBinding(declaration: ts.Declaration): ImportBinding | undefined {
let current: ts.Node | undefined = declaration
while (current && !ts.isImportDeclaration(current)) current = current.parent
if (!current || !ts.isStringLiteral(current.moduleSpecifier)) return undefined
if (ts.isImportSpecifier(declaration)) {
return {
importedName: declaration.propertyName?.text ?? declaration.name.text,
moduleName: current.moduleSpecifier.text,
}
}
if (ts.isImportClause(declaration)) {
return {
importedName: 'default',
moduleName: current.moduleSpecifier.text,
}
}
if (ts.isNamespaceImport(declaration)) {
return {
importedName: '*',
moduleName: current.moduleSpecifier.text,
}
}
return undefined
}
function getDeclarations(identifier: ts.Identifier, checker: ts.TypeChecker) {
return checker.getSymbolAtLocation(identifier)?.declarations ?? []
}
function isImportedBinding(
identifier: ts.Identifier,
checker: ts.TypeChecker,
importedName: string,
isAllowedModule: (moduleName: string) => boolean,
) {
return getDeclarations(identifier, checker).some((declaration) => {
const binding = getImportBinding(declaration)
return binding?.importedName === importedName && isAllowedModule(binding.moduleName)
})
}
function isTranslationFactoryIdentifier(identifier: ts.Identifier, checker: ts.TypeChecker) {
return getDeclarations(identifier, checker).some((declaration) => {
const binding = getImportBinding(declaration)
return Boolean(
binding &&
TRANSLATION_FACTORIES.has(binding.importedName) &&
(I18N_MODULES.has(binding.moduleName) || binding.moduleName.toLowerCase().includes('i18n')),
)
})
}
function isGetI18nIdentifier(identifier: ts.Identifier, checker: ts.TypeChecker) {
return isImportedBinding(identifier, checker, 'getI18n', (moduleName) =>
I18N_MODULES.has(moduleName),
)
}
function isTranslationFactoryCall(expression: ts.Expression, checker: ts.TypeChecker) {
const unwrapped = ts.isAwaitExpression(expression)
? unwrapExpression(expression.expression)
: unwrapExpression(expression)
return (
ts.isCallExpression(unwrapped) &&
ts.isIdentifier(unwrapped.expression) &&
isTranslationFactoryIdentifier(unwrapped.expression, checker)
)
}
function getBindingElementPropertyName(declaration: ts.BindingElement) {
if (
declaration.propertyName &&
(ts.isIdentifier(declaration.propertyName) || ts.isStringLiteral(declaration.propertyName))
)
return declaration.propertyName.text
if (ts.isIdentifier(declaration.name)) return declaration.name.text
return undefined
}
function findAncestor<T extends ts.Node>(
node: ts.Node,
predicate: (candidate: ts.Node) => candidate is T,
): T | undefined {
let current: ts.Node | undefined = node.parent
while (current) {
if (predicate(current)) return current
current = current.parent
}
return undefined
}
function hasTranslationFunctionType(parameter: ts.ParameterDeclaration, sourceFile: ts.SourceFile) {
const typeText = parameter.type?.getText(sourceFile) ?? ''
return (
/\b(?:TFunction|useTranslation)\b|(?:Translate|Translator)\b/.test(typeText) ||
typeText.trim() === 'any'
)
}
function isTranslationFunctionDeclaration(
declaration: ts.Declaration,
checker: ts.TypeChecker,
sourceFile: ts.SourceFile,
) {
const importBinding = getImportBinding(declaration)
if (importBinding)
return importBinding.importedName === 't' && I18N_MODULES.has(importBinding.moduleName)
if (ts.isBindingElement(declaration)) {
if (getBindingElementPropertyName(declaration) !== 't') return false
const variableDeclaration = findAncestor(declaration, ts.isVariableDeclaration)
if (variableDeclaration?.initializer)
return isTranslationFactoryCall(variableDeclaration.initializer, checker)
const parameter = findAncestor(declaration, ts.isParameter)
return Boolean(parameter && hasTranslationFunctionType(parameter, sourceFile))
}
if (ts.isVariableDeclaration(declaration) && declaration.initializer) {
const initializer = unwrapExpression(declaration.initializer)
return (
ts.isPropertyAccessExpression(initializer) &&
initializer.name.text === 't' &&
isTranslationFactoryCall(initializer.expression, checker)
)
}
return (
ts.isParameter(declaration) &&
ts.isIdentifier(declaration.name) &&
declaration.name.text === 't' &&
hasTranslationFunctionType(declaration, sourceFile)
)
}
function isTranslationFunctionIdentifier(
identifier: ts.Identifier,
checker: ts.TypeChecker,
sourceFile: ts.SourceFile,
) {
return getDeclarations(identifier, checker).some((declaration) =>
isTranslationFunctionDeclaration(declaration, checker, sourceFile),
)
}
function isI18nInstanceIdentifier(identifier: ts.Identifier, checker: ts.TypeChecker) {
const declarations = getDeclarations(identifier, checker)
if (!declarations.length) return identifier.text === 'i18n' || identifier.text === 'i18next'
return declarations.some((declaration) => {
const importBinding = getImportBinding(declaration)
if (importBinding)
return (
I18N_MODULES.has(importBinding.moduleName) &&
['*', 'default', 'i18n', 'i18next'].includes(importBinding.importedName)
)
if (ts.isBindingElement(declaration)) {
const variableDeclaration = findAncestor(declaration, ts.isVariableDeclaration)
return (
getBindingElementPropertyName(declaration) === 'i18n' &&
Boolean(
variableDeclaration?.initializer &&
isTranslationFactoryCall(variableDeclaration.initializer, checker),
)
)
}
if (!ts.isVariableDeclaration(declaration) || !declaration.initializer) return false
const initializer = unwrapExpression(declaration.initializer)
return (
ts.isCallExpression(initializer) &&
ts.isIdentifier(initializer.expression) &&
isGetI18nIdentifier(initializer.expression, checker)
)
})
}
function getTypeReferenceName(typeName: ts.EntityName): string {
return ts.isIdentifier(typeName) ? typeName.text : typeName.right.text
}
function hasSelectorType(typeNode: ts.TypeNode | undefined): boolean {
if (!typeNode) return false
if (ts.isParenthesizedTypeNode(typeNode) || ts.isTypeOperatorNode(typeNode))
return hasSelectorType(typeNode.type)
if (ts.isTypeReferenceNode(typeNode))
return ['SelectorKey', 'SelectorParam'].includes(getTypeReferenceName(typeNode.typeName))
if (ts.isUnionTypeNode(typeNode)) {
const meaningfulTypes = typeNode.types.filter(
(type) =>
type.kind !== ts.SyntaxKind.UndefinedKeyword &&
type.kind !== ts.SyntaxKind.NeverKeyword &&
!(ts.isLiteralTypeNode(type) && type.literal.kind === ts.SyntaxKind.NullKeyword),
)
return meaningfulTypes.length > 0 && meaningfulTypes.every(hasSelectorType)
}
return false
}
function hasSelectorCollectionType(typeNode: ts.TypeNode | undefined): boolean {
if (!typeNode) return false
if (ts.isParenthesizedTypeNode(typeNode) || ts.isTypeOperatorNode(typeNode))
return hasSelectorCollectionType(typeNode.type)
if (ts.isUnionTypeNode(typeNode)) return typeNode.types.some(hasSelectorCollectionType)
if (ts.isArrayTypeNode(typeNode)) return hasSelectorType(typeNode.elementType)
if (!ts.isTypeReferenceNode(typeNode)) return false
const typeName = getTypeReferenceName(typeNode.typeName)
const typeArguments = typeNode.typeArguments ?? []
if (typeName === 'Record') return hasSelectorType(typeArguments[1])
if (typeName === 'Array' || typeName === 'ReadonlyArray') return hasSelectorType(typeArguments[0])
return false
}
function hasCallableType(expression: ts.Expression, checker: ts.TypeChecker) {
const type = checker.getTypeAtLocation(expression)
if (type.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) return false
return checker.getSignaturesOfType(type, ts.SignatureKind.Call).length > 0
}
function isSelectorCollectionExpression(
expression: ts.Expression,
checker: ts.TypeChecker,
sourceFile: ts.SourceFile,
seenSymbols: Set<ts.Symbol>,
): boolean {
if (ts.isParenthesizedExpression(expression) || ts.isNonNullExpression(expression))
return isSelectorCollectionExpression(expression.expression, checker, sourceFile, seenSymbols)
if (
ts.isAsExpression(expression) ||
ts.isSatisfiesExpression(expression) ||
ts.isTypeAssertionExpression(expression)
) {
if (hasSelectorCollectionType(expression.type)) return true
return isSelectorCollectionExpression(expression.expression, checker, sourceFile, seenSymbols)
}
if (ts.isObjectLiteralExpression(expression)) {
const values = expression.properties.flatMap((property): ts.Expression[] => {
if (ts.isPropertyAssignment(property)) return [property.initializer]
if (ts.isShorthandPropertyAssignment(property)) return [property.name]
return []
})
return (
values.length > 0 &&
values.every((value) =>
isSelectorCompatibleExpression(value, checker, sourceFile, new Set(seenSymbols)),
)
)
}
if (ts.isArrayLiteralExpression(expression)) {
return (
expression.elements.length > 0 &&
expression.elements.every(
(element) =>
!ts.isSpreadElement(element) &&
isSelectorCompatibleExpression(element, checker, sourceFile, new Set(seenSymbols)),
)
)
}
if (!ts.isIdentifier(expression)) return false
const symbol = checker.getSymbolAtLocation(expression)
if (!symbol || seenSymbols.has(symbol)) return false
seenSymbols.add(symbol)
return (symbol.declarations ?? []).some((declaration) => {
if (ts.isVariableDeclaration(declaration)) {
return (
hasSelectorCollectionType(declaration.type) ||
Boolean(
declaration.initializer &&
isSelectorCollectionExpression(declaration.initializer, checker, sourceFile, seenSymbols),
)
)
}
if (
ts.isParameter(declaration) ||
ts.isPropertyDeclaration(declaration) ||
ts.isPropertySignature(declaration)
)
return hasSelectorCollectionType(declaration.type)
return false
})
}
function isSelectorCompatibleExpression(
expression: ts.Expression,
checker: ts.TypeChecker,
sourceFile: ts.SourceFile,
seenSymbols = new Set<ts.Symbol>(),
): boolean {
if (ts.isParenthesizedExpression(expression) || ts.isNonNullExpression(expression))
return isSelectorCompatibleExpression(expression.expression, checker, sourceFile, seenSymbols)
if (
ts.isAsExpression(expression) ||
ts.isSatisfiesExpression(expression) ||
ts.isTypeAssertionExpression(expression)
) {
if (hasSelectorType(expression.type)) return true
return isSelectorCompatibleExpression(expression.expression, checker, sourceFile, seenSymbols)
}
if (ts.isArrowFunction(expression) || ts.isFunctionExpression(expression)) return true
if (hasCallableType(expression, checker)) return true
if (ts.isConditionalExpression(expression)) {
return (
isSelectorCompatibleExpression(
expression.whenTrue,
checker,
sourceFile,
new Set(seenSymbols),
) &&
isSelectorCompatibleExpression(
expression.whenFalse,
checker,
sourceFile,
new Set(seenSymbols),
)
)
}
if (ts.isElementAccessExpression(expression) || ts.isPropertyAccessExpression(expression)) {
return isSelectorCollectionExpression(expression.expression, checker, sourceFile, seenSymbols)
}
if (!ts.isIdentifier(expression)) return false
const symbol = checker.getSymbolAtLocation(expression)
if (!symbol || seenSymbols.has(symbol)) return false
seenSymbols.add(symbol)
return (symbol.declarations ?? []).some((declaration) => {
if (ts.isVariableDeclaration(declaration)) {
return (
hasSelectorType(declaration.type) ||
Boolean(
declaration.initializer &&
isSelectorCompatibleExpression(declaration.initializer, checker, sourceFile, seenSymbols),
)
)
}
if (
ts.isParameter(declaration) ||
ts.isPropertyDeclaration(declaration) ||
ts.isPropertySignature(declaration)
)
return hasSelectorType(declaration.type)
return false
})
}
function quoteSelectorKey(key: string) {
return `'${key.replaceAll('\\', '\\\\').replaceAll("'", "\\'")}'`
}
function isSelectorPropertyName(key: string) {
return /^[A-Z_$][\w$]*$/i.test(key)
}
function selectorAccessFor(expression: ts.Expression, sourceFile: ts.SourceFile) {
if (ts.isStringLiteral(expression)) {
return isSelectorPropertyName(expression.text)
? `$.${expression.text}`
: `$[${quoteSelectorKey(expression.text)}]`
}
return `$[${expression.getText(sourceFile)}]`
}
function selectorFor(expression: ts.Expression, sourceFile: ts.SourceFile): string {
if (ts.isArrayLiteralExpression(expression)) {
const selectors = expression.elements.map((element) => {
if (ts.isSpreadElement(element)) return element.getText(sourceFile)
return `$ => ${selectorAccessFor(element, sourceFile)}`
})
return `[${selectors.join(', ')}]`
}
return `$ => ${selectorAccessFor(expression, sourceFile)}`
}
function isSelectorAccessRoot(expression: ts.Expression, parameterName: string): boolean {
if (ts.isIdentifier(expression)) return expression.text === parameterName
if (ts.isElementAccessExpression(expression) || ts.isPropertyAccessExpression(expression))
return isSelectorAccessRoot(expression.expression, parameterName)
return false
}
function isStringExpression(
node: ts.Node,
): node is ts.StringLiteral | ts.NoSubstitutionTemplateLiteral {
return ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)
}
function isTranslationCall(
node: ts.CallExpression,
checker: ts.TypeChecker,
sourceFile: ts.SourceFile,
) {
if (ts.isIdentifier(node.expression))
return isTranslationFunctionIdentifier(node.expression, checker, sourceFile)
if (!ts.isPropertyAccessExpression(node.expression) || node.expression.name.text !== 't')
return false
const receiver = unwrapExpression(node.expression.expression)
if (ts.isIdentifier(receiver)) return isI18nInstanceIdentifier(receiver, checker)
return (
ts.isCallExpression(receiver) &&
ts.isIdentifier(receiver.expression) &&
isGetI18nIdentifier(receiver.expression, checker)
)
}
function isTransComponent(identifier: ts.Identifier, checker: ts.TypeChecker) {
return isImportedBinding(identifier, checker, 'Trans', (moduleName) =>
I18N_MODULES.has(moduleName),
)
}
function getJsxAttribute(node: ts.JsxOpeningLikeElement, name: string) {
return node.attributes.properties.find((property): property is ts.JsxAttribute => {
return (
ts.isJsxAttribute(property) && ts.isIdentifier(property.name) && property.name.text === name
)
})
}
function getPropertyName(node: ts.ObjectLiteralElementLike) {
if (!('name' in node) || !node.name) return undefined
if (ts.isIdentifier(node.name) || ts.isStringLiteral(node.name)) return node.name.text
return undefined
}
function isI18nMock(node: ts.CallExpression) {
if (
!ts.isPropertyAccessExpression(node.expression) ||
!ts.isIdentifier(node.expression.expression) ||
node.expression.expression.text !== 'vi' ||
node.expression.name.text !== 'mock'
) {
return false
}
const moduleName = node.arguments[0]
return Boolean(moduleName && ts.isStringLiteral(moduleName) && I18N_MODULES.has(moduleName.text))
}
function isVitestValueWrapper(node: ts.CallExpression) {
return (
ts.isPropertyAccessExpression(node.expression) &&
ts.isIdentifier(node.expression.expression) &&
node.expression.expression.text === 'vi' &&
(node.expression.name.text === 'fn' || node.expression.name.text === 'hoisted')
)
}
function applyEdits(source: string, edits: TextEdit[]) {
let output = source
for (const edit of [...edits].sort((left, right) => right.start - left.start))
output = `${output.slice(0, edit.start)}${edit.replacement}${output.slice(edit.end)}`
return output
}
export function transformSource(source: string, fileName: string): TransformSourceResult {
const scriptKind =
fileName.endsWith('.tsx') || fileName.endsWith('.jsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS
const { checker, sourceFile } = createSourceAnalysis(source, fileName, scriptKind)
return transformAnalyzedSource(source, sourceFile, checker)
}
function transformAnalyzedSource(
source: string,
sourceFile: ts.SourceFile,
checker: ts.TypeChecker,
): TransformSourceResult {
const edits: TextEdit[] = []
const consumedEdits = new Set<TextEdit>()
const mockEditStarts = new Set<number>()
const mockProviderSymbols = new Set<ts.Symbol>()
const visitedMockModuleSymbols = new Set<ts.Symbol>()
const visitedMockProviderSymbols = new Set<ts.Symbol>()
const neededSelectorMockImports = new Set<string>()
const mockFactoryImports = new Map<ts.ArrowFunction | ts.FunctionExpression, Set<string>>()
let currentMockFactory: ts.ArrowFunction | ts.FunctionExpression | undefined
function requireMockHelper(name: 'withSelectorKey' | 'withSelectorKeyProps') {
if (!currentMockFactory) {
neededSelectorMockImports.add(name)
return
}
const imports = mockFactoryImports.get(currentMockFactory) ?? new Set<string>()
imports.add(name)
mockFactoryImports.set(currentMockFactory, imports)
}
function isSelectorMockAdapter(expression: ts.Expression) {
const adapter = unwrapExpression(expression)
if (!ts.isArrowFunction(adapter) && !ts.isFunctionExpression(adapter)) return false
const selectorParameter = adapter.parameters[0]
return (
Boolean(selectorParameter?.type && hasSelectorType(selectorParameter.type)) ||
/\b(?:keyFromSelector|resolveI18nKey)\s*\(/.test(adapter.body.getText(sourceFile))
)
}
function addMockTEdit(node: ts.PropertyAssignment | ts.ShorthandPropertyAssignment) {
if (mockEditStarts.has(node.getStart(sourceFile))) return
if (ts.isPropertyAssignment(node)) {
const initializer = unwrapExpression(node.initializer)
if (isSelectorMockAdapter(initializer)) return
const alreadyWrapped =
ts.isCallExpression(initializer) &&
ts.isIdentifier(initializer.expression) &&
initializer.expression.text === 'withSelectorKey'
if (alreadyWrapped) return
const translate =
currentMockFactory && ts.isIdentifier(initializer)
? `(...args: Parameters<typeof ${initializer.text}>) => ${initializer.text}(...args)`
: node.initializer.getText(sourceFile)
edits.push({
end: node.initializer.end,
replacement: `withSelectorKey(${translate})`,
start: node.initializer.getStart(sourceFile),
})
} else {
const translate = currentMockFactory
? `(...args: Parameters<typeof ${node.name.text}>) => ${node.name.text}(...args)`
: node.name.text
edits.push({
end: node.end,
replacement: `t: withSelectorKey(${translate})`,
start: node.getStart(sourceFile),
})
}
mockEditStarts.add(node.getStart(sourceFile))
requireMockHelper('withSelectorKey')
}
function addMockTransEdit(node: ts.PropertyAssignment | ts.ShorthandPropertyAssignment) {
if (mockEditStarts.has(node.getStart(sourceFile))) return
if (ts.isPropertyAssignment(node)) {
const initializer = unwrapExpression(node.initializer)
const alreadyWrapped =
ts.isCallExpression(initializer) &&
ts.isIdentifier(initializer.expression) &&
initializer.expression.text === 'withSelectorKeyProps'
if (alreadyWrapped) return
const render =
currentMockFactory && ts.isIdentifier(initializer)
? `(props: Parameters<typeof ${initializer.text}>[0]) => withSelectorKeyProps(${initializer.text})(props)`
: `withSelectorKeyProps(${node.initializer.getText(sourceFile)})`
edits.push({
end: node.initializer.end,
replacement: render,
start: node.initializer.getStart(sourceFile),
})
} else {
const render = currentMockFactory
? `(props: Parameters<typeof ${node.name.text}>[0]) => withSelectorKeyProps(${node.name.text})(props)`
: `withSelectorKeyProps(${node.name.text})`
edits.push({
end: node.end,
replacement: `Trans: ${render}`,
start: node.getStart(sourceFile),
})
}
mockEditStarts.add(node.getStart(sourceFile))
requireMockHelper('withSelectorKeyProps')
}
function collectReturnedExpressions(
node: ts.FunctionLikeDeclaration,
collect: (expression: ts.Expression) => void,
) {
if (ts.isArrowFunction(node) && !ts.isBlock(node.body)) {
collect(node.body)
return
}
if (!node.body) return
function visitReturn(candidate: ts.Node) {
if (candidate !== node && ts.isFunctionLike(candidate)) return
if (ts.isReturnStatement(candidate) && candidate.expression) {
collect(candidate.expression)
return
}
ts.forEachChild(candidate, visitReturn)
}
visitReturn(node.body)
}
function followLocalIdentifier(
identifier: ts.Identifier,
visitedSymbols: Set<ts.Symbol>,
collect: (node: ts.Node) => void,
) {
const symbol = checker.getSymbolAtLocation(identifier)
if (!symbol || visitedSymbols.has(symbol)) return symbol
visitedSymbols.add(symbol)
for (const declaration of symbol.declarations ?? []) {
if (ts.isVariableDeclaration(declaration) && declaration.initializer)
collect(declaration.initializer)
else if (ts.isFunctionDeclaration(declaration)) collect(declaration)
}
return symbol
}
function collectMockProviderValue(node: ts.Node) {
if (ts.isIdentifier(node)) {
const symbol = followLocalIdentifier(
node,
visitedMockProviderSymbols,
collectMockProviderValue,
)
if (symbol) mockProviderSymbols.add(symbol)
return
}
if (
ts.isParenthesizedExpression(node) ||
ts.isAsExpression(node) ||
ts.isNonNullExpression(node) ||
ts.isSatisfiesExpression(node) ||
ts.isTypeAssertionExpression(node)
) {
collectMockProviderValue(node.expression)
return
}
if (
ts.isArrowFunction(node) ||
ts.isFunctionExpression(node) ||
ts.isFunctionDeclaration(node) ||
ts.isMethodDeclaration(node)
) {
collectReturnedExpressions(node, collectMockProviderValue)
return
}
if (ts.isObjectLiteralExpression(node)) {
for (const property of node.properties) {
if (ts.isPropertyAssignment(property) && getPropertyName(property) === 't')
addMockTEdit(property)
else if (ts.isShorthandPropertyAssignment(property) && property.name.text === 't')
addMockTEdit(property)
else if (ts.isSpreadAssignment(property)) collectMockProviderValue(property.expression)
}
return
}
if (ts.isCallExpression(node)) {
if (ts.isIdentifier(node.expression)) {
const symbol = followLocalIdentifier(
node.expression,
visitedMockProviderSymbols,
collectMockProviderValue,
)
if (symbol) mockProviderSymbols.add(symbol)
}
if (isVitestValueWrapper(node)) {
for (const argument of node.arguments) collectMockProviderValue(argument)
}
return
}
if (ts.isConditionalExpression(node)) {
collectMockProviderValue(node.whenTrue)
collectMockProviderValue(node.whenFalse)
return
}
if (ts.isAwaitExpression(node)) collectMockProviderValue(node.expression)
}
function collectMockModuleValue(node: ts.Node) {
if (ts.isIdentifier(node)) {
followLocalIdentifier(node, visitedMockModuleSymbols, collectMockModuleValue)
return
}
if (
ts.isParenthesizedExpression(node) ||
ts.isAsExpression(node) ||
ts.isNonNullExpression(node) ||
ts.isSatisfiesExpression(node) ||
ts.isTypeAssertionExpression(node)
) {
collectMockModuleValue(node.expression)
return
}
if (
ts.isArrowFunction(node) ||
ts.isFunctionExpression(node) ||
ts.isFunctionDeclaration(node) ||
ts.isMethodDeclaration(node)
) {
collectReturnedExpressions(node, collectMockModuleValue)
return
}
if (ts.isObjectLiteralExpression(node)) {
for (const property of node.properties) {
const propertyName = getPropertyName(property)
if (
(ts.isPropertyAssignment(property) || ts.isShorthandPropertyAssignment(property)) &&
propertyName === 't'
) {
addMockTEdit(property)
} else if (
(ts.isPropertyAssignment(property) || ts.isShorthandPropertyAssignment(property)) &&
propertyName === 'Trans'
) {
addMockTransEdit(property)
} else if (propertyName === 'useTranslation' || propertyName === 'getI18n') {
if (ts.isPropertyAssignment(property)) collectMockProviderValue(property.initializer)
else if (ts.isShorthandPropertyAssignment(property))
collectMockProviderValue(property.name)
else if (ts.isMethodDeclaration(property)) collectMockProviderValue(property)
} else if (ts.isSpreadAssignment(property)) {
collectMockModuleValue(property.expression)
}
}
return
}
if (ts.isCallExpression(node)) {
if (isVitestValueWrapper(node)) {
for (const argument of node.arguments) collectMockModuleValue(argument)
}
return
}
if (ts.isConditionalExpression(node)) {
collectMockModuleValue(node.whenTrue)
collectMockModuleValue(node.whenFalse)
return
}
if (ts.isAwaitExpression(node)) collectMockModuleValue(node.expression)
}
function collectConfiguredMockProviderValues(node: ts.Node) {
if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)) {
const receiver = unwrapExpression(node.expression.expression)
const methodName = node.expression.name.text
const receiverSymbol = ts.isIdentifier(receiver)
? checker.getSymbolAtLocation(receiver)
: undefined
if (
ts.isIdentifier(receiver) &&
MOCK_PROVIDER_METHODS.has(methodName) &&
receiverSymbol &&
mockProviderSymbols.has(receiverSymbol)
) {
const value = node.arguments[0]
if (value) collectMockProviderValue(value)
}
}
ts.forEachChild(node, collectConfiguredMockProviderValues)
}
function visit(node: ts.Node) {
if (
ts.isArrowFunction(node) &&
node.parameters.length === 1 &&
ts.isIdentifier(node.parameters[0]!.name) &&
ts.isElementAccessExpression(node.body) &&
isSelectorAccessRoot(node.body.expression, node.parameters[0]!.name.text) &&
ts.isStringLiteral(node.body.argumentExpression)
) {
const argument = node.body.argumentExpression
if (isSelectorPropertyName(argument.text)) {
edits.push({
end: node.body.end,
replacement: `${node.body.expression.getText(sourceFile)}.${argument.text}`,
start: node.body.getStart(sourceFile),
})
} else if (argument.getText(sourceFile).startsWith('"')) {
edits.push({
end: argument.end,
replacement: quoteSelectorKey(argument.text),
start: argument.getStart(sourceFile),
})
}
}
if (ts.isCallExpression(node) && isI18nMock(node)) {
const factory = node.arguments[1]
if (factory && (ts.isArrowFunction(factory) || ts.isFunctionExpression(factory))) {
const previousMockFactory = currentMockFactory
currentMockFactory = factory
collectMockModuleValue(factory)
currentMockFactory = previousMockFactory
} else if (factory) {
collectMockModuleValue(factory)
}
}
if (
ts.isCallExpression(node) &&
isTranslationCall(node, checker, sourceFile) &&
node.arguments.length
) {
const keyExpression = node.arguments[0]! as ts.Expression
if (!isSelectorCompatibleExpression(keyExpression, checker, sourceFile)) {
edits.push({
end: keyExpression.end,
replacement: selectorFor(keyExpression, sourceFile),
start: keyExpression.getStart(sourceFile),
})
}
const fallback = node.arguments[1]
if (fallback && isStringExpression(fallback)) {
const options = node.arguments[2]
if (options && ts.isObjectLiteralExpression(unwrapExpression(options as ts.Expression))) {
const optionsText = options.getText(sourceFile)
const properties = optionsText.slice(1, -1).trim()
edits.push({
end: options.end,
replacement: `{ defaultValue: ${fallback.getText(sourceFile)}${properties ? `, ${properties}` : ''} }`,
start: fallback.getStart(sourceFile),
})
} else if (!options) {
edits.push({
end: fallback.end,
replacement: `{ defaultValue: ${fallback.getText(sourceFile)} }`,
start: fallback.getStart(sourceFile),
})
}
}
}
if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) {
if (ts.isIdentifier(node.tagName) && isTransComponent(node.tagName, checker)) {
const attribute = getJsxAttribute(node, 'i18nKey')
const initializer = attribute?.initializer
if (initializer && ts.isStringLiteral(initializer)) {
edits.push({
end: initializer.end,
replacement: `{${selectorFor(initializer, sourceFile)}}`,
start: initializer.getStart(sourceFile),
})
} else if (initializer && ts.isJsxExpression(initializer) && initializer.expression) {
if (!isSelectorCompatibleExpression(initializer.expression, checker, sourceFile)) {
edits.push({
end: initializer.expression.end,
replacement: selectorFor(initializer.expression, sourceFile),
start: initializer.expression.getStart(sourceFile),
})
}
}
}
}
ts.forEachChild(node, visit)
}
visit(sourceFile)
collectConfiguredMockProviderValues(sourceFile)
for (const [factory, helperNames] of mockFactoryImports) {
const helpers = Array.from(helperNames).sort()
const body = factory.body
const alreadyLoadsHelpers =
ts.isBlock(body) &&
body.statements.some((statement) => {
return (
ts.isVariableStatement(statement) &&
statement.getText(sourceFile).includes(`import('@/test/i18n-mock')`)
)
})
if (alreadyLoadsHelpers) continue
const isAsync = factory.modifiers?.some(
(modifier) => modifier.kind === ts.SyntaxKind.AsyncKeyword,
)
if (!isAsync) {
edits.push({
end: factory.getStart(sourceFile),
replacement: 'async ',
start: factory.getStart(sourceFile),
})
}
const { character: factoryColumn } = sourceFile.getLineAndCharacterOfPosition(
factory.getStart(sourceFile),
)
const factoryLineStart = factory.getStart(sourceFile) - factoryColumn
const leadingWhitespace =
source.slice(factoryLineStart, factory.getStart(sourceFile)).match(/^\s*/)?.[0].length ?? 0
const bodyIndent = ' '.repeat(leadingWhitespace + 2)
const closingIndent = ' '.repeat(leadingWhitespace)
const helperImport = `const { ${helpers.join(', ')} } = await import('@/test/i18n-mock')`
if (ts.isBlock(body)) {
edits.push({
end: body.getStart(sourceFile) + 1,
replacement: `\n${bodyIndent}${helperImport}`,
start: body.getStart(sourceFile) + 1,
})
} else {
const bodyStart = body.getStart(sourceFile)
const nestedEdits = edits.filter((edit) => edit.start >= bodyStart && edit.end <= body.end)
const transformedBody = applyEdits(
source.slice(bodyStart, body.end),
nestedEdits.map((edit) => ({
...edit,
end: edit.end - bodyStart,
start: edit.start - bodyStart,
})),
)
for (const edit of nestedEdits) consumedEdits.add(edit)
const indentedBody = transformedBody
.split('\n')
.map((line, index) => (index === 0 || line.trim() === '' ? line.trimEnd() : ` ${line}`))
.join('\n')
edits.push({
end: body.end,
replacement: `{\n${bodyIndent}${helperImport}\n${bodyIndent}return ${indentedBody}\n${closingIndent}}`,
start: bodyStart,
})
}
}
if (neededSelectorMockImports.size) {
const imports = sourceFile.statements.filter(ts.isImportDeclaration)
const helperImport = imports.find((node) => {
return (
ts.isStringLiteral(node.moduleSpecifier) && node.moduleSpecifier.text === '@/test/i18n-mock'
)
})
const namedBindings = helperImport?.importClause?.namedBindings
const existingNames =
namedBindings && ts.isNamedImports(namedBindings)
? namedBindings.elements.map((element) => element.name.text)
: []
const missingNames = Array.from(neededSelectorMockImports).filter(
(name) => !existingNames.includes(name),
)
if (missingNames.length && namedBindings && ts.isNamedImports(namedBindings)) {
edits.push({
end: namedBindings.end,
replacement: `{ ${[...existingNames, ...missingNames].join(', ')} }`,
start: namedBindings.getStart(sourceFile),
})
} else if (missingNames.length) {
const lastImport = imports.at(-1)
const position = lastImport?.end ?? 0
const prefix = position ? '\n' : ''
edits.push({
end: position,
replacement: `${prefix}import { ${missingNames.join(', ')} } from '@/test/i18n-mock'`,
start: position,
})
}
}
return {
changes: edits.length,
output: applyEdits(
source,
edits.filter((edit) => !consumedEdits.has(edit)),
),
}
}
async function listSourceFiles(root: string) {
const files: string[] = []
async function walk(directory: string) {
const entries = await fs.readdir(directory, { withFileTypes: true })
for (const entry of entries) {
if (entry.isDirectory() && SKIPPED_DIRECTORIES.has(entry.name)) continue
const entryPath = path.join(directory, entry.name)
if (entry.isDirectory()) await walk(entryPath)
else if (
entry.isFile() &&
!SKIPPED_FILES.has(entry.name) &&
SOURCE_EXTENSIONS.has(path.extname(entry.name))
)
files.push(entryPath)
}
}
await walk(root)
return files.sort()
}
function createProjectProgram(root: string) {
const configPath = ts.findConfigFile(root, ts.sys.fileExists, 'tsconfig.json')
if (!configPath) return undefined
const config = ts.readConfigFile(configPath, ts.sys.readFile)
if (config.error) throw new Error(ts.flattenDiagnosticMessageText(config.error.messageText, '\n'))
const parsed = ts.parseJsonConfigFileContent(
config.config,
ts.sys,
path.dirname(configPath),
{
incremental: false,
noEmit: true,
},
configPath,
)
if (parsed.errors.length)
throw new Error(ts.flattenDiagnosticMessageText(parsed.errors[0]!.messageText, '\n'))
return ts.createProgram({
options: parsed.options,
rootNames: parsed.fileNames,
})
}
export async function migrateSelectors(root: string, write: boolean) {
const files = await listSourceFiles(root)
const projectProgram = createProjectProgram(root)
const projectChecker = projectProgram?.getTypeChecker()
const changedFilePaths: string[] = []
let changedFiles = 0
let changes = 0
for (const file of files) {
const source = await fs.readFile(file, 'utf8')
const projectSourceFile = projectProgram?.getSourceFile(path.resolve(file))
const result =
projectSourceFile && projectChecker && projectSourceFile.text === source
? transformAnalyzedSource(source, projectSourceFile, projectChecker)
: transformSource(source, file)
if (!result.changes) continue
changedFiles++
changedFilePaths.push(file)
changes += result.changes
if (write) await fs.writeFile(file, result.output, 'utf8')
}
return { changedFilePaths, changedFiles, changes }
}
async function runCli() {
const write = process.argv.includes('--write')
const verbose = process.argv.includes('--verbose')
const unknownArgs = process.argv
.slice(2)
.filter((arg) => arg !== '--verbose' && arg !== '--write')
if (unknownArgs.length) throw new Error(`Unknown arguments: ${unknownArgs.join(', ')}`)
const result = await migrateSelectors(process.cwd(), write)
const action = write ? 'Migrated' : 'Found'
console.log(
`${action} ${result.changes} i18n selector call sites across ${result.changedFiles} files.`,
)
if (verbose) {
for (const file of result.changedFilePaths) console.log(path.relative(process.cwd(), file))
}
if (!write && result.changes) process.exitCode = 1
}
const entryPath = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : ''
if (import.meta.url === entryPath) {
runCli().catch((error: unknown) => {
console.error(error instanceof Error ? error.message : String(error))
process.exit(1)
})
}