Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add: add i18n and translator features #131

Merged
merged 4 commits into from
Oct 4, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions ui/packages/i18n-tool/i18n.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
module.exports = {
// entry for scan start
entry: ['./src'],
// files or subdirectories to be excluded from processing
exclude: [
'node_modules/**',
'scripts/**',
'dist/**',
'vite.config.ts',
'tailwind.config.js',
'postcss.config.js',
'**/*.d.ts',
'utils/i18n.tsx',
],
// path to the original locales directory
localesDir: "./locales",
// original lang
originLang: "zh-CN",
// target lang, can a list, currently we support zh-CN、en-US
targetLangs: ["en-US"],
// [i18n keygen]
prefixKey: '',
keygenAlgorithm: 'md5',
showOriginKey: true,
// [i18n import config]
i18nImport: "import i18nInstance from '@/utils/i18n';",
i18nObject: 'i18nInstance',
i18nMethod: 't',
// [i18n translate provider]
translate: {
type: '',
appid: '',
model: '',
key: '',
}
};
23 changes: 23 additions & 0 deletions ui/packages/i18n-tool/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"name": "@karmada/i18n-tool",
"version": "1.0.0",
"bin": "src/index.js",
"dependencies": {
"@babel/generator": "^7.25.6",
"@babel/parser": "^7.25.6",
"@babel/traverse": "^7.25.6",
"@babel/types": "^7.25.6",
"@karmada/translators": "workspace:*",
"chalk": "^4.1.2",
"commander": "^12.1.0",
"debug": "^4.3.5",
"glob": "^11.0.0",
"prettier": "^3.3.2"
},
"scripts": {
"test": "vitest"
},
"author": "",
"license": "ISC",
"description": ""
}
47 changes: 47 additions & 0 deletions ui/packages/i18n-tool/src/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
#!/usr/bin/env node

const {Command} = require('commander');
const chalk = require('chalk');
const pkgInfo = require('../package.json');

const scan = require('./scan')
const init = require('./init')



// Create command-line tool
const program = new Command();
program
.name(pkgInfo.name)
.description(pkgInfo.description)
.version(pkgInfo.version, '-v, --version')
.option('-d, --directory <path>', 'specify folder path')
.option('-f, --file <path>', 'specify a single file path')
// If not specified, will look for the config in the same directory as i18n-tool
.option(
'-c, --config <path>',
'specify the configuration file path, default is ./i18n.config.cjs',
'./i18n.config.cjs'
)

program
.command('scan')
.alias('s')
.description('scan target language text from code')
.action(async () => {
const options = program.opts();
await scan(options);
});

program
.command('init')
.description('init i18n.config.js for current project')
.action(async () => {
const options = program.opts();
await init(options);
});

program.parseAsync(process.argv).catch((error) => {
console.error(chalk.red(error.stack));
process.exit(1);
});
16 changes: 16 additions & 0 deletions ui/packages/i18n-tool/src/init.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
const fs = require('node:fs');
const path = require('node:path');
const {getDebug} = require("./utils");
const debug = getDebug('init');

async function init(cmdOptions) {
debug('execute scan method', cmdOptions);
const srcDir = path.dirname( __filename)
const i18nTemplateFilePath = path.join(srcDir, "../", "i18n.config.js")

debug('i18nTemplateFilePath %s', i18nTemplateFilePath);
const targetI18nConfigFilePath = path.join(process.cwd(), "i18n.config.cjs")
fs.copyFileSync(i18nTemplateFilePath, targetI18nConfigFilePath)
}

module.exports = init
69 changes: 69 additions & 0 deletions ui/packages/i18n-tool/src/options.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
const path = require('node:path');
const fs = require('node:fs');
const chalk = require('chalk');

const defaultOptions = {
entry: ['src'],
exclude: [],

localesDir: "src/locales",
originLang: "zh-CN",
targetLangs: ["en-US"],

prefixKey: '',
showOriginKey: true,
keygenAlgorithm: 'md5',
};

/**
* return default options for i18n-tools
* @returns
*/
function getDefaultOptions() {
return defaultOptions
}


/**
* Try to parse i18n config from the specific file path
* @param configPath
*/
async function parseI18nConfig(configPath) {
const _configPath = generatePath(configPath)
if (!fs.existsSync(_configPath)) {
console.log(
chalk.red(`I18n config file ${configPath} not exist, please check!`),
);
process.exit(1);
}

const configOptions = require(_configPath)
configOptions.entry = (configOptions.entry || []).map((entryItem) =>
generatePath(entryItem),
);
return {
...defaultOptions,
...configOptions,
};
}

/**
* Generate absolute path for the input path.
* If the input path is relative path, it will expand as absolute path.
* If the input path is absolute path, it will return directly.
* @param p
* @returns absolute path of input
*/
function generatePath(p) {
const cwd = process.cwd();
if (path.isAbsolute(p)) {
return p;
}
return path.join(cwd, p);
}

