mirror of
https://github.com/langgenius/dify.git
synced 2026-07-21 02:28:30 +08:00
1187 lines
39 KiB
TypeScript
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)
|
|
})
|
|
}
|