mirror of
https://github.com/Afilmory/afilmory
synced 2026-02-01 22:48:17 +00:00
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:
140
plugins/eslint/eslint-check-i18n-json.js
Normal file
140
plugins/eslint/eslint-check-i18n-json.js
Normal 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)
|
||||
},
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
34
plugins/eslint/eslint-no-debug.js
Normal file
34
plugins/eslint/eslint-no-debug.js
Normal 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.',
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
147
plugins/eslint/eslint-package-json.js
Normal file
147
plugins/eslint/eslint-package-json.js
Normal 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}`,
|
||||
})
|
||||
})
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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
26
plugins/eslint/utils.js
Normal 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
24
plugins/vite/i18n-hmr.ts
Normal 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
68
plugins/vite/locales.ts
Normal 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]
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user