feat(i18n): integrate i18next for internationalization support

- Added i18next and react-i18next for multi-language support in the application.
- Created localization files for English, Japanese, Korean, Traditional Chinese, and Simplified Chinese.
- Implemented translation hooks in various components to replace hardcoded strings with translatable keys.
- Updated ESLint configuration to include new i18n JSON validation rules.
- Introduced a new event bus for handling i18n updates during development.

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei
2025-06-12 17:56:11 +08:00
parent 4a385b9852
commit c775f82153
40 changed files with 1874 additions and 259 deletions

View File

@@ -0,0 +1,140 @@
// @ts-check
/** @type {import("eslint").ESLint.Plugin} */
import fs from 'node:fs'
import path from 'node:path'
import { cleanJsonText } from './utils.js'
export default {
rules: {
'valid-i18n-keys': {
meta: {
type: 'problem',
docs: {
description:
'Ensure i18n JSON keys are flat and valid as object paths',
category: 'Possible Errors',
recommended: true,
},
fixable: null,
},
create(context) {
return {
Program(node) {
const { filename, sourceCode } = context
if (!filename.endsWith('.json')) return
let json
try {
json = JSON.parse(cleanJsonText(sourceCode.text))
} catch {
context.report({
node,
message: 'Invalid JSON format',
})
return
}
const keys = Object.keys(json)
const keyPrefixes = new Set()
for (const key of keys) {
if (key.includes('.')) {
const parts = key.split('.')
for (let i = 1; i < parts.length; i++) {
const prefix = parts.slice(0, i).join('.')
if (keys.includes(prefix)) {
context.report({
node,
message: `Invalid key structure: '${key}' conflicts with '${prefix}'`,
})
}
keyPrefixes.add(prefix)
}
}
}
for (const key of keys) {
if (keyPrefixes.has(key)) {
context.report({
node,
message: `Invalid key structure: '${key}' is a prefix of another key`,
})
}
}
},
}
},
},
'no-extra-keys': {
meta: {
type: 'problem',
docs: {
description:
"Ensure non-English JSON files don't have extra keys not present in en.json",
category: 'Possible Errors',
recommended: true,
},
fixable: 'code', // Set fixable to "code"
},
create(context) {
return {
Program(node) {
const { filename, sourceCode } = context
if (!filename.endsWith('.json')) return
const parts = filename.split(path.sep)
const lang = parts.at(-1).split('.')[0]
const namespace = parts.at(-2)
if (lang === 'en') return
let currentJson = {}
let englishJson = {}
try {
currentJson = JSON.parse(sourceCode.text)
const englishFilePath = path.join(
path.dirname(filename),
'../',
namespace,
'en.json',
)
englishJson = JSON.parse(fs.readFileSync(englishFilePath, 'utf8'))
} catch (error) {
context.report({
node,
message: `Error parsing JSON: ${error.message}`,
})
return
}
const extraKeys = Object.keys(currentJson).filter(
(key) => !Object.prototype.hasOwnProperty.call(englishJson, key),
)
for (const key of extraKeys) {
context.report({
node,
message: `Key "${key}" is present in ${lang}.json but not in en.json for namespace "${namespace}"`,
fix(fixer) {
const newJson = Object.fromEntries(
Object.entries(currentJson).filter(
([k]) => !extraKeys.includes(k),
),
)
const newText = `${JSON.stringify(newJson, null, 2)}\n`
return fixer.replaceText(node, newText)
},
})
}
},
}
},
},
},
}

View File

@@ -0,0 +1,34 @@
/**
* @type {import("eslint").ESLint.Plugin}
*/
export default {
rules: {
'no-debug-stack': {
meta: {
type: 'problem',
docs: {
description: 'Disallow use of debugStack() function',
category: 'Possible Errors',
recommended: true,
},
fixable: null,
},
create(context) {
return {
CallExpression(node) {
if (
node.callee.type === 'Identifier' &&
node.callee.name === 'debugStack'
) {
context.report({
node,
message:
'Unexpected debugStack() statement. Remove debugStack() calls from production code.',
})
}
},
}
},
},
},
}

