feat: add TypeScript type safety for i18next with automated maintenance (#25152)

This commit is contained in:
lyzno1 2025-09-04 17:12:48 +08:00 • committed by GitHub
commit fb307ae128
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 373 additions and 3 deletions

View file

@ -0,0 +1,120 @@
#!/usr/bin/env node
const fs = require('fs')
const path = require('path')
const { camelCase } = require('lodash')
// Import the NAMESPACES array from i18next-config.ts
function getNamespacesFromConfig() {
const configPath = path.join(__dirname, 'i18next-config.ts')
const configContent = fs.readFileSync(configPath, 'utf8')
// Extract NAMESPACES array using regex
const namespacesMatch = configContent.match(/const NAMESPACES = \[([\s\S]*?)\]/)
if (!namespacesMatch) {
throw new Error('Could not find NAMESPACES array in i18next-config.ts')
}
// Parse the namespaces
const namespacesStr = namespacesMatch[1]
const namespaces = namespacesStr
.split(',')
.map(line => line.trim())
.filter(line => line.startsWith("'") || line.startsWith('"'))
.map(line => line.slice(1, -1)) // Remove quotes
return namespaces
}
function getNamespacesFromTypes() {
const typesPath = path.join(__dirname, '../types/i18n.d.ts')
if (!fs.existsSync(typesPath)) {
return null
}
const typesContent = fs.readFileSync(typesPath, 'utf8')
// Extract namespaces from Messages type
const messagesMatch = typesContent.match(/export type Messages = \{([\s\S]*?)\}/)
if (!messagesMatch) {
return null
}
// Parse the properties
const propertiesStr = messagesMatch[1]
const properties = propertiesStr
.split('\n')
.map(line => line.trim())
.filter(line => line.includes(':'))
.map(line => line.split(':')[0].trim())
.filter(prop => prop.length > 0)
return properties
}
function main() {
try {
console.log('🔍 Checking i18n types synchronization...')
// Get namespaces from config
const configNamespaces = getNamespacesFromConfig()
console.log(`📦 Found ${configNamespaces.length} namespaces in config`)
// Convert to camelCase for comparison
const configCamelCase = configNamespaces.map(ns => camelCase(ns)).sort()
// Get namespaces from type definitions
const typeNamespaces = getNamespacesFromTypes()
if (!typeNamespaces) {
console.error('❌ Type definitions file not found or invalid')
console.error(' Run: pnpm run gen:i18n-types')
process.exit(1)
}
console.log(`🔧 Found ${typeNamespaces.length} namespaces in types`)
const typeCamelCase = typeNamespaces.sort()
// Compare arrays
const configSet = new Set(configCamelCase)
const typeSet = new Set(typeCamelCase)
// Find missing in types
const missingInTypes = configCamelCase.filter(ns => !typeSet.has(ns))
// Find extra in types
const extraInTypes = typeCamelCase.filter(ns => !configSet.has(ns))
let hasErrors = false
if (missingInTypes.length > 0) {
hasErrors = true
console.error('❌ Missing in type definitions:')
missingInTypes.forEach(ns => console.error(` - ${ns}`))
}
if (extraInTypes.length > 0) {
hasErrors = true
console.error('❌ Extra in type definitions:')
extraInTypes.forEach(ns => console.error(` - ${ns}`))
}
if (hasErrors) {
console.error('\n💡 To fix synchronization issues:')
console.error(' Run: pnpm run gen:i18n-types')
process.exit(1)
}
console.log('✅ i18n types are synchronized')
} catch (error) {
console.error('❌ Error:', error.message)
process.exit(1)
}
}
if (require.main === module) {
main()
}

View file

@ -0,0 +1,135 @@
#!/usr/bin/env node
const fs = require('fs')
const path = require('path')
const { camelCase } = require('lodash')
// Import the NAMESPACES array from i18next-config.ts
function getNamespacesFromConfig() {
const configPath = path.join(__dirname, 'i18next-config.ts')
const configContent = fs.readFileSync(configPath, 'utf8')
// Extract NAMESPACES array using regex
const namespacesMatch = configContent.match(/const NAMESPACES = \[([\s\S]*?)\]/)
if (!namespacesMatch) {
throw new Error('Could not find NAMESPACES array in i18next-config.ts')
}
// Parse the namespaces
const namespacesStr = namespacesMatch[1]
const namespaces = namespacesStr
.split(',')
.map(line => line.trim())
.filter(line => line.startsWith("'") || line.startsWith('"'))
.map(line => line.slice(1, -1)) // Remove quotes
return namespaces
}
function generateTypeDefinitions(namespaces) {
const header = `// TypeScript type definitions for Dify's i18next configuration
// This file is auto-generated. Do not edit manually.
// To regenerate, run: pnpm run gen:i18n-types
import 'react-i18next'
// Extract types from translation files using typeof import pattern`
// Generate individual type definitions
const typeDefinitions = namespaces.map(namespace => {
const typeName = camelCase(namespace).replace(/^\w/, c => c.toUpperCase()) + 'Messages'
return `type ${typeName} = typeof import('../i18n/en-US/${namespace}').default`
}).join('\n')
// Generate Messages interface
const messagesInterface = `
// Complete type structure that matches i18next-config.ts camelCase conversion
export type Messages = {
${namespaces.map(namespace => {
const camelCased = camelCase(namespace)
const typeName = camelCase(namespace).replace(/^\w/, c => c.toUpperCase()) + 'Messages'
return ` ${camelCased}: ${typeName};`
}).join('\n')}
}`
const utilityTypes = `
// Utility type to flatten nested object keys into dot notation
type FlattenKeys<T> = T extends object
? {
[K in keyof T]: T[K] extends object
? \`\${K & string}.\${FlattenKeys<T[K]> & string}\`
: \`\${K & string}\`
}[keyof T]
: never
export type ValidTranslationKeys = FlattenKeys<Messages>`
const moduleDeclarations = `
// Extend react-i18next with Dify's type structure
declare module 'react-i18next' {
interface CustomTypeOptions {
defaultNS: 'translation';
resources: {
translation: Messages;
};
}
}
// Extend i18next for complete type safety
declare module 'i18next' {
interface CustomTypeOptions {
defaultNS: 'translation';
resources: {
translation: Messages;
};
}
}`
return [header, typeDefinitions, messagesInterface, utilityTypes, moduleDeclarations].join('\n\n')
}
function main() {
const args = process.argv.slice(2)
const checkMode = args.includes('--check')
try {
console.log('📦 Generating i18n type definitions...')
// Get namespaces from config
const namespaces = getNamespacesFromConfig()
console.log(`✅ Found ${namespaces.length} namespaces`)
// Generate type definitions
const typeDefinitions = generateTypeDefinitions(namespaces)
const outputPath = path.join(__dirname, '../types/i18n.d.ts')
if (checkMode) {
// Check mode: compare with existing file
if (!fs.existsSync(outputPath)) {
console.error('❌ Type definitions file does not exist')
process.exit(1)
}
const existingContent = fs.readFileSync(outputPath, 'utf8')
if (existingContent.trim() !== typeDefinitions.trim()) {
console.error('❌ Type definitions are out of sync')
console.error(' Run: pnpm run gen:i18n-types')
process.exit(1)
}
console.log('✅ Type definitions are in sync')
} else {
// Generate mode: write file
fs.writeFileSync(outputPath, typeDefinitions)
console.log(`✅ Generated type definitions: ${outputPath}`)
}
} catch (error) {
console.error('❌ Error:', error.message)
process.exit(1)
}
}
if (require.main === module) {
main()
}