252 lines
9.3 KiB
TypeScript
252 lines
9.3 KiB
TypeScript
import type { ExtractedKey, Plugin } from 'i18next-cli'
|
|
import * as ts from 'typescript'
|
|
import { vol } from 'memfs'
|
|
import * as path from 'path'
|
|
|
|
// based on https://github.com/i18next/i18next-cli/blob/49188621ae5534d940c7cc86eca1e3ece99506e7/test/plugin.typescript.test.ts
|
|
|
|
// --- HELPER FUNCTIONS FOR PLUGIN ---
|
|
function isTranslationFunction (node: ts.CallExpression): boolean {
|
|
const expr = node.expression
|
|
// Matches t('...')
|
|
if (ts.isIdentifier(expr) && expr.text === 't') return true
|
|
|
|
// Matches i18n.t('...')
|
|
if (ts.isPropertyAccessExpression(expr) && expr.name.text === 't') return true
|
|
|
|
return false
|
|
}
|
|
|
|
function extractStringsFromType (type: ts.Type): string[] {
|
|
if (type.isStringLiteral()) {
|
|
return [type.value]
|
|
}
|
|
if (type.isUnion()) {
|
|
return type.types.flatMap(t => extractStringsFromType(t))
|
|
}
|
|
if (type.isIntersection()) {
|
|
return type.types.flatMap(t => extractStringsFromType(t))
|
|
}
|
|
return []
|
|
}
|
|
|
|
export function typescriptPlugin (
|
|
entryPoints: string[],
|
|
options: { defaultNS?: string } = {}
|
|
): Plugin {
|
|
const defaultNS = options.defaultNS
|
|
|
|
return {
|
|
name: 'typescript-resolver',
|
|
async onEnd (keys: Map<string, ExtractedKey>) {
|
|
// 1. Setup Compiler Options
|
|
const compilerOptions: ts.CompilerOptions = {
|
|
allowJs: true,
|
|
jsx: ts.JsxEmit.ReactJSX,
|
|
target: ts.ScriptTarget.ESNext,
|
|
module: ts.ModuleKind.CommonJS,
|
|
moduleResolution: ts.ModuleResolutionKind.NodeJs,
|
|
esModuleInterop: true
|
|
}
|
|
|
|
// 2. Create a custom CompilerHost that reads from memfs (vol)
|
|
const host = ts.createCompilerHost(compilerOptions)
|
|
const originalReadFile = host.readFile
|
|
const originalFileExists = host.fileExists
|
|
|
|
host.readFile = (fileName: string) => {
|
|
if (vol.existsSync(fileName)) {
|
|
return vol.readFileSync(fileName, 'utf-8') as string
|
|
}
|
|
return originalReadFile(fileName)
|
|
}
|
|
|
|
host.fileExists = (fileName: string) => {
|
|
if (vol.existsSync(fileName)) return true
|
|
return originalFileExists(fileName)
|
|
}
|
|
|
|
// Override module resolution to look in memfs
|
|
host.resolveModuleNameLiterals = (moduleLiterals, containingFile, redirectedReference, options, containingSourceFile, reusedNames) => {
|
|
return moduleLiterals.map(moduleLiteral => {
|
|
const moduleName = moduleLiteral.text
|
|
// Simple resolution for test: resolve relative paths against the containing file's directory
|
|
if (moduleName.startsWith('.')) {
|
|
const dir = path.dirname(containingFile)
|
|
const candidates: [string, ts.Extension][] = [
|
|
[path.join(dir, moduleName + '.tsx'), ts.Extension.Tsx],
|
|
[path.join(dir, moduleName + '.ts'), ts.Extension.Ts],
|
|
]
|
|
for (const [resolvedPath, ext] of candidates) {
|
|
if (vol.existsSync(resolvedPath)) {
|
|
return {
|
|
resolvedModule: {
|
|
resolvedFileName: resolvedPath,
|
|
extension: ext,
|
|
isExternalLibraryImport: false
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Fallback to standard resolution (for node_modules etc)
|
|
return ts.resolveModuleName(moduleName, containingFile, options, host)
|
|
})
|
|
}
|
|
|
|
// 3. Create Program
|
|
const program = ts.createProgram(entryPoints, compilerOptions, host)
|
|
const checker = program.getTypeChecker()
|
|
|
|
// 4. Visit AST
|
|
for (const sourceFile of program.getSourceFiles()) {
|
|
if (sourceFile.isDeclarationFile) continue
|
|
ts.forEachChild(sourceFile, node => { visit(node, undefined) })
|
|
}
|
|
|
|
// Extract namespace from useTranslation call
|
|
function getUseTranslationNamespace (node: ts.CallExpression): string | undefined {
|
|
const expr = node.expression
|
|
if (ts.isIdentifier(expr) && expr.text === 'useTranslation') {
|
|
const arg = node.arguments[0]
|
|
if (arg && ts.isStringLiteral(arg)) {
|
|
return arg.text
|
|
}
|
|
// Handle array of namespaces: useTranslation(['ns1', 'ns2']) - use the first one
|
|
if (arg && ts.isArrayLiteralExpression(arg)) {
|
|
const firstElement = arg.elements[0]
|
|
if (firstElement && ts.isStringLiteral(firstElement)) {
|
|
return firstElement.text
|
|
}
|
|
}
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
function visit (node: ts.Node, scopeNs: string | undefined) {
|
|
// Track useTranslation namespace in current scope
|
|
let currentScopeNs = scopeNs
|
|
|
|
// Check if this is a function with useTranslation
|
|
if (ts.isFunctionDeclaration(node) ||
|
|
ts.isFunctionExpression(node) ||
|
|
ts.isArrowFunction(node) ||
|
|
ts.isMethodDeclaration(node)) {
|
|
const body = 'body' in node ? node.body : undefined
|
|
if (body) {
|
|
// Look for useTranslation in this function
|
|
const searchForUseTranslation = (n: ts.Node): string | undefined => {
|
|
if (ts.isVariableDeclaration(n) && n.initializer &&
|
|
ts.isCallExpression(n.initializer)) {
|
|
const ns = getUseTranslationNamespace(n.initializer)
|
|
if (ns) return ns
|
|
}
|
|
if (ts.isCallExpression(n)) {
|
|
const ns = getUseTranslationNamespace(n)
|
|
if (ns) return ns
|
|
}
|
|
let result: string | undefined
|
|
ts.forEachChild(n, child => {
|
|
if (!result) result = searchForUseTranslation(child)
|
|
})
|
|
return result
|
|
}
|
|
currentScopeNs = searchForUseTranslation(body) ?? scopeNs
|
|
}
|
|
}
|
|
|
|
if (ts.isCallExpression(node) && isTranslationFunction(node)) {
|
|
const arg = node.arguments[0]
|
|
if (arg) {
|
|
if (ts.isStringLiteral(arg) || ts.isNoSubstitutionTemplateLiteral(arg)) {
|
|
ts.forEachChild(node, child => { visit(child, currentScopeNs) })
|
|
return
|
|
}
|
|
|
|
let values: string[] = []
|
|
|
|
// Handle function arguments (e.g. t(() => ...)) by checking return type
|
|
if (ts.isArrowFunction(arg) || ts.isFunctionExpression(arg)) {
|
|
const signature = checker.getSignatureFromDeclaration(arg)
|
|
if (signature) {
|
|
const returnType = signature.getReturnType()
|
|
values = extractStringsFromType(returnType)
|
|
}
|
|
} else {
|
|
// Try standard type resolution first
|
|
const type = checker.getTypeAtLocation(arg)
|
|
values = extractStringsFromType(type)
|
|
}
|
|
|
|
// Fallback: If type resolution failed (generic string) but it's a TemplateExpression,
|
|
// try to manually resolve the parts. This helps in test environments where full type inference is flaky.
|
|
if (values.length === 0 && ts.isTemplateExpression(arg)) {
|
|
const head = arg.head.text
|
|
// Only handle simple case: `prefix.${var}`
|
|
if (arg.templateSpans.length === 1) {
|
|
const span = arg.templateSpans[0]
|
|
const spanType = checker.getTypeAtLocation(span.expression)
|
|
const spanValues = extractStringsFromType(spanType)
|
|
|
|
if (spanValues.length > 0) {
|
|
values = spanValues.map(v => head + v + span.literal.text)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Extract namespace from options (second argument)
|
|
let optionsNs: string | undefined
|
|
const optionsArg = node.arguments[1]
|
|
if (optionsArg && ts.isObjectLiteralExpression(optionsArg)) {
|
|
const nsProp = optionsArg.properties.find(
|
|
p => ts.isPropertyAssignment(p) && ts.isIdentifier(p.name) && p.name.text === 'ns'
|
|
)
|
|
if (nsProp && ts.isPropertyAssignment(nsProp)) {
|
|
if (ts.isStringLiteral(nsProp.initializer)) {
|
|
optionsNs = nsProp.initializer.text
|
|
}
|
|
}
|
|
}
|
|
|
|
values.forEach(val => {
|
|
let key = val
|
|
// Priority: 1. options ns, 2. key contains ns, 3. useTranslation ns, 4. default 'all'
|
|
let ns = optionsNs
|
|
|
|
// Check if key contains namespace separator (e.g., "namespace:key")
|
|
const separatorIndex = val.indexOf(':')
|
|
if (separatorIndex > 0) {
|
|
ns = val.substring(0, separatorIndex)
|
|
key = val.substring(separatorIndex + 1)
|
|
}
|
|
|
|
let uniqueKey = key
|
|
|
|
// Fallback to useTranslation namespace or default
|
|
if (!ns) {
|
|
if(currentScopeNs) {
|
|
ns = currentScopeNs
|
|
} else if (defaultNS) {
|
|
ns = defaultNS
|
|
}
|
|
}
|
|
if (ns) {
|
|
uniqueKey = `${ns}:${key}`
|
|
}
|
|
|
|
if (!keys.has(uniqueKey)) {
|
|
keys.set(uniqueKey, {
|
|
key,
|
|
defaultValue: key,
|
|
nsIsImplicit: !optionsNs && !val.includes(':') && !currentScopeNs,
|
|
})
|
|
}
|
|
})
|
|
}
|
|
}
|
|
ts.forEachChild(node, child => { visit(child, currentScopeNs) })
|
|
}
|
|
}
|
|
}
|
|
}
|