View File

@@ -0,0 +1,147 @@
// @ts-check
import fs from 'node:fs'
import path from 'node:path'
import process from 'node:process'
import fg from 'fast-glob'
const dependencyKeys = ['dependencies', 'devDependencies']
/** @type {import("eslint").ESLint.Plugin} */
export default {
rules: {
'ensure-package-version': {
meta: {
type: 'problem',
docs: {
description:
'Ensure that the versions of packages in the workspace are consistent',
category: 'Possible Errors',
recommended: true,
},
fixable: 'code',
hasSuggestions: true,
},
create(context) {
if (!context.filename.endsWith('package.json')) return {}
const cwd = process.cwd()
const packageJsonFilePaths = fg.globSync(
['packages/*/package.json', 'apps/*/package.json', 'package.json'],
{
cwd,
ignore: ['**/node_modules/**'],
},
)
/** @type {Map<string, { version: string, filePath: string }[]>} */
const packageVersionMap = new Map()
packageJsonFilePaths.forEach((filePath) => {
if (filePath === path.relative(cwd, context.filename)) return
const packageJson = JSON.parse(fs.readFileSync(filePath, 'utf-8'))
dependencyKeys.forEach((key) => {
const dependencies = packageJson[key]
if (!dependencies) return
Object.keys(dependencies).forEach((dependency) => {
if (!packageVersionMap.has(dependency)) {
packageVersionMap.set(dependency, [])
}
packageVersionMap.get(dependency)?.push({
version: dependencies[dependency],
filePath,
})
})
})
})
return {
'Program > JSONExpressionStatement > JSONObjectExpression > JSONProperty > JSONObjectExpression > JSONProperty'(
node,
) {
const parent = node?.parent?.parent
if (!parent) return
const packageCategory = parent.key.value
if (!dependencyKeys.includes(packageCategory)) return
const packageName = node.key.value
const packageVersion = node.value.value
const versions = packageVersionMap.get(packageName)
if (!versions || versions.find((v) => v.version === packageVersion))
return
context.report({
node,
message: `Inconsistent versions of ${packageName}: ${Array.from(new Set(versions.map((v) => v.version))).join(', ')}`,
suggest: versions.map((version) => ({
desc: `Follow the version ${version.version} in ${version.filePath}`,
fix: (fixer) =>
fixer.replaceText(node.value, `"${version.version}"`),
})),
})
},
}
},
},
'no-duplicate-package': {
meta: {
type: 'problem',
docs: {
description: 'Ensure packages are not duplicated in one package.json',
category: 'Possible Errors',
recommended: true,
},
},
create(context) {
if (!context.filename.endsWith('package.json')) return {}
let json
try {
json = JSON.parse(fs.readFileSync(context.filename, 'utf-8'))
} catch {
return {}
}
const dependencyMap = new Map()
dependencyKeys.forEach((key) => {
const dependencies = json[key]
if (!dependencies) return
if (!dependencyMap.get(key)) {
dependencyMap.set(key, new Set())
}
const dependencySet = dependencyMap.get(key)
Object.keys(dependencies).forEach((dependency) => {
dependencySet.add(dependency)
})
})
return {
'Program > JSONExpressionStatement > JSONObjectExpression > JSONProperty > JSONObjectExpression > JSONProperty'(
node,
) {
const parent = node?.parent?.parent
if (!parent) return
const packageCategory = parent.key.value
if (!dependencyKeys.includes(packageCategory)) return
const packageName = node.key.value
dependencyKeys.forEach((key) => {
if (key === packageCategory) return
if (!dependencyMap.get(key)?.has(packageName)) return
context.report({
node,
message: `Duplicated package ${packageName} in ${key}`,
})
})
},
}
},
},
},
}

View File