module.exports = {
getDefaultOptions,
parseI18nConfig,
generatePath,
}
130 changes: 130 additions & 0 deletions ui/packages/i18n-tool/src/scan/ast.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
const babelParser = require('@babel/parser');
const babelTraverse = require('@babel/traverse').default;
const babelGenerator = require('@babel/generator').default;
const t = require("@babel/types");
const crypto = require('node:crypto');

// Method to generate unique keys using the MD5 hash algorithm
const generateKey = (text) => crypto.createHash('md5').update(text).digest('hex');

// Process the AST tree, detect Chinese characters, and generate i18n key-value pairs
const processAST = (tsxCode, debugMode) => {
const log = (...args) => {
if (debugMode) {
console.log(...args);
}
};

const ast = babelParser.parse(tsxCode, {
sourceType: 'module',
plugins: ['jsx', 'typescript'],
});

let CNPath = []; // Store paths of Chinese text
let i18nMap = {}; // Store key-value pairs for i18n
let i18nImported = false; // Flag to check if i18nInstance is imported

// Traverse the AST and find the target nodes
babelTraverse(ast, {
ImportDeclaration(path) {
const sourceValue = path.node.source.value;
if (sourceValue === '@/utils/i18n') {
const specifier = path.node.specifiers.find(spec => spec.local.name === 'i18nInstance');
if (specifier) {
i18nImported = true;
}
}
},
StringLiteral(path) {
const value = path.node.value;
if (/[\u4e00-\u9fa5]/.test(value)) { // Check if the string contains Chinese characters
const key = generateKey(value);
if (!i18nMap[key]) {
i18nMap[key] = value;
CNPath.push({ path, key, value, type: 'StringLiteral' });
log(`Detected Chinese text: ${value}`);
}
}
},
TemplateLiteral(path) {
path.node.quasis.forEach((quasi) => {
const value = quasi.value.raw;
if (/[\u4e00-\u9fa5]/.test(value)) { // Check if the template contains Chinese characters
const key = generateKey(value);
if (!i18nMap[key]) {
i18nMap[key] = value;
CNPath.push({ path: quasi, key, value, type: 'TemplateLiteral' });
log(`Detected Chinese text: ${value}`);
}
}
});
},
JSXText(path) {
const value = path.node.value.trim();
if (/[\u4e00-\u9fa5]/.test(value)) { // Check if JSXText contains Chinese characters
const key = generateKey(value);
if (!i18nMap[key]) {
i18nMap[key] = value;
CNPath.push({ path, key, value, type: 'JSXText' });
log(`Detected Chinese text: ${value}`);
}
}
},
JSXAttribute(path) {
const value = path.node.value && path.node.value.value;
if (typeof value === 'string' && /[\u4e00-\u9fa5]/.test(value)) { // Check if JSXAttribute contains Chinese text
const key = generateKey(value);
if (!i18nMap[key]) {
i18nMap[key] = value;
CNPath.push({ path, key, value, type: 'JSXAttribute' });
log(`Detected Chinese text: ${value}`);
}
}
}
});

return { ast, CNPath, i18nMap, i18nImported };
};

// Generate code and add i18nInstance.t calls
const generateCode = (ast, i18nImported, CNPath) => {
CNPath.forEach(({ path, key, value, type }) => {
if(isI18nInvoke(path)) {
return
}
if (type === 'StringLiteral') {
path.replaceWith(
babelParser.parseExpression(`i18nInstance.t("${key}", "${value}")`)
);
} else if (type === 'TemplateLiteral' ) {
path.value.raw = `\${i18nInstance.t("${key}", "${value}")}`;
} else if (type === 'JSXText') {
const jsxText = `i18nInstance.t("${key}", "${value}")`
path.replaceWith(t.JSXExpressionContainer(babelParser.parseExpression(jsxText)))
} else if (type === 'JSXAttribute') {
path.node.value = babelParser.parseExpression(`i18nInstance.t("${key}", "${value}")`);
}
});

let transformedCode = babelGenerator(ast).code;
if (!i18nImported) {
transformedCode = `import i18nInstance from '@/utils/i18n';\n` + transformedCode;
}
return transformedCode;
};

/**
* check the ast node to detect whether the ast node is already in form of i18n
* @param path
* @returns {boolean}
*/
function isI18nInvoke(path) {
if (!path || !path.parent ||
!path.parent.callee ||
!path.parent.callee.property || !path.parent.callee.object) return false;
const property = path.parent.callee.property.name
const object = path.parent.callee.object.name
return object === "i18nInstance" && property === "t"
}

module.exports = { processAST, generateCode };
Loading