feat: add TypeScript type safety for i18next with automated maintenance (#25152)
This commit is contained in:
@@ -67,12 +67,22 @@ jobs:
|
|||||||
working-directory: ./web
|
working-directory: ./web
|
||||||
run: pnpm run auto-gen-i18n ${{ env.FILE_ARGS }}
|
run: pnpm run auto-gen-i18n ${{ env.FILE_ARGS }}
|
||||||
|
|
||||||
|
- name: Generate i18n type definitions
|
||||||
|
if: env.FILES_CHANGED == 'true'
|
||||||
|
working-directory: ./web
|
||||||
|
run: pnpm run gen:i18n-types
|
||||||
|
|
||||||
- name: Create Pull Request
|
- name: Create Pull Request
|
||||||
if: env.FILES_CHANGED == 'true'
|
if: env.FILES_CHANGED == 'true'
|
||||||
uses: peter-evans/create-pull-request@v6
|
uses: peter-evans/create-pull-request@v6
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
commit-message: Update i18n files based on en-US changes
|
commit-message: Update i18n files and type definitions based on en-US changes
|
||||||
title: 'chore: translate i18n files'
|
title: 'chore: translate i18n files and update type definitions'
|
||||||
body: This PR was automatically created to update i18n files based on changes in en-US locale.
|
body: |
|
||||||
|
This PR was automatically created to update i18n files and TypeScript type definitions based on changes in en-US locale.
|
||||||
|
|
||||||
|
**Changes included:**
|
||||||
|
- Updated translation files for all locales
|
||||||
|
- Regenerated TypeScript type definitions for type safety
|
||||||
branch: chore/automated-i18n-updates
|
branch: chore/automated-i18n-updates
|
||||||
|
5
.github/workflows/web-tests.yml
vendored
5
.github/workflows/web-tests.yml
vendored
@@ -47,6 +47,11 @@ jobs:
|
|||||||
working-directory: ./web
|
working-directory: ./web
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Check i18n types synchronization
|
||||||
|
if: steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
working-directory: ./web
|
||||||
|
run: pnpm run check:i18n-types
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
if: steps.changed-files.outputs.any_changed == 'true'
|
if: steps.changed-files.outputs.any_changed == 'true'
|
||||||
working-directory: ./web
|
working-directory: ./web
|
||||||
|
2
web/global.d.ts
vendored
2
web/global.d.ts
vendored
@@ -8,3 +8,5 @@ declare module '*.mdx' {
|
|||||||
let MDXComponent: (props: any) => JSX.Element
|
let MDXComponent: (props: any) => JSX.Element
|
||||||
export default MDXComponent
|
export default MDXComponent
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import './types/i18n'
|
||||||
|
120
web/i18n-config/check-i18n-sync.js
Normal file
120
web/i18n-config/check-i18n-sync.js
Normal 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()
|
||||||
|
}
|
135
web/i18n-config/generate-i18n-types.js
Normal file
135
web/i18n-config/generate-i18n-types.js
Normal 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()
|
||||||
|
}
|
@@ -35,6 +35,8 @@
|
|||||||
"uglify-embed": "node ./bin/uglify-embed",
|
"uglify-embed": "node ./bin/uglify-embed",
|
||||||
"check-i18n": "node ./i18n-config/check-i18n.js",
|
"check-i18n": "node ./i18n-config/check-i18n.js",
|
||||||
"auto-gen-i18n": "node ./i18n-config/auto-gen-i18n.js",
|
"auto-gen-i18n": "node ./i18n-config/auto-gen-i18n.js",
|
||||||
|
"gen:i18n-types": "node ./i18n-config/generate-i18n-types.js",
|
||||||
|
"check:i18n-types": "node ./i18n-config/check-i18n-sync.js",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
"test:watch": "jest --watch",
|
"test:watch": "jest --watch",
|
||||||
"storybook": "storybook dev -p 6006",
|
"storybook": "storybook dev -p 6006",
|
||||||
|
96
web/types/i18n.d.ts
vendored
Normal file
96
web/types/i18n.d.ts
vendored
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
// 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
|
||||||
|
|
||||||
|
type AppAnnotationMessages = typeof import('../i18n/en-US/app-annotation').default
|
||||||
|
type AppApiMessages = typeof import('../i18n/en-US/app-api').default
|
||||||
|
type AppDebugMessages = typeof import('../i18n/en-US/app-debug').default
|
||||||
|
type AppLogMessages = typeof import('../i18n/en-US/app-log').default
|
||||||
|
type AppOverviewMessages = typeof import('../i18n/en-US/app-overview').default
|
||||||
|
type AppMessages = typeof import('../i18n/en-US/app').default
|
||||||
|
type BillingMessages = typeof import('../i18n/en-US/billing').default
|
||||||
|
type CommonMessages = typeof import('../i18n/en-US/common').default
|
||||||
|
type CustomMessages = typeof import('../i18n/en-US/custom').default
|
||||||
|
type DatasetCreationMessages = typeof import('../i18n/en-US/dataset-creation').default
|
||||||
|
type DatasetDocumentsMessages = typeof import('../i18n/en-US/dataset-documents').default
|
||||||
|
type DatasetHitTestingMessages = typeof import('../i18n/en-US/dataset-hit-testing').default
|
||||||
|
type DatasetSettingsMessages = typeof import('../i18n/en-US/dataset-settings').default
|
||||||
|
type DatasetMessages = typeof import('../i18n/en-US/dataset').default
|
||||||
|
type EducationMessages = typeof import('../i18n/en-US/education').default
|
||||||
|
type ExploreMessages = typeof import('../i18n/en-US/explore').default
|
||||||
|
type LayoutMessages = typeof import('../i18n/en-US/layout').default
|
||||||
|
type LoginMessages = typeof import('../i18n/en-US/login').default
|
||||||
|
type OauthMessages = typeof import('../i18n/en-US/oauth').default
|
||||||
|
type PluginTagsMessages = typeof import('../i18n/en-US/plugin-tags').default
|
||||||
|
type PluginMessages = typeof import('../i18n/en-US/plugin').default
|
||||||
|
type RegisterMessages = typeof import('../i18n/en-US/register').default
|
||||||
|
type RunLogMessages = typeof import('../i18n/en-US/run-log').default
|
||||||
|
type ShareMessages = typeof import('../i18n/en-US/share').default
|
||||||
|
type TimeMessages = typeof import('../i18n/en-US/time').default
|
||||||
|
type ToolsMessages = typeof import('../i18n/en-US/tools').default
|
||||||
|
type WorkflowMessages = typeof import('../i18n/en-US/workflow').default
|
||||||
|
|
||||||
|
// Complete type structure that matches i18next-config.ts camelCase conversion
|
||||||
|
export type Messages = {
|
||||||
|
appAnnotation: AppAnnotationMessages;
|
||||||
|
appApi: AppApiMessages;
|
||||||
|
appDebug: AppDebugMessages;
|
||||||
|
appLog: AppLogMessages;
|
||||||
|
appOverview: AppOverviewMessages;
|
||||||
|
app: AppMessages;
|
||||||
|
billing: BillingMessages;
|
||||||
|
common: CommonMessages;
|
||||||
|
custom: CustomMessages;
|
||||||
|
datasetCreation: DatasetCreationMessages;
|
||||||
|
datasetDocuments: DatasetDocumentsMessages;
|
||||||
|
datasetHitTesting: DatasetHitTestingMessages;
|
||||||
|
datasetSettings: DatasetSettingsMessages;
|
||||||
|
dataset: DatasetMessages;
|
||||||
|
education: EducationMessages;
|
||||||
|
explore: ExploreMessages;
|
||||||
|
layout: LayoutMessages;
|
||||||
|
login: LoginMessages;
|
||||||
|
oauth: OauthMessages;
|
||||||
|
pluginTags: PluginTagsMessages;
|
||||||
|
plugin: PluginMessages;
|
||||||
|
register: RegisterMessages;
|
||||||
|
runLog: RunLogMessages;
|
||||||
|
share: ShareMessages;
|
||||||
|
time: TimeMessages;
|
||||||
|
tools: ToolsMessages;
|
||||||
|
workflow: WorkflowMessages;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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>
|
||||||
|
|
||||||
|
// Extend react-i18next with Dify's type structure
|
||||||
|
declare module 'react-i18next' {
|
||||||
|
type CustomTypeOptions = {
|
||||||
|
defaultNS: 'translation';
|
||||||
|
resources: {
|
||||||
|
translation: Messages;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extend i18next for complete type safety
|
||||||
|
declare module 'i18next' {
|
||||||
|
type CustomTypeOptions = {
|
||||||
|
defaultNS: 'translation';
|
||||||
|
resources: {
|
||||||
|
translation: Messages;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
Reference in New Issue
Block a user