@@ -1,19 +1,5 @@
const sortObjectKeys = (obj) => {
if (typeof obj !== 'object' || obj === null) {
return obj
}
import { cleanJsonText, sortObjectKeys } from './utils.js'
if (Array.isArray(obj)) {
return obj.map((element) => sortObjectKeys(element))
}
return Object.keys(obj)
.sort()
.reduce((acc, key) => {
acc[key] = sortObjectKeys(obj[key])
return acc
}, {})
}
/**
* @type {import("eslint").ESLint.Plugin}
*/
@@ -27,16 +13,19 @@ export default {
create(context) {
return {
Program(node) {
if (context.getFilename().endsWith('.json')) {
const sourceCode = context.getSourceCode()
const text = sourceCode.getText()
if (context.filename.endsWith('.json')) {
const { sourceCode } = context
const text = cleanJsonText(sourceCode.getText())
try {
const json = JSON.parse(text)
const sortedJson = sortObjectKeys(json)
const sortedText = JSON.stringify(sortedJson, null, 2)
const sortedText = `${JSON.stringify(sortedJson, null, 2)}\n`
if (text.trim() !== sortedText.trim()) {
const noWhiteSpaceDiff = (a, b) =>
a.replaceAll(/\s/g, '') === b.replaceAll(/\s/g, '')
if (!noWhiteSpaceDiff(text, sortedText)) {
context.report({
node,
message: 'JSON keys are not sorted recursively',

26
plugins/eslint/utils.js Normal file
View File

@@ -0,0 +1,26 @@
export const sortObjectKeys = (obj) => {
if (typeof obj !== 'object' || obj === null) {
return obj
}
if (Array.isArray(obj)) {
return obj.map((element) => sortObjectKeys(element))
}
return Object.keys(obj)
.sort()
.reduce((acc, key) => {
acc[key] = sortObjectKeys(obj[key])
return acc
}, {})
}
export const cleanJsonText = (text) => {
const cleaned = text.replaceAll(/,\s*\}/g, '}')
try {
JSON.parse(cleaned)
return cleaned
} catch {
return text
}
}

24
plugins/vite/i18n-hmr.ts Normal file
View File

@@ -0,0 +1,24 @@
import { readFileSync } from 'node:fs'
import type { Plugin } from 'vite'
export function customI18nHmrPlugin(): Plugin {
return {
name: 'custom-i18n-hmr',
handleHotUpdate({ file, server }) {
if (file.endsWith('.json') && file.includes('locales')) {
server.ws.send({
type: 'custom',
event: 'i18n-update',
data: {
file,
content: readFileSync(file, 'utf-8'),
},
})
// return empty array to prevent the default HMR
return []
}
},
}
}

68
plugins/vite/locales.ts Normal file
View File

@@ -0,0 +1,68 @@
import fs from 'node:fs'
import path, { dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { set } from 'es-toolkit/compat'
import type { Plugin } from 'vite'
export function localesPlugin(): Plugin {
return {
name: 'locales-merge',
enforce: 'post',
generateBundle(_options, bundle) {
const __dirname = dirname(fileURLToPath(import.meta.url))
const localesDir = path.resolve(__dirname, '../../locales')
const namespaces = fs
.readdirSync(localesDir)
.filter((dir) => dir !== '.DS_Store')
const languageResources = {} as any
namespaces.forEach((namespace) => {
const namespacePath = path.join(localesDir, namespace)
const files = fs
.readdirSync(namespacePath)
.filter((file) => file.endsWith('.json'))
files.forEach((file) => {
const lang = path.basename(file, '.json')
const filePath = path.join(namespacePath, file)
const content = JSON.parse(fs.readFileSync(filePath, 'utf-8'))
if (!languageResources[lang]) {
languageResources[lang] = {}
}
const obj = {}
const keys = Object.keys(content as object)
for (const accessorKey of keys) {
set(obj, accessorKey, (content as any)[accessorKey])
}
languageResources[lang][namespace] = obj
})
})
Object.entries(languageResources).forEach(([lang, resources]) => {
const fileName = `locales/${lang}.js`
const content = `export default ${JSON.stringify(resources)};`
this.emitFile({
type: 'asset',
fileName,
source: content,
})
})
// Remove original JSON chunks
Object.keys(bundle).forEach((key) => {
if (key.startsWith('locales/') && key.endsWith('.json')) {
delete bundle[key]
}
})
},
}
}