dify
This commit is contained in:
48
dify/web/i18n-config/DEV.md
Normal file
48
dify/web/i18n-config/DEV.md
Normal file
@@ -0,0 +1,48 @@
|
||||
## library
|
||||
|
||||
- i18next
|
||||
- react-i18next
|
||||
|
||||
## hooks
|
||||
|
||||
- useTranslation
|
||||
- useGetLanguage
|
||||
- useI18N
|
||||
- useRenderI18nObject
|
||||
|
||||
## impl
|
||||
|
||||
- App Boot
|
||||
- app/layout.tsx load i18n and init context
|
||||
- use `<I18nServer/>`
|
||||
- read locale with `getLocaleOnServer` (in node.js)
|
||||
- locale from cookie, or browser request header
|
||||
- only used in client app init and 2 server code(plugin desc, datasets)
|
||||
- use `<I18N/>`
|
||||
- init i18n context
|
||||
- `setLocaleOnClient`
|
||||
- `changeLanguage` (defined in i18n/i18next-config, also init i18n resources (side effects))
|
||||
- is `i18next.changeLanguage`
|
||||
- all languages text is merge & load in FrontEnd as .js (see i18n/i18next-config)
|
||||
- i18n context
|
||||
- `locale` - current locale code (ex `eu-US`, `zh-Hans`)
|
||||
- `i18n` - useless
|
||||
- `setLocaleOnClient` - used by App Boot and user change language
|
||||
|
||||
### load i18n resources
|
||||
|
||||
- client: i18n/i18next-config.ts
|
||||
- ns = camalCase(filename)
|
||||
- ex: `app/components/datasets/create/embedding-process/index.tsx`
|
||||
- `t('datasetSettings.form.retrievalSetting.title')`
|
||||
- server: i18n/server.ts
|
||||
- ns = filename
|
||||
- ex: `app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/settings/page.tsx`
|
||||
- `translate(locale, 'dataset-settings')`
|
||||
|
||||
## TODO
|
||||
|
||||
- [ ] ts docs for useGetLanguage
|
||||
- [ ] ts docs for useI18N
|
||||
- [ ] client docs for i18n
|
||||
- [ ] server docs for i18n
|
||||
186
dify/web/i18n-config/README.md
Normal file
186
dify/web/i18n-config/README.md
Normal file
@@ -0,0 +1,186 @@
|
||||
# Internationalization (i18n)
|
||||
|
||||
## Introduction
|
||||
|
||||
This directory contains the internationalization (i18n) files for this project.
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
├── [ 24] README.md
|
||||
├── [ 704] en-US
|
||||
│ ├── [2.4K] app-annotation.ts
|
||||
│ ├── [5.2K] app-api.ts
|
||||
│ ├── [ 16K] app-debug.ts
|
||||
│ ├── [2.1K] app-log.ts
|
||||
│ ├── [5.3K] app-overview.ts
|
||||
│ ├── [1.9K] app.ts
|
||||
│ ├── [4.1K] billing.ts
|
||||
│ ├── [ 17K] common.ts
|
||||
│ ├── [ 859] custom.ts
|
||||
│ ├── [5.7K] dataset-creation.ts
|
||||
│ ├── [ 10K] dataset-documents.ts
|
||||
│ ├── [ 761] dataset-hit-testing.ts
|
||||
│ ├── [1.7K] dataset-settings.ts
|
||||
│ ├── [2.0K] dataset.ts
|
||||
│ ├── [ 941] explore.ts
|
||||
│ ├── [ 52] layout.ts
|
||||
│ ├── [2.3K] login.ts
|
||||
│ ├── [ 52] register.ts
|
||||
│ ├── [2.5K] share.ts
|
||||
│ └── [2.8K] tools.ts
|
||||
├── [1.6K] i18next-config.ts
|
||||
├── [ 634] index.ts
|
||||
├── [4.4K] language.ts
|
||||
```
|
||||
|
||||
We use English as the default language. The i18n files are organized by language and then by module. For example, the English translation for the `app` module is in `en-US/app.ts`.
|
||||
|
||||
If you want to add a new language or modify an existing translation, you can create a new file for the language or modify the existing file. The file name should be the language code (e.g., `zh-Hans` for Chinese) and the file extension should be `.ts`.
|
||||
|
||||
For example, if you want to add french translation, you can create a new folder `fr-FR` and add the translation files in it.
|
||||
|
||||
By default we will use `LanguagesSupported` to determine which languages are supported. For example, in login page and settings page, we will use `LanguagesSupported` to determine which languages are supported and display them in the language selection dropdown.
|
||||
|
||||
## Example
|
||||
|
||||
1. Create a new folder for the new language.
|
||||
|
||||
```
|
||||
cd web/i18n
|
||||
cp -r en-US id-ID
|
||||
```
|
||||
|
||||
2. Modify the translation files in the new folder.
|
||||
|
||||
1. Add type to new language in the `language.ts` file.
|
||||
|
||||
```typescript
|
||||
export type I18nText = {
|
||||
'en-US': string
|
||||
'zh-Hans': string
|
||||
'pt-BR': string
|
||||
'es-ES': string
|
||||
'fr-FR': string
|
||||
'de-DE': string
|
||||
'ja-JP': string
|
||||
'ko-KR': string
|
||||
'ru-RU': string
|
||||
'it-IT': string
|
||||
'uk-UA': string
|
||||
'id-ID': string
|
||||
'tr-TR': string
|
||||
'YOUR_LANGUAGE_CODE': string
|
||||
}
|
||||
```
|
||||
|
||||
4. Add the new language to the `language.json` file.
|
||||
|
||||
```typescript
|
||||
|
||||
export const languages = [
|
||||
{
|
||||
value: 'en-US',
|
||||
name: 'English(United States)',
|
||||
example: 'Hello, Dify!',
|
||||
supported: true,
|
||||
},
|
||||
{
|
||||
value: 'zh-Hans',
|
||||
name: '简体中文',
|
||||
example: '你好,Dify!',
|
||||
supported: true,
|
||||
},
|
||||
{
|
||||
value: 'pt-BR',
|
||||
name: 'Português(Brasil)',
|
||||
example: 'Olá, Dify!',
|
||||
supported: true,
|
||||
},
|
||||
{
|
||||
value: 'es-ES',
|
||||
name: 'Español(España)',
|
||||
example: 'Saluton, Dify!',
|
||||
supported: false,
|
||||
},
|
||||
{
|
||||
value: 'fr-FR',
|
||||
name: 'Français(France)',
|
||||
example: 'Bonjour, Dify!',
|
||||
supported: false,
|
||||
},
|
||||
{
|
||||
value: 'de-DE',
|
||||
name: 'Deutsch(Deutschland)',
|
||||
example: 'Hallo, Dify!',
|
||||
supported: false,
|
||||
},
|
||||
{
|
||||
value: 'ja-JP',
|
||||
name: '日本語 (日本)',
|
||||
example: 'こんにちは、Dify!',
|
||||
supported: false,
|
||||
},
|
||||
{
|
||||
value: 'ko-KR',
|
||||
name: '한국어 (대한민국)',
|
||||
example: '안녕, Dify!',
|
||||
supported: true,
|
||||
},
|
||||
{
|
||||
value: 'ru-RU',
|
||||
name: 'Русский(Россия)',
|
||||
example: ' Привет, Dify!',
|
||||
supported: false,
|
||||
},
|
||||
{
|
||||
value: 'it-IT',
|
||||
name: 'Italiano(Italia)',
|
||||
example: 'Ciao, Dify!',
|
||||
supported: false,
|
||||
},
|
||||
{
|
||||
value: 'th-TH',
|
||||
name: 'ไทย(ประเทศไทย)',
|
||||
example: 'สวัสดี Dify!',
|
||||
supported: false,
|
||||
},
|
||||
{
|
||||
value: 'id-ID',
|
||||
name: 'Bahasa Indonesia',
|
||||
example: 'Halo, Dify!',
|
||||
supported: true,
|
||||
},
|
||||
{
|
||||
value: 'uk-UA',
|
||||
name: 'Українська(Україна)',
|
||||
example: 'Привет, Dify!',
|
||||
supported: true,
|
||||
},
|
||||
// Add your language here 👇
|
||||
...
|
||||
// Add your language here 👆
|
||||
]
|
||||
```
|
||||
|
||||
5. Don't forget to mark the supported field as `true` if the language is supported.
|
||||
|
||||
1. Sometime you might need to do some changes in the server side. Please change this file as well. 👇
|
||||
https://github.com/langgenius/dify/blob/61e4bbabaf2758354db4073cbea09fdd21a5bec1/api/constants/languages.py#L5
|
||||
|
||||
## Clean Up
|
||||
|
||||
That's it! You have successfully added a new language to the project. If you want to remove a language, you can simply delete the folder and remove the language from the `language.ts` file.
|
||||
|
||||
We have a list of languages that we support in the `language.ts` file. But some of them are not supported yet. So, they are marked as `false`. If you want to support a language, you can follow the steps above and mark the supported field as `true`.
|
||||
|
||||
## Utility scripts
|
||||
|
||||
- Auto-fill translations: `pnpm run auto-gen-i18n -- --file app common --lang zh-Hans ja-JP [--dry-run]`
|
||||
- Use space-separated values; repeat `--file` / `--lang` as needed. Defaults to all en-US files and all supported locales except en-US.
|
||||
- Protects placeholders (`{{var}}`, `${var}`, `<tag>`) before translation and restores them after.
|
||||
- Check missing/extra keys: `pnpm run check-i18n -- --file app billing --lang zh-Hans [--auto-remove]`
|
||||
- Use space-separated values; repeat `--file` / `--lang` as needed. Returns non-zero on missing/extra keys (CI will fail); `--auto-remove` deletes extra keys automatically.
|
||||
- Generate types: `pnpm run gen:i18n-types`; verify sync: `pnpm run check:i18n-types`.
|
||||
|
||||
Workflows: `.github/workflows/translate-i18n-base-on-english.yml` auto-runs the translation generator on en-US changes to main; `.github/workflows/web-tests.yml` checks i18n keys and type sync on web changes.
|
||||
428
dify/web/i18n-config/auto-gen-i18n.js
Normal file
428
dify/web/i18n-config/auto-gen-i18n.js
Normal file
@@ -0,0 +1,428 @@
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const vm = require('node:vm')
|
||||
const transpile = require('typescript').transpile
|
||||
const magicast = require('magicast')
|
||||
const { parseModule, generateCode, loadFile } = magicast
|
||||
const bingTranslate = require('bing-translate-api')
|
||||
const { translate } = bingTranslate
|
||||
const data = require('./languages.json')
|
||||
|
||||
const targetLanguage = 'en-US'
|
||||
const i18nFolder = '../i18n' // Path to i18n folder relative to this script
|
||||
// https://github.com/plainheart/bing-translate-api/blob/master/src/met/lang.json
|
||||
const languageKeyMap = data.languages.reduce((map, language) => {
|
||||
if (language.supported) {
|
||||
if (language.value === 'zh-Hans' || language.value === 'zh-Hant')
|
||||
map[language.value] = language.value
|
||||
else
|
||||
map[language.value] = language.value.split('-')[0]
|
||||
}
|
||||
|
||||
return map
|
||||
}, {})
|
||||
|
||||
const supportedLanguages = Object.keys(languageKeyMap)
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = {
|
||||
files: [],
|
||||
languages: [],
|
||||
isDryRun: false,
|
||||
help: false,
|
||||
errors: [],
|
||||
}
|
||||
|
||||
const collectValues = (startIndex) => {
|
||||
const values = []
|
||||
let cursor = startIndex + 1
|
||||
while (cursor < argv.length && !argv[cursor].startsWith('--')) {
|
||||
const value = argv[cursor].trim()
|
||||
if (value) values.push(value)
|
||||
cursor++
|
||||
}
|
||||
return { values, nextIndex: cursor - 1 }
|
||||
}
|
||||
|
||||
const validateList = (values, flag) => {
|
||||
if (!values.length) {
|
||||
args.errors.push(`${flag} requires at least one value. Example: ${flag} app billing`)
|
||||
return false
|
||||
}
|
||||
|
||||
const invalid = values.find(value => value.includes(','))
|
||||
if (invalid) {
|
||||
args.errors.push(`${flag} expects space-separated values. Example: ${flag} app billing`)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
for (let index = 2; index < argv.length; index++) {
|
||||
const arg = argv[index]
|
||||
|
||||
if (arg === '--dry-run') {
|
||||
args.isDryRun = true
|
||||
continue
|
||||
}
|
||||
|
||||
if (arg === '--help' || arg === '-h') {
|
||||
args.help = true
|
||||
break
|
||||
}
|
||||
|
||||
if (arg.startsWith('--file=')) {
|
||||
args.errors.push('--file expects space-separated values. Example: --file app billing')
|
||||
continue
|
||||
}
|
||||
|
||||
if (arg === '--file') {
|
||||
const { values, nextIndex } = collectValues(index)
|
||||
if (validateList(values, '--file'))
|
||||
args.files.push(...values)
|
||||
index = nextIndex
|
||||
continue
|
||||
}
|
||||
|
||||
if (arg.startsWith('--lang=')) {
|
||||
args.errors.push('--lang expects space-separated values. Example: --lang zh-Hans ja-JP')
|
||||
continue
|
||||
}
|
||||
|
||||
if (arg === '--lang') {
|
||||
const { values, nextIndex } = collectValues(index)
|
||||
if (validateList(values, '--lang'))
|
||||
args.languages.push(...values)
|
||||
index = nextIndex
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
console.log(`Usage: pnpm run auto-gen-i18n [options]
|
||||
|
||||
Options:
|
||||
--file <name...> Process only specific files; provide space-separated names and repeat --file if needed
|
||||
--lang <locale> Process only specific locales; provide space-separated locales and repeat --lang if needed (default: all supported except en-US)
|
||||
--dry-run Preview changes without writing files
|
||||
-h, --help Show help
|
||||
|
||||
Examples:
|
||||
pnpm run auto-gen-i18n -- --file app common --lang zh-Hans ja-JP
|
||||
pnpm run auto-gen-i18n -- --dry-run
|
||||
`)
|
||||
}
|
||||
|
||||
function protectPlaceholders(text) {
|
||||
const placeholders = []
|
||||
let safeText = text
|
||||
const patterns = [
|
||||
/\{\{[^{}]+\}\}/g, // mustache
|
||||
/\$\{[^{}]+\}/g, // template expressions
|
||||
/<[^>]+?>/g, // html-like tags
|
||||
]
|
||||
|
||||
patterns.forEach((pattern) => {
|
||||
safeText = safeText.replace(pattern, (match) => {
|
||||
const token = `__PH_${placeholders.length}__`
|
||||
placeholders.push({ token, value: match })
|
||||
return token
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
safeText,
|
||||
restore(translated) {
|
||||
return placeholders.reduce((result, { token, value }) => result.replace(new RegExp(token, 'g'), value), translated)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function translateText(source, toLanguage) {
|
||||
if (typeof source !== 'string')
|
||||
return { value: source, skipped: false }
|
||||
|
||||
const trimmed = source.trim()
|
||||
if (!trimmed)
|
||||
return { value: source, skipped: false }
|
||||
|
||||
const { safeText, restore } = protectPlaceholders(source)
|
||||
|
||||
try {
|
||||
const { translation } = await translate(safeText, null, languageKeyMap[toLanguage])
|
||||
return { value: restore(translation), skipped: false }
|
||||
}
|
||||
catch (error) {
|
||||
console.error(`❌ Error translating to ${toLanguage}:`, error.message)
|
||||
return { value: source, skipped: true, error: error.message }
|
||||
}
|
||||
}
|
||||
|
||||
async function translateMissingKeyDeeply(sourceObj, targetObject, toLanguage) {
|
||||
const skippedKeys = []
|
||||
const translatedKeys = []
|
||||
|
||||
const entries = Object.keys(sourceObj)
|
||||
|
||||
const processArray = async (sourceArray, targetArray, parentKey) => {
|
||||
for (let i = 0; i < sourceArray.length; i++) {
|
||||
const item = sourceArray[i]
|
||||
const pathKey = `${parentKey}[${i}]`
|
||||
|
||||
const existingTarget = targetArray[i]
|
||||
|
||||
if (typeof item === 'object' && item !== null) {
|
||||
const targetChild = (Array.isArray(existingTarget) || typeof existingTarget === 'object') ? existingTarget : (Array.isArray(item) ? [] : {})
|
||||
const childResult = await translateMissingKeyDeeply(item, targetChild, toLanguage)
|
||||
targetArray[i] = targetChild
|
||||
skippedKeys.push(...childResult.skipped.map(k => `${pathKey}.${k}`))
|
||||
translatedKeys.push(...childResult.translated.map(k => `${pathKey}.${k}`))
|
||||
}
|
||||
else {
|
||||
if (existingTarget !== undefined)
|
||||
continue
|
||||
|
||||
const translationResult = await translateText(item, toLanguage)
|
||||
targetArray[i] = translationResult.value ?? ''
|
||||
if (translationResult.skipped)
|
||||
skippedKeys.push(`${pathKey}: ${item}`)
|
||||
else
|
||||
translatedKeys.push(pathKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of entries) {
|
||||
const sourceValue = sourceObj[key]
|
||||
const targetValue = targetObject[key]
|
||||
|
||||
if (targetValue === undefined) {
|
||||
if (Array.isArray(sourceValue)) {
|
||||
const translatedArray = []
|
||||
await processArray(sourceValue, translatedArray, key)
|
||||
targetObject[key] = translatedArray
|
||||
}
|
||||
else if (typeof sourceValue === 'object' && sourceValue !== null) {
|
||||
targetObject[key] = {}
|
||||
const result = await translateMissingKeyDeeply(sourceValue, targetObject[key], toLanguage)
|
||||
skippedKeys.push(...result.skipped.map(k => `${key}.${k}`))
|
||||
translatedKeys.push(...result.translated.map(k => `${key}.${k}`))
|
||||
}
|
||||
else {
|
||||
const translationResult = await translateText(sourceValue, toLanguage)
|
||||
targetObject[key] = translationResult.value ?? ''
|
||||
if (translationResult.skipped)
|
||||
skippedKeys.push(`${key}: ${sourceValue}`)
|
||||
else
|
||||
translatedKeys.push(key)
|
||||
}
|
||||
}
|
||||
else if (Array.isArray(sourceValue)) {
|
||||
const targetArray = Array.isArray(targetValue) ? targetValue : []
|
||||
await processArray(sourceValue, targetArray, key)
|
||||
targetObject[key] = targetArray
|
||||
}
|
||||
else if (typeof sourceValue === 'object' && sourceValue !== null) {
|
||||
const targetChild = targetValue && typeof targetValue === 'object' ? targetValue : {}
|
||||
targetObject[key] = targetChild
|
||||
const result = await translateMissingKeyDeeply(sourceValue, targetChild, toLanguage)
|
||||
skippedKeys.push(...result.skipped.map(k => `${key}.${k}`))
|
||||
translatedKeys.push(...result.translated.map(k => `${key}.${k}`))
|
||||
}
|
||||
else {
|
||||
// Overwrite when type is different or value is missing to keep structure in sync
|
||||
const shouldUpdate = typeof targetValue !== typeof sourceValue || targetValue === undefined || targetValue === null
|
||||
if (shouldUpdate) {
|
||||
const translationResult = await translateText(sourceValue, toLanguage)
|
||||
targetObject[key] = translationResult.value ?? ''
|
||||
if (translationResult.skipped)
|
||||
skippedKeys.push(`${key}: ${sourceValue}`)
|
||||
else
|
||||
translatedKeys.push(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { skipped: skippedKeys, translated: translatedKeys }
|
||||
}
|
||||
async function autoGenTrans(fileName, toGenLanguage, isDryRun = false) {
|
||||
const fullKeyFilePath = path.resolve(__dirname, i18nFolder, targetLanguage, `${fileName}.ts`)
|
||||
const toGenLanguageFilePath = path.resolve(__dirname, i18nFolder, toGenLanguage, `${fileName}.ts`)
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(fullKeyFilePath, 'utf8')
|
||||
|
||||
// Create a safer module environment for vm
|
||||
const moduleExports = {}
|
||||
const context = {
|
||||
exports: moduleExports,
|
||||
module: { exports: moduleExports },
|
||||
require,
|
||||
console,
|
||||
__filename: fullKeyFilePath,
|
||||
__dirname: path.dirname(fullKeyFilePath),
|
||||
}
|
||||
|
||||
// Use vm.runInNewContext instead of eval for better security
|
||||
vm.runInNewContext(transpile(content), context)
|
||||
|
||||
const fullKeyContent = moduleExports.default || moduleExports
|
||||
|
||||
if (!fullKeyContent || typeof fullKeyContent !== 'object')
|
||||
throw new Error(`Failed to extract translation object from ${fullKeyFilePath}`)
|
||||
|
||||
// if toGenLanguageFilePath is not exist, create it
|
||||
if (!fs.existsSync(toGenLanguageFilePath)) {
|
||||
fs.writeFileSync(toGenLanguageFilePath, `const translation = {
|
||||
}
|
||||
|
||||
export default translation
|
||||
`)
|
||||
}
|
||||
// To keep object format and format it for magicast to work: const translation = { ... } => export default {...}
|
||||
const readContent = await loadFile(toGenLanguageFilePath)
|
||||
const { code: toGenContent } = generateCode(readContent)
|
||||
|
||||
const mod = await parseModule(`export default ${toGenContent.replace('export default translation', '').replace('const translation = ', '')}`)
|
||||
const toGenOutPut = mod.exports.default
|
||||
|
||||
console.log(`\n🌍 Processing ${fileName} for ${toGenLanguage}...`)
|
||||
const result = await translateMissingKeyDeeply(fullKeyContent, toGenOutPut, toGenLanguage)
|
||||
|
||||
// Generate summary report
|
||||
console.log(`\n📊 Translation Summary for ${fileName} -> ${toGenLanguage}:`)
|
||||
console.log(` ✅ Translated: ${result.translated.length} keys`)
|
||||
console.log(` ⏭️ Skipped: ${result.skipped.length} keys`)
|
||||
|
||||
if (result.skipped.length > 0) {
|
||||
console.log(`\n⚠️ Skipped keys in ${fileName} (${toGenLanguage}):`)
|
||||
result.skipped.slice(0, 5).forEach(item => console.log(` - ${item}`))
|
||||
if (result.skipped.length > 5)
|
||||
console.log(` ... and ${result.skipped.length - 5} more`)
|
||||
}
|
||||
|
||||
const { code } = generateCode(mod)
|
||||
let res = `const translation =${code.replace('export default', '')}
|
||||
|
||||
export default translation
|
||||
`.replace(/,\n\n/g, ',\n').replace('};', '}')
|
||||
|
||||
if (!isDryRun) {
|
||||
fs.writeFileSync(toGenLanguageFilePath, res)
|
||||
console.log(`💾 Saved translations to ${toGenLanguageFilePath}`)
|
||||
}
|
||||
else {
|
||||
console.log(`🔍 [DRY RUN] Would save translations to ${toGenLanguageFilePath}`)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
catch (error) {
|
||||
console.error(`Error processing file ${fullKeyFilePath}:`, error.message)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Add command line argument support
|
||||
const args = parseArgs(process.argv)
|
||||
const isDryRun = args.isDryRun
|
||||
const targetFiles = args.files
|
||||
const targetLangs = args.languages
|
||||
|
||||
// Rate limiting helper
|
||||
function delay(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (args.help) {
|
||||
printHelp()
|
||||
return
|
||||
}
|
||||
|
||||
if (args.errors.length) {
|
||||
args.errors.forEach(message => console.error(`❌ ${message}`))
|
||||
printHelp()
|
||||
process.exit(1)
|
||||
return
|
||||
}
|
||||
|
||||
console.log('🚀 Starting auto-gen-i18n script...')
|
||||
console.log(`📋 Mode: ${isDryRun ? 'DRY RUN (no files will be modified)' : 'LIVE MODE'}`)
|
||||
|
||||
const filesInEn = fs
|
||||
.readdirSync(path.resolve(__dirname, i18nFolder, targetLanguage))
|
||||
.filter(file => /\.ts$/.test(file)) // Only process .ts files
|
||||
.map(file => file.replace(/\.ts$/, ''))
|
||||
|
||||
// Filter by target files if specified
|
||||
const filesToProcess = targetFiles.length > 0 ? filesInEn.filter(f => targetFiles.includes(f)) : filesInEn
|
||||
const languagesToProcess = Array.from(new Set((targetLangs.length > 0 ? targetLangs : supportedLanguages)
|
||||
.filter(lang => lang !== targetLanguage)))
|
||||
|
||||
const unknownLangs = languagesToProcess.filter(lang => !languageKeyMap[lang])
|
||||
if (unknownLangs.length) {
|
||||
console.error(`❌ Unsupported languages: ${unknownLangs.join(', ')}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (!filesToProcess.length) {
|
||||
console.log('ℹ️ No files to process based on provided arguments')
|
||||
return
|
||||
}
|
||||
|
||||
if (!languagesToProcess.length) {
|
||||
console.log('ℹ️ No languages to process (did you only specify en-US?)')
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`📁 Files to process: ${filesToProcess.join(', ')}`)
|
||||
console.log(`🌍 Languages to process: ${languagesToProcess.join(', ')}`)
|
||||
|
||||
let totalTranslated = 0
|
||||
let totalSkipped = 0
|
||||
let totalErrors = 0
|
||||
|
||||
// Process files sequentially to avoid API rate limits
|
||||
for (const file of filesToProcess) {
|
||||
console.log(`\n📄 Processing file: ${file}`)
|
||||
|
||||
// Process languages with rate limiting
|
||||
for (const language of languagesToProcess) {
|
||||
try {
|
||||
const result = await autoGenTrans(file, language, isDryRun)
|
||||
totalTranslated += result.translated.length
|
||||
totalSkipped += result.skipped.length
|
||||
|
||||
// Rate limiting: wait 500ms between language processing
|
||||
await delay(500)
|
||||
}
|
||||
catch (e) {
|
||||
console.error(`❌ Error translating ${file} to ${language}:`, e.message)
|
||||
totalErrors++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Final summary
|
||||
console.log('\n🎉 Auto-translation completed!')
|
||||
console.log('📊 Final Summary:')
|
||||
console.log(` ✅ Total keys translated: ${totalTranslated}`)
|
||||
console.log(` ⏭️ Total keys skipped: ${totalSkipped}`)
|
||||
console.log(` ❌ Total errors: ${totalErrors}`)
|
||||
|
||||
if (isDryRun)
|
||||
console.log('\n💡 This was a dry run. To actually translate, run without --dry-run flag.')
|
||||
|
||||
if (totalErrors > 0)
|
||||
process.exitCode = 1
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('❌ Unexpected error:', error.message)
|
||||
process.exit(1)
|
||||
})
|
||||
130
dify/web/i18n-config/check-i18n-sync.js
Normal file
130
dify/web/i18n-config/check-i18n-sync.js
Normal file
@@ -0,0 +1,130 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const { camelCase } = require('lodash')
|
||||
const ts = require('typescript')
|
||||
|
||||
// Import the NAMESPACES array from i18next-config.ts
|
||||
function getNamespacesFromConfig() {
|
||||
const configPath = path.join(__dirname, 'i18next-config.ts')
|
||||
const configContent = fs.readFileSync(configPath, 'utf8')
|
||||
const sourceFile = ts.createSourceFile(configPath, configContent, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS)
|
||||
|
||||
const namespaces = []
|
||||
|
||||
const visit = (node) => {
|
||||
if (
|
||||
ts.isVariableDeclaration(node)
|
||||
&& node.name.getText() === 'NAMESPACES'
|
||||
&& node.initializer
|
||||
&& ts.isArrayLiteralExpression(node.initializer)
|
||||
) {
|
||||
node.initializer.elements.forEach((el) => {
|
||||
if (ts.isStringLiteral(el))
|
||||
namespaces.push(el.text)
|
||||
})
|
||||
}
|
||||
ts.forEachChild(node, visit)
|
||||
}
|
||||
|
||||
visit(sourceFile)
|
||||
|
||||
if (!namespaces.length)
|
||||
throw new Error('Could not find NAMESPACES array in i18next-config.ts')
|
||||
|
||||
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()
|
||||
}
|
||||
496
dify/web/i18n-config/check-i18n.js
Normal file
496
dify/web/i18n-config/check-i18n.js
Normal file
@@ -0,0 +1,496 @@
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const vm = require('node:vm')
|
||||
const transpile = require('typescript').transpile
|
||||
|
||||
const targetLanguage = 'en-US'
|
||||
const data = require('./languages.json')
|
||||
const languages = data.languages.filter(language => language.supported).map(language => language.value)
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = {
|
||||
files: [],
|
||||
languages: [],
|
||||
autoRemove: false,
|
||||
help: false,
|
||||
errors: [],
|
||||
}
|
||||
|
||||
const collectValues = (startIndex) => {
|
||||
const values = []
|
||||
let cursor = startIndex + 1
|
||||
while (cursor < argv.length && !argv[cursor].startsWith('--')) {
|
||||
const value = argv[cursor].trim()
|
||||
if (value) values.push(value)
|
||||
cursor++
|
||||
}
|
||||
return { values, nextIndex: cursor - 1 }
|
||||
}
|
||||
|
||||
const validateList = (values, flag) => {
|
||||
if (!values.length) {
|
||||
args.errors.push(`${flag} requires at least one value. Example: ${flag} app billing`)
|
||||
return false
|
||||
}
|
||||
|
||||
const invalid = values.find(value => value.includes(','))
|
||||
if (invalid) {
|
||||
args.errors.push(`${flag} expects space-separated values. Example: ${flag} app billing`)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
for (let index = 2; index < argv.length; index++) {
|
||||
const arg = argv[index]
|
||||
|
||||
if (arg === '--auto-remove') {
|
||||
args.autoRemove = true
|
||||
continue
|
||||
}
|
||||
|
||||
if (arg === '--help' || arg === '-h') {
|
||||
args.help = true
|
||||
break
|
||||
}
|
||||
|
||||
if (arg.startsWith('--file=')) {
|
||||
args.errors.push('--file expects space-separated values. Example: --file app billing')
|
||||
continue
|
||||
}
|
||||
|
||||
if (arg === '--file') {
|
||||
const { values, nextIndex } = collectValues(index)
|
||||
if (validateList(values, '--file'))
|
||||
args.files.push(...values)
|
||||
index = nextIndex
|
||||
continue
|
||||
}
|
||||
|
||||
if (arg.startsWith('--lang=')) {
|
||||
args.errors.push('--lang expects space-separated values. Example: --lang zh-Hans ja-JP')
|
||||
continue
|
||||
}
|
||||
|
||||
if (arg === '--lang') {
|
||||
const { values, nextIndex } = collectValues(index)
|
||||
if (validateList(values, '--lang'))
|
||||
args.languages.push(...values)
|
||||
index = nextIndex
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
console.log(`Usage: pnpm run check-i18n [options]
|
||||
|
||||
Options:
|
||||
--file <name...> Check only specific files; provide space-separated names and repeat --file if needed
|
||||
--lang <locale> Check only specific locales; provide space-separated locales and repeat --lang if needed
|
||||
--auto-remove Remove extra keys automatically
|
||||
-h, --help Show help
|
||||
|
||||
Examples:
|
||||
pnpm run check-i18n -- --file app billing --lang zh-Hans ja-JP
|
||||
pnpm run check-i18n -- --auto-remove
|
||||
`)
|
||||
}
|
||||
|
||||
async function getKeysFromLanguage(language) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const folderPath = path.resolve(__dirname, '../i18n', language)
|
||||
const allKeys = []
|
||||
fs.readdir(folderPath, (err, files) => {
|
||||
if (err) {
|
||||
console.error('Error reading folder:', err)
|
||||
reject(err)
|
||||
return
|
||||
}
|
||||
|
||||
// Filter only .ts and .js files
|
||||
const translationFiles = files.filter(file => /\.(ts|js)$/.test(file))
|
||||
|
||||
translationFiles.forEach((file) => {
|
||||
const filePath = path.join(folderPath, file)
|
||||
const fileName = file.replace(/\.[^/.]+$/, '') // Remove file extension
|
||||
const camelCaseFileName = fileName.replace(/[-_](.)/g, (_, c) =>
|
||||
c.toUpperCase(),
|
||||
) // Convert to camel case
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf8')
|
||||
|
||||
// Create a safer module environment for vm
|
||||
const moduleExports = {}
|
||||
const context = {
|
||||
exports: moduleExports,
|
||||
module: { exports: moduleExports },
|
||||
require,
|
||||
console,
|
||||
__filename: filePath,
|
||||
__dirname: folderPath,
|
||||
}
|
||||
|
||||
// Use vm.runInNewContext instead of eval for better security
|
||||
vm.runInNewContext(transpile(content), context)
|
||||
|
||||
// Extract the translation object
|
||||
const translationObj = moduleExports.default || moduleExports
|
||||
|
||||
if(!translationObj || typeof translationObj !== 'object') {
|
||||
console.error(`Error parsing file: ${filePath}`)
|
||||
reject(new Error(`Error parsing file: ${filePath}`))
|
||||
return
|
||||
}
|
||||
|
||||
const nestedKeys = []
|
||||
const iterateKeys = (obj, prefix = '') => {
|
||||
for (const key in obj) {
|
||||
const nestedKey = prefix ? `${prefix}.${key}` : key
|
||||
if (typeof obj[key] === 'object' && obj[key] !== null && !Array.isArray(obj[key])) {
|
||||
// This is an object (but not array), recurse into it but don't add it as a key
|
||||
iterateKeys(obj[key], nestedKey)
|
||||
}
|
||||
else {
|
||||
// This is a leaf node (string, number, boolean, array, etc.), add it as a key
|
||||
nestedKeys.push(nestedKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
iterateKeys(translationObj)
|
||||
|
||||
// Fixed: accumulate keys instead of overwriting
|
||||
const fileKeys = nestedKeys.map(key => `${camelCaseFileName}.${key}`)
|
||||
allKeys.push(...fileKeys)
|
||||
}
|
||||
catch (error) {
|
||||
console.error(`Error processing file ${filePath}:`, error.message)
|
||||
reject(error)
|
||||
}
|
||||
})
|
||||
resolve(allKeys)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function removeKeysFromObject(obj, keysToRemove, prefix = '') {
|
||||
let modified = false
|
||||
for (const key in obj) {
|
||||
const fullKey = prefix ? `${prefix}.${key}` : key
|
||||
|
||||
if (keysToRemove.includes(fullKey)) {
|
||||
delete obj[key]
|
||||
modified = true
|
||||
console.log(`🗑️ Removed key: ${fullKey}`)
|
||||
}
|
||||
else if (typeof obj[key] === 'object' && obj[key] !== null) {
|
||||
const subModified = removeKeysFromObject(obj[key], keysToRemove, fullKey)
|
||||
modified = modified || subModified
|
||||
}
|
||||
}
|
||||
return modified
|
||||
}
|
||||
|
||||
async function removeExtraKeysFromFile(language, fileName, extraKeys) {
|
||||
const filePath = path.resolve(__dirname, '../i18n', language, `${fileName}.ts`)
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.log(`⚠️ File not found: ${filePath}`)
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
// Filter keys that belong to this file
|
||||
const camelCaseFileName = fileName.replace(/[-_](.)/g, (_, c) => c.toUpperCase())
|
||||
const fileSpecificKeys = extraKeys
|
||||
.filter(key => key.startsWith(`${camelCaseFileName}.`))
|
||||
.map(key => key.substring(camelCaseFileName.length + 1)) // Remove file prefix
|
||||
|
||||
if (fileSpecificKeys.length === 0)
|
||||
return false
|
||||
|
||||
console.log(`🔄 Processing file: ${filePath}`)
|
||||
|
||||
// Read the original file content
|
||||
const content = fs.readFileSync(filePath, 'utf8')
|
||||
const lines = content.split('\n')
|
||||
|
||||
let modified = false
|
||||
const linesToRemove = []
|
||||
|
||||
// Find lines to remove for each key (including multiline values)
|
||||
for (const keyToRemove of fileSpecificKeys) {
|
||||
const keyParts = keyToRemove.split('.')
|
||||
let targetLineIndex = -1
|
||||
const linesToRemoveForKey = []
|
||||
|
||||
// Build regex pattern for the exact key path
|
||||
if (keyParts.length === 1) {
|
||||
// Simple key at root level like "pickDate: 'value'"
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i]
|
||||
const simpleKeyPattern = new RegExp(`^\\s*${keyParts[0]}\\s*:`)
|
||||
if (simpleKeyPattern.test(line)) {
|
||||
targetLineIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
// Nested key - need to find the exact path
|
||||
const currentPath = []
|
||||
let braceDepth = 0
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i]
|
||||
const trimmedLine = line.trim()
|
||||
|
||||
// Track current object path
|
||||
const keyMatch = trimmedLine.match(/^(\w+)\s*:\s*{/)
|
||||
if (keyMatch) {
|
||||
currentPath.push(keyMatch[1])
|
||||
braceDepth++
|
||||
}
|
||||
else if (trimmedLine === '},' || trimmedLine === '}') {
|
||||
if (braceDepth > 0) {
|
||||
braceDepth--
|
||||
currentPath.pop()
|
||||
}
|
||||
}
|
||||
|
||||
// Check if this line matches our target key
|
||||
const leafKeyMatch = trimmedLine.match(/^(\w+)\s*:/)
|
||||
if (leafKeyMatch) {
|
||||
const fullPath = [...currentPath, leafKeyMatch[1]]
|
||||
const fullPathString = fullPath.join('.')
|
||||
|
||||
if (fullPathString === keyToRemove) {
|
||||
targetLineIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (targetLineIndex !== -1) {
|
||||
linesToRemoveForKey.push(targetLineIndex)
|
||||
|
||||
// Check if this is a multiline key-value pair
|
||||
const keyLine = lines[targetLineIndex]
|
||||
const trimmedKeyLine = keyLine.trim()
|
||||
|
||||
// If key line ends with ":" (not ":", "{ " or complete value), it's likely multiline
|
||||
if (trimmedKeyLine.endsWith(':') && !trimmedKeyLine.includes('{') && !trimmedKeyLine.match(/:\s*['"`]/)) {
|
||||
// Find the value lines that belong to this key
|
||||
let currentLine = targetLineIndex + 1
|
||||
let foundValue = false
|
||||
|
||||
while (currentLine < lines.length) {
|
||||
const line = lines[currentLine]
|
||||
const trimmed = line.trim()
|
||||
|
||||
// Skip empty lines
|
||||
if (trimmed === '') {
|
||||
currentLine++
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if this line starts a new key (indicates end of current value)
|
||||
if (trimmed.match(/^\w+\s*:/))
|
||||
break
|
||||
|
||||
// Check if this line is part of the value
|
||||
if (trimmed.startsWith('\'') || trimmed.startsWith('"') || trimmed.startsWith('`') || foundValue) {
|
||||
linesToRemoveForKey.push(currentLine)
|
||||
foundValue = true
|
||||
|
||||
// Check if this line ends the value (ends with quote and comma/no comma)
|
||||
if ((trimmed.endsWith('\',') || trimmed.endsWith('",') || trimmed.endsWith('`,')
|
||||
|| trimmed.endsWith('\'') || trimmed.endsWith('"') || trimmed.endsWith('`'))
|
||||
&& !trimmed.startsWith('//'))
|
||||
break
|
||||
}
|
||||
else {
|
||||
break
|
||||
}
|
||||
|
||||
currentLine++
|
||||
}
|
||||
}
|
||||
|
||||
linesToRemove.push(...linesToRemoveForKey)
|
||||
console.log(`🗑️ Found key to remove: ${keyToRemove} at line ${targetLineIndex + 1}${linesToRemoveForKey.length > 1 ? ` (multiline, ${linesToRemoveForKey.length} lines)` : ''}`)
|
||||
modified = true
|
||||
}
|
||||
else {
|
||||
console.log(`⚠️ Could not find key: ${keyToRemove}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (modified) {
|
||||
// Remove duplicates and sort in reverse order to maintain correct indices
|
||||
const uniqueLinesToRemove = [...new Set(linesToRemove)].sort((a, b) => b - a)
|
||||
|
||||
for (const lineIndex of uniqueLinesToRemove) {
|
||||
const line = lines[lineIndex]
|
||||
console.log(`🗑️ Removing line ${lineIndex + 1}: ${line.trim()}`)
|
||||
lines.splice(lineIndex, 1)
|
||||
|
||||
// Also remove trailing comma from previous line if it exists and the next line is a closing brace
|
||||
if (lineIndex > 0 && lineIndex < lines.length) {
|
||||
const prevLine = lines[lineIndex - 1]
|
||||
const nextLine = lines[lineIndex] ? lines[lineIndex].trim() : ''
|
||||
|
||||
if (prevLine.trim().endsWith(',') && (nextLine.startsWith('}') || nextLine === ''))
|
||||
lines[lineIndex - 1] = prevLine.replace(/,\s*$/, '')
|
||||
}
|
||||
}
|
||||
|
||||
// Write back to file
|
||||
const newContent = lines.join('\n')
|
||||
fs.writeFileSync(filePath, newContent)
|
||||
console.log(`💾 Updated file: ${filePath}`)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
catch (error) {
|
||||
console.error(`Error processing file ${filePath}:`, error.message)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Add command line argument support
|
||||
const args = parseArgs(process.argv)
|
||||
const targetFiles = Array.from(new Set(args.files))
|
||||
const targetLangs = Array.from(new Set(args.languages))
|
||||
const autoRemove = args.autoRemove
|
||||
|
||||
async function main() {
|
||||
const compareKeysCount = async () => {
|
||||
let hasDiff = false
|
||||
const allTargetKeys = await getKeysFromLanguage(targetLanguage)
|
||||
|
||||
// Filter target keys by file if specified
|
||||
const camelTargetFiles = targetFiles.map(file => file.replace(/[-_](.)/g, (_, c) => c.toUpperCase()))
|
||||
const targetKeys = targetFiles.length
|
||||
? allTargetKeys.filter(key => camelTargetFiles.some(file => key.startsWith(`${file}.`)))
|
||||
: allTargetKeys
|
||||
|
||||
// Filter languages by target language if specified
|
||||
const languagesToProcess = targetLangs.length ? targetLangs : languages
|
||||
|
||||
const allLanguagesKeys = await Promise.all(languagesToProcess.map(language => getKeysFromLanguage(language)))
|
||||
|
||||
// Filter language keys by file if specified
|
||||
const languagesKeys = targetFiles.length
|
||||
? allLanguagesKeys.map(keys => keys.filter(key => camelTargetFiles.some(file => key.startsWith(`${file}.`))))
|
||||
: allLanguagesKeys
|
||||
|
||||
const keysCount = languagesKeys.map(keys => keys.length)
|
||||
const targetKeysCount = targetKeys.length
|
||||
|
||||
const comparison = languagesToProcess.reduce((result, language, index) => {
|
||||
const languageKeysCount = keysCount[index]
|
||||
const difference = targetKeysCount - languageKeysCount
|
||||
result[language] = difference
|
||||
return result
|
||||
}, {})
|
||||
|
||||
console.log(comparison)
|
||||
|
||||
// Print missing keys and extra keys
|
||||
for (let index = 0; index < languagesToProcess.length; index++) {
|
||||
const language = languagesToProcess[index]
|
||||
const languageKeys = languagesKeys[index]
|
||||
const missingKeys = targetKeys.filter(key => !languageKeys.includes(key))
|
||||
const extraKeys = languageKeys.filter(key => !targetKeys.includes(key))
|
||||
|
||||
console.log(`Missing keys in ${language}:`, missingKeys)
|
||||
if (missingKeys.length > 0)
|
||||
hasDiff = true
|
||||
|
||||
// Show extra keys only when there are extra keys (negative difference)
|
||||
if (extraKeys.length > 0) {
|
||||
console.log(`Extra keys in ${language} (not in ${targetLanguage}):`, extraKeys)
|
||||
|
||||
// Auto-remove extra keys if flag is set
|
||||
if (autoRemove) {
|
||||
console.log(`\n🤖 Auto-removing extra keys from ${language}...`)
|
||||
|
||||
// Get all translation files
|
||||
const i18nFolder = path.resolve(__dirname, '../i18n', language)
|
||||
const files = fs.readdirSync(i18nFolder)
|
||||
.filter(file => /\.ts$/.test(file))
|
||||
.map(file => file.replace(/\.ts$/, ''))
|
||||
.filter(f => targetFiles.length === 0 || targetFiles.includes(f))
|
||||
|
||||
let totalRemoved = 0
|
||||
for (const fileName of files) {
|
||||
const removed = await removeExtraKeysFromFile(language, fileName, extraKeys)
|
||||
if (removed) totalRemoved++
|
||||
}
|
||||
|
||||
console.log(`✅ Auto-removal completed for ${language}. Modified ${totalRemoved} files.`)
|
||||
}
|
||||
else {
|
||||
hasDiff = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return hasDiff
|
||||
}
|
||||
|
||||
console.log('🚀 Starting check-i18n script...')
|
||||
if (targetFiles.length)
|
||||
console.log(`📁 Checking files: ${targetFiles.join(', ')}`)
|
||||
|
||||
if (targetLangs.length)
|
||||
console.log(`🌍 Checking languages: ${targetLangs.join(', ')}`)
|
||||
|
||||
if (autoRemove)
|
||||
console.log('🤖 Auto-remove mode: ENABLED')
|
||||
|
||||
const hasDiff = await compareKeysCount()
|
||||
if (hasDiff) {
|
||||
console.error('\n❌ i18n keys are not aligned. Fix issues above.')
|
||||
process.exitCode = 1
|
||||
}
|
||||
else {
|
||||
console.log('\n✅ All i18n files are in sync')
|
||||
}
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
if (args.help) {
|
||||
printHelp()
|
||||
return
|
||||
}
|
||||
|
||||
if (args.errors.length) {
|
||||
args.errors.forEach(message => console.error(`❌ ${message}`))
|
||||
printHelp()
|
||||
process.exit(1)
|
||||
return
|
||||
}
|
||||
|
||||
const unknownLangs = targetLangs.filter(lang => !languages.includes(lang))
|
||||
if (unknownLangs.length) {
|
||||
console.error(`❌ Unsupported languages: ${unknownLangs.join(', ')}`)
|
||||
process.exit(1)
|
||||
return
|
||||
}
|
||||
|
||||
await main()
|
||||
}
|
||||
|
||||
bootstrap().catch((error) => {
|
||||
console.error('❌ Unexpected error:', error.message)
|
||||
process.exit(1)
|
||||
})
|
||||
145
dify/web/i18n-config/generate-i18n-types.js
Normal file
145
dify/web/i18n-config/generate-i18n-types.js
Normal file
@@ -0,0 +1,145 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const { camelCase } = require('lodash')
|
||||
const ts = require('typescript')
|
||||
|
||||
// Import the NAMESPACES array from i18next-config.ts
|
||||
function getNamespacesFromConfig() {
|
||||
const configPath = path.join(__dirname, 'i18next-config.ts')
|
||||
const configContent = fs.readFileSync(configPath, 'utf8')
|
||||
const sourceFile = ts.createSourceFile(configPath, configContent, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS)
|
||||
|
||||
const namespaces = []
|
||||
|
||||
const visit = (node) => {
|
||||
if (
|
||||
ts.isVariableDeclaration(node)
|
||||
&& node.name.getText() === 'NAMESPACES'
|
||||
&& node.initializer
|
||||
&& ts.isArrayLiteralExpression(node.initializer)
|
||||
) {
|
||||
node.initializer.elements.forEach((el) => {
|
||||
if (ts.isStringLiteral(el))
|
||||
namespaces.push(el.text)
|
||||
})
|
||||
}
|
||||
ts.forEachChild(node, visit)
|
||||
}
|
||||
|
||||
visit(sourceFile)
|
||||
|
||||
if (!namespaces.length)
|
||||
throw new Error('Could not find NAMESPACES array in i18next-config.ts')
|
||||
|
||||
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()
|
||||
}
|
||||
92
dify/web/i18n-config/i18next-config.ts
Normal file
92
dify/web/i18n-config/i18next-config.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
'use client'
|
||||
import i18n from 'i18next'
|
||||
import { camelCase } from 'lodash-es'
|
||||
import { initReactI18next } from 'react-i18next'
|
||||
|
||||
const requireSilent = async (lang: string, namespace: string) => {
|
||||
let res
|
||||
try {
|
||||
res = (await import(`../i18n/${lang}/${namespace}`)).default
|
||||
}
|
||||
catch {
|
||||
res = (await import(`../i18n/en-US/${namespace}`)).default
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
const NAMESPACES = [
|
||||
'app-annotation',
|
||||
'app-api',
|
||||
'app-debug',
|
||||
'app-log',
|
||||
'app-overview',
|
||||
'app',
|
||||
'billing',
|
||||
'common',
|
||||
'custom',
|
||||
'dataset-creation',
|
||||
'dataset-documents',
|
||||
'dataset-hit-testing',
|
||||
'dataset-pipeline',
|
||||
'dataset-settings',
|
||||
'dataset',
|
||||
'education',
|
||||
'explore',
|
||||
'layout',
|
||||
'login',
|
||||
'oauth',
|
||||
'pipeline',
|
||||
'plugin-tags',
|
||||
'plugin-trigger',
|
||||
'plugin',
|
||||
'register',
|
||||
'run-log',
|
||||
'share',
|
||||
'time',
|
||||
'tools',
|
||||
'workflow',
|
||||
]
|
||||
|
||||
export const loadLangResources = async (lang: string) => {
|
||||
const modules = await Promise.all(
|
||||
NAMESPACES.map(ns => requireSilent(lang, ns)),
|
||||
)
|
||||
const resources = modules.reduce((acc, mod, index) => {
|
||||
acc[camelCase(NAMESPACES[index])] = mod
|
||||
return acc
|
||||
}, {} as Record<string, any>)
|
||||
return resources
|
||||
}
|
||||
|
||||
// Load en-US resources first to make sure fallback works
|
||||
const getInitialTranslations = () => {
|
||||
const en_USResources = NAMESPACES.reduce((acc, ns, index) => {
|
||||
acc[camelCase(NAMESPACES[index])] = require(`../i18n/en-US/${ns}`).default
|
||||
return acc
|
||||
}, {} as Record<string, any>)
|
||||
return {
|
||||
'en-US': {
|
||||
translation: en_USResources,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (!i18n.isInitialized) {
|
||||
i18n.use(initReactI18next).init({
|
||||
lng: undefined,
|
||||
fallbackLng: 'en-US',
|
||||
resources: getInitialTranslations(),
|
||||
})
|
||||
}
|
||||
|
||||
export const changeLanguage = async (lng?: string) => {
|
||||
if (!lng) return
|
||||
if (!i18n.hasResourceBundle(lng, 'translation')) {
|
||||
const resource = await loadLangResources(lng)
|
||||
i18n.addResourceBundle(lng, 'translation', resource, true, true)
|
||||
}
|
||||
await i18n.changeLanguage(lng)
|
||||
}
|
||||
|
||||
export default i18n
|
||||
30
dify/web/i18n-config/index.ts
Normal file
30
dify/web/i18n-config/index.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import Cookies from 'js-cookie'
|
||||
|
||||
import { changeLanguage } from '@/i18n-config/i18next-config'
|
||||
import { LOCALE_COOKIE_NAME } from '@/config'
|
||||
import { LanguagesSupported } from '@/i18n-config/language'
|
||||
|
||||
export const i18n = {
|
||||
defaultLocale: 'en-US',
|
||||
locales: LanguagesSupported,
|
||||
} as const
|
||||
|
||||
export type Locale = typeof i18n['locales'][number]
|
||||
|
||||
export const setLocaleOnClient = async (locale: Locale, reloadPage = true) => {
|
||||
Cookies.set(LOCALE_COOKIE_NAME, locale, { expires: 365 })
|
||||
await changeLanguage(locale)
|
||||
if (reloadPage)
|
||||
location.reload()
|
||||
}
|
||||
|
||||
export const getLocaleOnClient = (): Locale => {
|
||||
return Cookies.get(LOCALE_COOKIE_NAME) as Locale || i18n.defaultLocale
|
||||
}
|
||||
|
||||
export const renderI18nObject = (obj: Record<string, string>, language: string) => {
|
||||
if (!obj) return ''
|
||||
if (obj?.[language]) return obj[language]
|
||||
if (obj?.en_US) return obj.en_US
|
||||
return Object.values(obj)[0]
|
||||
}
|
||||
122
dify/web/i18n-config/language.ts
Normal file
122
dify/web/i18n-config/language.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import data from './languages.json'
|
||||
export type Item = {
|
||||
value: number | string
|
||||
name: string
|
||||
example: string
|
||||
}
|
||||
|
||||
export type I18nText = {
|
||||
'en-US': string
|
||||
'zh-Hans': string
|
||||
'zh-Hant': string
|
||||
'pt-BR': string
|
||||
'es-ES': string
|
||||
'fr-FR': string
|
||||
'de-DE': string
|
||||
'ja-JP': string
|
||||
'ko-KR': string
|
||||
'ru-RU': string
|
||||
'it-IT': string
|
||||
'th-TH': string
|
||||
'id-ID': string
|
||||
'uk-UA': string
|
||||
'vi-VN': string
|
||||
'ro-RO': string
|
||||
'pl-PL': string
|
||||
'hi-IN': string
|
||||
'tr-TR': string
|
||||
'fa-IR': string
|
||||
'sl-SI': string
|
||||
}
|
||||
|
||||
export const languages = data.languages
|
||||
|
||||
export const LanguagesSupported = languages.filter(item => item.supported).map(item => item.value)
|
||||
|
||||
export const getLanguage = (locale: string) => {
|
||||
if (['zh-Hans', 'ja-JP'].includes(locale))
|
||||
return locale.replace('-', '_')
|
||||
|
||||
return LanguagesSupported[0].replace('-', '_')
|
||||
}
|
||||
|
||||
const DOC_LANGUAGE: Record<string, string> = {
|
||||
'zh-Hans': 'zh-hans',
|
||||
'ja-JP': 'ja-jp',
|
||||
'en-US': 'en',
|
||||
}
|
||||
|
||||
export const getDocLanguage = (locale: string) => {
|
||||
return DOC_LANGUAGE[locale] || 'en'
|
||||
}
|
||||
|
||||
const PRICING_PAGE_LANGUAGE: Record<string, string> = {
|
||||
'ja-JP': 'jp',
|
||||
}
|
||||
|
||||
export const getPricingPageLanguage = (locale: string) => {
|
||||
return PRICING_PAGE_LANGUAGE[locale] || ''
|
||||
}
|
||||
|
||||
export const NOTICE_I18N = {
|
||||
title: {
|
||||
en_US: 'Important Notice',
|
||||
zh_Hans: '重要公告',
|
||||
zh_Hant: '重要公告',
|
||||
pt_BR: 'Aviso Importante',
|
||||
es_ES: 'Aviso Importante',
|
||||
fr_FR: 'Avis important',
|
||||
de_DE: 'Wichtiger Hinweis',
|
||||
ja_JP: '重要なお知らせ',
|
||||
ko_KR: '중요 공지',
|
||||
ru_RU: 'Важное Уведомление',
|
||||
it_IT: 'Avviso Importante',
|
||||
th_TH: 'ประกาศสำคัญ',
|
||||
id_ID: 'Pengumuman Penting',
|
||||
uk_UA: 'Важливе повідомлення',
|
||||
vi_VN: 'Thông báo quan trọng',
|
||||
ro_RO: 'Anunț Important',
|
||||
pl_PL: 'Ważne ogłoszenie',
|
||||
hi_IN: 'महत्वपूर्ण सूचना',
|
||||
tr_TR: 'Önemli Duyuru',
|
||||
fa_IR: 'هشدار مهم',
|
||||
sl_SI: 'Pomembno obvestilo',
|
||||
},
|
||||
desc: {
|
||||
en_US:
|
||||
'Our system will be unavailable from 19:00 to 24:00 UTC on August 28 for an upgrade. For questions, kindly contact our support team (support@dify.ai). We value your patience.',
|
||||
zh_Hans:
|
||||
'为了有效提升数据检索能力及稳定性,Dify 将于 2023 年 8 月 29 日 03:00 至 08:00 期间进行服务升级,届时 Dify 云端版及应用将无法访问。感谢您的耐心与支持。',
|
||||
pt_BR:
|
||||
'Our system will be unavailable from 19:00 to 24:00 UTC on August 28 for an upgrade. For questions, kindly contact our support team (support@dify.ai). We value your patience.',
|
||||
es_ES:
|
||||
'Our system will be unavailable from 19:00 to 24:00 UTC on August 28 for an upgrade. For questions, kindly contact our support team (support@dify.ai). We value your patience.',
|
||||
fr_FR:
|
||||
'Our system will be unavailable from 19:00 to 24:00 UTC on August 28 for an upgrade. For questions, kindly contact our support team (support@dify.ai). We value your patience.',
|
||||
de_DE:
|
||||
'Our system will be unavailable from 19:00 to 24:00 UTC on August 28 for an upgrade. For questions, kindly contact our support team (support@dify.ai). We value your patience.',
|
||||
ja_JP:
|
||||
'Our system will be unavailable from 19:00 to 24:00 UTC on August 28 for an upgrade. For questions, kindly contact our support team (support@dify.ai). We value your patience.',
|
||||
ko_KR:
|
||||
'시스템이 업그레이드를 위해 UTC 시간대로 8 월 28 일 19:00 ~ 24:00 에 사용 불가될 예정입니다. 질문이 있으시면 지원 팀에 연락주세요 (support@dify.ai). 최선을 다해 답변해드리겠습니다.',
|
||||
pl_PL:
|
||||
'Nasz system będzie niedostępny od 19:00 do 24:00 UTC 28 sierpnia w celu aktualizacji. W przypadku pytań prosimy o kontakt z naszym zespołem wsparcia (support@dify.ai). Doceniamy Twoją cierpliwość.',
|
||||
uk_UA:
|
||||
'Наша система буде недоступна з 19:00 до 24:00 UTC 28 серпня для оновлення. Якщо у вас виникнуть запитання, будь ласка, зв’яжіться з нашою службою підтримки (support@dify.ai). Дякуємо за терпіння.',
|
||||
ru_RU:
|
||||
'Наша система будет недоступна с 19:00 до 24:00 UTC 28 августа для обновления. По вопросам, пожалуйста, обращайтесь в нашу службу поддержки (support@dify.ai). Спасибо за ваше терпение',
|
||||
vi_VN:
|
||||
'Hệ thống của chúng tôi sẽ ngừng hoạt động từ 19:00 đến 24:00 UTC vào ngày 28 tháng 8 để nâng cấp. Nếu có thắc mắc, vui lòng liên hệ với nhóm hỗ trợ của chúng tôi (support@dify.ai). Chúng tôi đánh giá cao sự kiên nhẫn của bạn.',
|
||||
id_ID:
|
||||
'Sistem kami tidak akan tersedia dari 19:00 hingga 24:00 UTC pada 28 Agustus untuk pemutakhiran. Untuk pertanyaan, silakan hubungi tim dukungan kami (support@dify.ai). Kami menghargai kesabaran Anda.',
|
||||
tr_TR:
|
||||
'Sistemimiz, 28 Ağustos\'ta 19:00 ile 24:00 UTC saatleri arasında güncelleme nedeniyle kullanılamayacaktır. Sorularınız için lütfen destek ekibimizle iletişime geçin (support@dify.ai). Sabrınız için teşekkür ederiz.',
|
||||
fa_IR:
|
||||
'سیستم ما از ساعت 19:00 تا 24:00 UTC در تاریخ 28 اوت برای ارتقاء در دسترس نخواهد بود. برای سؤالات، لطفاً با تیم پشتیبانی ما (support@dify.ai) تماس بگیرید. ما برای صبر شما ارزش قائلیم.',
|
||||
sl_SI:
|
||||
'Naš sistem ne bo na voljo od 19:00 do 24:00 UTC 28. avgusta zaradi nadgradnje. Za vprašanja se obrnite na našo skupino za podporo (support@dify.ai). Cenimo vašo potrpežljivost.',
|
||||
th_TH:
|
||||
'ระบบของเราจะไม่สามารถใช้งานได้ตั้งแต่เวลา 19:00 ถึง 24:00 UTC ในวันที่ 28 สิงหาคม เพื่อทำการอัปเกรด หากมีคำถามใดๆ กรุณาติดต่อทีมสนับสนุนของเรา (support@dify.ai) เราขอขอบคุณในความอดทนของท่าน',
|
||||
},
|
||||
href: '#',
|
||||
}
|
||||
151
dify/web/i18n-config/languages.json
Normal file
151
dify/web/i18n-config/languages.json
Normal file
@@ -0,0 +1,151 @@
|
||||
{
|
||||
"languages": [
|
||||
{
|
||||
"value": "en-US",
|
||||
"name": "English (United States)",
|
||||
"prompt_name": "English",
|
||||
"example": "Hello, Dify!",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"value": "zh-Hans",
|
||||
"name": "简体中文",
|
||||
"prompt_name": "Chinese Simplified",
|
||||
"example": "你好,Dify!",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"value": "zh-Hant",
|
||||
"name": "繁體中文",
|
||||
"prompt_name": "Chinese Traditional",
|
||||
"example": "你好,Dify!",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"value": "pt-BR",
|
||||
"name": "Português (Brasil)",
|
||||
"prompt_name": "Portuguese",
|
||||
"example": "Olá, Dify!",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"value": "es-ES",
|
||||
"name": "Español (España)",
|
||||
"prompt_name": "Spanish",
|
||||
"example": "¡Hola, Dify!",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"value": "fr-FR",
|
||||
"name": "Français (France)",
|
||||
"prompt_name": "French",
|
||||
"example": "Bonjour, Dify!",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"value": "de-DE",
|
||||
"name": "Deutsch (Deutschland)",
|
||||
"prompt_name": "German",
|
||||
"example": "Hallo, Dify!",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"value": "ja-JP",
|
||||
"name": "日本語 (日本)",
|
||||
"prompt_name": "Japanese",
|
||||
"example": "こんにちは、Dify!",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"value": "ko-KR",
|
||||
"name": "한국어 (대한민국)",
|
||||
"prompt_name": "Korean",
|
||||
"example": "안녕하세요, Dify!",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"value": "ru-RU",
|
||||
"name": "Русский (Россия)",
|
||||
"prompt_name": "Russian",
|
||||
"example": " Привет, Dify!",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"value": "it-IT",
|
||||
"name": "Italiano (Italia)",
|
||||
"prompt_name": "Italian",
|
||||
"example": "Ciao, Dify!",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"value": "th-TH",
|
||||
"name": "ไทย (ประเทศไทย)",
|
||||
"prompt_name": "Thai",
|
||||
"example": "สวัสดี Dify!",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"value": "uk-UA",
|
||||
"name": "Українська (Україна)",
|
||||
"prompt_name": "Ukrainian",
|
||||
"example": "Привет, Dify!",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"value": "vi-VN",
|
||||
"name": "Tiếng Việt (Việt Nam)",
|
||||
"prompt_name": "Vietnamese",
|
||||
"example": "Xin chào, Dify!",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"value": "ro-RO",
|
||||
"name": "Română (România)",
|
||||
"prompt_name": "Romanian",
|
||||
"example": "Salut, Dify!",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"value": "pl-PL",
|
||||
"name": "Polski (Polish)",
|
||||
"prompt_name": "Polish",
|
||||
"example": "Cześć, Dify!",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"value": "hi-IN",
|
||||
"name": "Hindi (India)",
|
||||
"prompt_name": "Hindi",
|
||||
"example": "नमस्ते, Dify!",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"value": "tr-TR",
|
||||
"name": "Türkçe",
|
||||
"prompt_name": "Türkçe",
|
||||
"example": "Selam!",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"value": "fa-IR",
|
||||
"name": "Farsi (Iran)",
|
||||
"prompt_name": "Farsi",
|
||||
"example": "سلام, دیفای!",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"value": "sl-SI",
|
||||
"name": "Slovensko (Slovenija)",
|
||||
"prompt_name": "Slovensko",
|
||||
"example": "Zdravo, Dify!",
|
||||
"supported": true
|
||||
},
|
||||
{
|
||||
"value": "id-ID",
|
||||
"name": "Bahasa Indonesia",
|
||||
"prompt_name": "Indonesian",
|
||||
"example": "Halo, Dify!",
|
||||
"supported": true
|
||||
}
|
||||
]
|
||||
}
|
||||
56
dify/web/i18n-config/server.ts
Normal file
56
dify/web/i18n-config/server.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { cookies, headers } from 'next/headers'
|
||||
import Negotiator from 'negotiator'
|
||||
import { match } from '@formatjs/intl-localematcher'
|
||||
|
||||
import { createInstance } from 'i18next'
|
||||
import resourcesToBackend from 'i18next-resources-to-backend'
|
||||
import { initReactI18next } from 'react-i18next/initReactI18next'
|
||||
import { i18n } from '.'
|
||||
import type { Locale } from '.'
|
||||
|
||||
// https://locize.com/blog/next-13-app-dir-i18n/
|
||||
const initI18next = async (lng: Locale, ns: string) => {
|
||||
const i18nInstance = createInstance()
|
||||
await i18nInstance
|
||||
.use(initReactI18next)
|
||||
.use(resourcesToBackend((language: string, namespace: string) => import(`../i18n/${language}/${namespace}.ts`)))
|
||||
.init({
|
||||
lng: lng === 'zh-Hans' ? 'zh-Hans' : lng,
|
||||
ns,
|
||||
fallbackLng: 'en-US',
|
||||
})
|
||||
return i18nInstance
|
||||
}
|
||||
|
||||
export async function useTranslation(lng: Locale, ns = '', options: Record<string, any> = {}) {
|
||||
const i18nextInstance = await initI18next(lng, ns)
|
||||
return {
|
||||
t: i18nextInstance.getFixedT(lng, ns, options.keyPrefix),
|
||||
i18n: i18nextInstance,
|
||||
}
|
||||
}
|
||||
|
||||
export const getLocaleOnServer = async (): Promise<Locale> => {
|
||||
const locales: string[] = i18n.locales
|
||||
|
||||
let languages: string[] | undefined
|
||||
// get locale from cookie
|
||||
const localeCookie = (await cookies()).get('locale')
|
||||
languages = localeCookie?.value ? [localeCookie.value] : []
|
||||
|
||||
if (!languages.length) {
|
||||
// Negotiator expects plain object so we need to transform headers
|
||||
const negotiatorHeaders: Record<string, string> = {};
|
||||
(await headers()).forEach((value, key) => (negotiatorHeaders[key] = value))
|
||||
// Use negotiator and intl-localematcher to get best locale
|
||||
languages = new Negotiator({ headers: negotiatorHeaders }).languages()
|
||||
}
|
||||
|
||||
// Validate languages
|
||||
if (!Array.isArray(languages) || languages.length === 0 || !languages.every(lang => typeof lang === 'string' && /^[\w-]+$/.test(lang)))
|
||||
languages = [i18n.defaultLocale]
|
||||
|
||||
// match locale
|
||||
const matchedLocale = match(languages, locales, i18n.defaultLocale) as Locale
|
||||
return matchedLocale
|
||||
}
|
||||
Reference in New Issue
Block a user