mirror of
https://github.com/logto-io/logto.git
synced 2025-01-06 20:40:08 -05:00
refactor: split translate cmd from logto cli (#6451)
* refactor: split translate cmd from logto cli * chore: add changeset * refactor(cli): remove translate command from cli package
This commit is contained in:
parent
57974a11d6
commit
0183d0c33a
24 changed files with 553 additions and 248 deletions
11
.changeset/silly-hotels-greet.md
Normal file
11
.changeset/silly-hotels-greet.md
Normal file
|
@ -0,0 +1,11 @@
|
|||
---
|
||||
"@logto/cli": minor
|
||||
"@logto/translate": minor
|
||||
---
|
||||
|
||||
split translate command from @logto/cli and create a standalone package
|
||||
|
||||
The "translate" command has greatly increased the size of the "@logto/cli" package, as it involves TypeScript code manipulation and has to use "typescrpt" as a "dependency".
|
||||
In fact, only a small number of developers who want to contribute Logto will use this command, so we believe it's best to separate the less frequently used "translate" command from the "cli" package to keep it small and simple.
|
||||
|
||||
Please also be noted that this change is actually a breaking change for those who use the "translate" command. However, the CLI has to be bundle-released with the "Logto" open-source distribution, and we feel it is still too early to make a major version bump for Logto. Therefore, the "minor" bump is used this time.
|
|
@ -7,7 +7,7 @@ const config: UserConfig = {
|
|||
extends: ['@commitlint/config-conventional'],
|
||||
rules: {
|
||||
'type-enum': [2, 'always', [...conventional.rules['type-enum'][2], 'api', 'release']],
|
||||
'scope-enum': [2, 'always', ['connector', 'console', 'core', 'demo-app', 'test', 'phrases', 'schemas', 'shared', 'experience', 'deps', 'deps-dev', 'cli', 'toolkit', 'cloud', 'app-insights', 'elements']],
|
||||
'scope-enum': [2, 'always', ['connector', 'console', 'core', 'demo-app', 'test', 'phrases', 'schemas', 'shared', 'experience', 'deps', 'deps-dev', 'cli', 'toolkit', 'cloud', 'app-insights', 'elements', 'translate']],
|
||||
// Slightly increase the tolerance to allow the appending PR number
|
||||
...(isCi && { 'header-max-length': [2, 'always', 110] }),
|
||||
'body-max-line-length': [2, 'always', 110],
|
||||
|
|
|
@ -13,6 +13,7 @@
|
|||
"start:dev": "pnpm -r --parallel --filter=!@logto/integration-tests --filter \"!./packages/connectors/connector-*\" dev",
|
||||
"start": "cd packages/core && NODE_ENV=production node .",
|
||||
"cli": "logto",
|
||||
"translate": "logto-translate",
|
||||
"changeset": "changeset",
|
||||
"alteration": "logto db alt",
|
||||
"connectors": "pnpm -r --filter \"./packages/connectors/connector-*\"",
|
||||
|
@ -52,6 +53,7 @@
|
|||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@logto/cli": "workspace:^1.1.0"
|
||||
"@logto/cli": "workspace:^1.1.0",
|
||||
"@logto/translate": "workspace:^0.0.0"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -44,9 +44,6 @@
|
|||
"dependencies": {
|
||||
"@logto/connector-kit": "workspace:^4.0.0",
|
||||
"@logto/core-kit": "workspace:^2.5.0",
|
||||
"@logto/language-kit": "workspace:^1.1.0",
|
||||
"@logto/phrases": "workspace:^1.13.0",
|
||||
"@logto/phrases-experience": "workspace:^1.7.0",
|
||||
"@logto/schemas": "workspace:1.19.0",
|
||||
"@logto/shared": "workspace:^3.1.1",
|
||||
"@silverhand/essentials": "^2.9.1",
|
||||
|
@ -62,13 +59,10 @@
|
|||
"nanoid": "^5.0.1",
|
||||
"ora": "^8.0.1",
|
||||
"p-limit": "^6.0.0",
|
||||
"p-queue": "^8.0.0",
|
||||
"p-retry": "^6.0.0",
|
||||
"pg-protocol": "^1.6.0",
|
||||
"roarr": "^7.11.0",
|
||||
"semver": "^7.3.8",
|
||||
"tar": "^7.0.0",
|
||||
"typescript": "^5.5.3",
|
||||
"yargs": "^17.6.0",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
|
@ -87,6 +81,7 @@
|
|||
"lint-staged": "^15.0.0",
|
||||
"prettier": "^3.0.0",
|
||||
"sinon": "^18.0.0",
|
||||
"typescript": "^5.5.3",
|
||||
"vitest": "^2.0.0"
|
||||
},
|
||||
"eslintConfig": {
|
||||
|
|
|
@ -1,32 +0,0 @@
|
|||
import { noop } from '@silverhand/essentials';
|
||||
import type { CommandModule } from 'yargs';
|
||||
|
||||
import create from './create.js';
|
||||
import listTags from './list-tags.js';
|
||||
import syncKeys from './sync-keys/index.js';
|
||||
import sync from './sync.js';
|
||||
|
||||
const translate: CommandModule = {
|
||||
command: ['translate', 't'],
|
||||
describe: 'Commands for Logto translation',
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.option('path', {
|
||||
alias: 'p',
|
||||
type: 'string',
|
||||
describe: 'The path to your Logto instance directory',
|
||||
})
|
||||
.option('skip-core-check', {
|
||||
alias: 'sc',
|
||||
type: 'boolean',
|
||||
describe: 'Skip checking if the core package is existed',
|
||||
})
|
||||
.command(create)
|
||||
.command(listTags)
|
||||
.command(sync)
|
||||
.command(syncKeys)
|
||||
.demandCommand(1),
|
||||
handler: noop,
|
||||
};
|
||||
|
||||
export default translate;
|
|
@ -1,109 +0,0 @@
|
|||
import fs from 'node:fs/promises';
|
||||
|
||||
import { type LanguageTag } from '@logto/language-kit';
|
||||
import { trySafe } from '@silverhand/essentials';
|
||||
import { type Got, got, HTTPError } from 'got';
|
||||
import { HttpsProxyAgent } from 'hpagent';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { consoleLog, getProxy } from '../../utils.js';
|
||||
|
||||
import { getTranslationPromptMessages } from './prompts.js';
|
||||
|
||||
export const createOpenaiApi = () => {
|
||||
const proxy = getProxy();
|
||||
|
||||
return got.extend({
|
||||
prefixUrl: process.env.OPENAI_API_PROXY_ENDPOINT ?? 'https://api.openai.com/v1',
|
||||
headers: {
|
||||
Authorization: `Bearer ${process.env.OPENAI_API_KEY ?? ''}`,
|
||||
},
|
||||
timeout: { request: 300_000 },
|
||||
...(proxy && { agent: { https: new HttpsProxyAgent({ proxy, timeout: 300_000 }) } }),
|
||||
});
|
||||
};
|
||||
|
||||
const gptResponseGuard = z.object({
|
||||
choices: z
|
||||
.object({
|
||||
message: z.object({ role: z.string(), content: z.string() }),
|
||||
finish_reason: z.string(),
|
||||
})
|
||||
.array(),
|
||||
});
|
||||
|
||||
type TranslateConfig = {
|
||||
api: Got;
|
||||
sourceFilePath: string;
|
||||
targetLanguage: LanguageTag;
|
||||
extraPrompt?: string;
|
||||
};
|
||||
|
||||
export const translate = async ({
|
||||
api,
|
||||
targetLanguage,
|
||||
sourceFilePath,
|
||||
extraPrompt,
|
||||
}: TranslateConfig) => {
|
||||
const sourceFileContent = await fs.readFile(sourceFilePath, 'utf8');
|
||||
const response = await trySafe(
|
||||
api
|
||||
.post('chat/completions', {
|
||||
json: {
|
||||
// The full list of OPENAI model can be found at https://platform.openai.com/docs/models.
|
||||
model: process.env.OPENAI_MODEL_NAME ?? 'gpt-3.5-turbo-0125',
|
||||
messages: getTranslationPromptMessages({
|
||||
sourceFileContent,
|
||||
targetLanguage,
|
||||
extraPrompt,
|
||||
}),
|
||||
},
|
||||
})
|
||||
.json(),
|
||||
(error) => {
|
||||
consoleLog.warn(`Error while translating ${sourceFilePath}:`, String(error));
|
||||
|
||||
if (error instanceof HTTPError) {
|
||||
consoleLog.warn(error.response.body);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (!response) {
|
||||
return;
|
||||
}
|
||||
|
||||
const guarded = gptResponseGuard.safeParse(response);
|
||||
|
||||
if (!guarded.success) {
|
||||
consoleLog.warn(`Error while guarding response for ${sourceFilePath}:`, response);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const [entity] = guarded.data.choices;
|
||||
|
||||
if (!entity) {
|
||||
consoleLog.warn(`No choice found in response when translating ${sourceFilePath}`);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (entity.finish_reason !== 'stop') {
|
||||
consoleLog.warn(`Unexpected finish reason ${entity.finish_reason} for ${sourceFilePath}`);
|
||||
}
|
||||
|
||||
const { content } = entity.message;
|
||||
const matched = /```(?:ts)?\n(.*)```/s.exec(content)?.[1];
|
||||
|
||||
if (!matched) {
|
||||
// Treat as pure code
|
||||
if (['const ', 'import '].some((prefix) => content.startsWith(prefix))) {
|
||||
return content;
|
||||
}
|
||||
|
||||
consoleLog.warn('No matching code snippet from response:', content);
|
||||
}
|
||||
|
||||
return matched;
|
||||
};
|
|
@ -6,7 +6,6 @@ import { hideBin } from 'yargs/helpers';
|
|||
import connector from './commands/connector/index.js';
|
||||
import database from './commands/database/index.js';
|
||||
import install from './commands/install/index.js';
|
||||
import translate from './commands/translate/index.js';
|
||||
import tunnel from './commands/tunnel/index.js';
|
||||
import { packageJson } from './package-json.js';
|
||||
import { cliConfig, ConfigKey, consoleLog } from './utils.js';
|
||||
|
@ -48,7 +47,6 @@ void yargs(hideBin(process.argv))
|
|||
.command(install)
|
||||
.command(database)
|
||||
.command(connector)
|
||||
.command(translate)
|
||||
.command(tunnel)
|
||||
.demandCommand(1)
|
||||
.showHelpOnFail(false, `Specify ${chalk.green('--help')} for available options`)
|
||||
|
|
|
@ -1,9 +1,8 @@
|
|||
import { execSync, execFile } from 'node:child_process';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { createWriteStream, existsSync } from 'node:fs';
|
||||
import { readdir, readFile } from 'node:fs/promises';
|
||||
import { createRequire } from 'node:module';
|
||||
import path from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
import { ConsoleLog } from '@logto/shared';
|
||||
import type { Optional } from '@silverhand/essentials';
|
||||
|
@ -271,32 +270,3 @@ export const getConnectorPackagesFromDirectory = async (directory: string) => {
|
|||
(packageInfo): packageInfo is ConnectorPackage => typeof packageInfo.name === 'string'
|
||||
);
|
||||
};
|
||||
|
||||
const execPromise = promisify(execFile);
|
||||
|
||||
export const lintLocaleFiles = async (
|
||||
/** Logto instance path */
|
||||
instancePath: string,
|
||||
/** Target package name, ignore to lint both `phrases` and `phrases-experience` packages */
|
||||
packageName?: string
|
||||
) => {
|
||||
const spinner = ora({
|
||||
text: 'Running `eslint --fix` for locales',
|
||||
}).start();
|
||||
|
||||
const targetPackages = packageName ? [packageName] : ['phrases', 'phrases-experience'];
|
||||
|
||||
await Promise.all(
|
||||
targetPackages.map(async (packageName) => {
|
||||
const phrasesPath = path.join(instancePath, 'packages', packageName);
|
||||
const localesPath = path.join(phrasesPath, 'src/locales');
|
||||
await execPromise(
|
||||
'pnpm',
|
||||
['eslint', '--ext', '.ts', path.relative(phrasesPath, localesPath), '--fix'],
|
||||
{ cwd: phrasesPath }
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
spinner.succeed('Ran `eslint --fix` for locales');
|
||||
};
|
||||
|
|
1
packages/translate/.gitignore
vendored
Normal file
1
packages/translate/.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
src/package-json.ts
|
3
packages/translate/bin/index.js
Executable file
3
packages/translate/bin/index.js
Executable file
|
@ -0,0 +1,3 @@
|
|||
#!/usr/bin/env node
|
||||
// eslint-disable-next-line import/no-unassigned-import
|
||||
import '../lib/index.js';
|
80
packages/translate/package.json
Normal file
80
packages/translate/package.json
Normal file
|
@ -0,0 +1,80 @@
|
|||
{
|
||||
"name": "@logto/translate",
|
||||
"version": "0.0.0",
|
||||
"description": "A CLI tool that helps translate phrases and experience-phrases to i18n resources.",
|
||||
"author": "Silverhand Inc. <contact@silverhand.io>",
|
||||
"homepage": "https://github.com/logto-io/logto#readme",
|
||||
"license": "MPL-2.0",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"main": "lib/index.js",
|
||||
"bin": {
|
||||
"logto-translate": "bin/index.js"
|
||||
},
|
||||
"files": [
|
||||
"bin",
|
||||
"lib"
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/logto-io/logto.git"
|
||||
},
|
||||
"scripts": {
|
||||
"precommit": "lint-staged",
|
||||
"prepare:package-json": "node -p \"'export const packageJson = ' + JSON.stringify(require('./package.json'), undefined, 2) + ';'\" > src/package-json.ts",
|
||||
"build": "rm -rf lib && pnpm prepare:package-json && tsc -p tsconfig.build.json",
|
||||
"dev": "tsc -p tsconfig.build.json --watch --preserveWatchOutput --incremental",
|
||||
"start": "node .",
|
||||
"start:dev": "pnpm build && node .",
|
||||
"lint": "eslint --ext .ts src",
|
||||
"lint:report": "pnpm lint --format json --output-file report.json",
|
||||
"prepack": "pnpm build"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.9.0"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/logto-io/logto/issues"
|
||||
},
|
||||
"dependencies": {
|
||||
"@logto/core-kit": "workspace:^2.5.0",
|
||||
"@logto/language-kit": "workspace:^1.1.0",
|
||||
"@logto/phrases": "workspace:^1.13.0",
|
||||
"@logto/phrases-experience": "workspace:^1.7.0",
|
||||
"@logto/shared": "workspace:^3.1.1",
|
||||
"@silverhand/essentials": "^2.9.1",
|
||||
"chalk": "^5.3.0",
|
||||
"dotenv": "^16.4.5",
|
||||
"got": "^14.0.0",
|
||||
"hpagent": "^1.2.0",
|
||||
"inquirer": "^9.0.0",
|
||||
"ora": "^8.0.1",
|
||||
"p-queue": "^8.0.0",
|
||||
"typescript": "^5.5.3",
|
||||
"yargs": "^17.6.0",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@silverhand/eslint-config": "6.0.1",
|
||||
"@silverhand/ts-config": "6.0.0",
|
||||
"@types/inquirer": "^9.0.0",
|
||||
"@types/node": "^20.9.5",
|
||||
"@types/yargs": "^17.0.13",
|
||||
"@vitest/coverage-v8": "^2.0.0",
|
||||
"eslint": "^8.56.0",
|
||||
"lint-staged": "^15.0.0",
|
||||
"prettier": "^3.0.0"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": "@silverhand",
|
||||
"rules": {
|
||||
"no-console": "error"
|
||||
},
|
||||
"ignorePatterns": [
|
||||
"src/package-json.ts"
|
||||
]
|
||||
},
|
||||
"prettier": "@silverhand/eslint-config/.prettierrc"
|
||||
}
|
5
packages/translate/src/constants.ts
Normal file
5
packages/translate/src/constants.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
export const defaultPath = path.join(os.homedir(), 'logto');
|
||||
export const coreDirectory = 'packages/core';
|
|
@ -3,9 +3,8 @@ import { isBuiltInLanguageTag as isPhrasesBuiltInLanguageTag } from '@logto/phra
|
|||
import { isBuiltInLanguageTag as isPhrasesUiBuiltInLanguageTag } from '@logto/phrases-experience';
|
||||
import type { CommandModule } from 'yargs';
|
||||
|
||||
import { consoleLog, inquireInstancePath } from '../../utils.js';
|
||||
|
||||
import { createFullTranslation } from './utils.js';
|
||||
import { createFullTranslation } from './openai.js';
|
||||
import { consoleLog, inquireInstancePath } from './utils.js';
|
||||
|
||||
const create: CommandModule<{ path?: string }, { path?: string; 'language-tag': string }> = {
|
||||
command: ['create <language-tag>', 'c'],
|
56
packages/translate/src/index.ts
Normal file
56
packages/translate/src/index.ts
Normal file
|
@ -0,0 +1,56 @@
|
|||
import chalk from 'chalk';
|
||||
import dotenv from 'dotenv';
|
||||
import yargs from 'yargs';
|
||||
import { hideBin } from 'yargs/helpers';
|
||||
|
||||
import create from './create.js';
|
||||
import listTags from './list-tags.js';
|
||||
import { packageJson } from './package-json.js';
|
||||
import syncKeys from './sync-keys/index.js';
|
||||
import sync from './sync.js';
|
||||
import { consoleLog } from './utils.js';
|
||||
|
||||
void yargs(hideBin(process.argv))
|
||||
.version(false)
|
||||
.option('env', {
|
||||
alias: ['e', 'env-file'],
|
||||
describe: 'The path to your `.env` file',
|
||||
type: 'string',
|
||||
})
|
||||
.option('version', {
|
||||
alias: 'v',
|
||||
describe: 'Print Logto translate CLI version',
|
||||
type: 'boolean',
|
||||
global: false,
|
||||
})
|
||||
.middleware(({ version }) => {
|
||||
if (version) {
|
||||
consoleLog.plain(packageJson.name + ' v' + packageJson.version);
|
||||
// eslint-disable-next-line unicorn/no-process-exit
|
||||
process.exit(0);
|
||||
}
|
||||
}, true)
|
||||
.middleware(({ env }) => {
|
||||
dotenv.config({ path: env });
|
||||
})
|
||||
.option('path', {
|
||||
alias: 'p',
|
||||
type: 'string',
|
||||
describe: 'The path to your Logto instance directory',
|
||||
})
|
||||
.option('skip-core-check', {
|
||||
alias: 'sc',
|
||||
type: 'boolean',
|
||||
describe: 'Skip checking if the core package is existed',
|
||||
})
|
||||
.command(create)
|
||||
.command(listTags)
|
||||
.command(sync)
|
||||
.command(syncKeys)
|
||||
.demandCommand(1)
|
||||
.showHelpOnFail(false, `Specify ${chalk.green('--help')} for available options`)
|
||||
.strict()
|
||||
.parserConfiguration({
|
||||
'dot-notation': false,
|
||||
})
|
||||
.parse();
|
|
@ -4,7 +4,7 @@ import { isBuiltInLanguageTag as isPhrasesUiBuiltInLanguageTag } from '@logto/ph
|
|||
import chalk from 'chalk';
|
||||
import type { CommandModule } from 'yargs';
|
||||
|
||||
import { consoleLog } from '../../utils.js';
|
||||
import { consoleLog } from './utils.js';
|
||||
|
||||
const listTags: CommandModule<Record<string, unknown>> = {
|
||||
command: ['list-tags', 'list'],
|
|
@ -3,50 +3,117 @@ import fs from 'node:fs/promises';
|
|||
import path from 'node:path';
|
||||
|
||||
import { type LanguageTag } from '@logto/language-kit';
|
||||
import { conditionalString } from '@silverhand/essentials';
|
||||
import { type Got } from 'got';
|
||||
import { conditionalString, trySafe } from '@silverhand/essentials';
|
||||
import { type Got, got, HTTPError } from 'got';
|
||||
import { HttpsProxyAgent } from 'hpagent';
|
||||
import PQueue from 'p-queue';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { consoleLog } from '../../utils.js';
|
||||
import { getTranslationPromptMessages, untranslatedMark } from './prompts.js';
|
||||
import {
|
||||
baseLanguage,
|
||||
consoleLog,
|
||||
getProxy,
|
||||
readBaseLocaleFiles,
|
||||
type TranslationOptions,
|
||||
} from './utils.js';
|
||||
|
||||
import { createOpenaiApi, translate } from './openai.js';
|
||||
import { untranslatedMark } from './prompts.js';
|
||||
export const createOpenaiApi = () => {
|
||||
const proxy = getProxy();
|
||||
|
||||
export const baseLanguage = 'en' satisfies LanguageTag;
|
||||
return got.extend({
|
||||
prefixUrl: process.env.OPENAI_API_PROXY_ENDPOINT ?? 'https://api.openai.com/v1',
|
||||
headers: {
|
||||
Authorization: `Bearer ${process.env.OPENAI_API_KEY ?? ''}`,
|
||||
},
|
||||
timeout: { request: 300_000 },
|
||||
...(proxy && { agent: { https: new HttpsProxyAgent({ proxy, timeout: 300_000 }) } }),
|
||||
});
|
||||
};
|
||||
|
||||
export const readLocaleFiles = async (directory: string): Promise<string[]> => {
|
||||
const entities = await fs.readdir(directory, { withFileTypes: true });
|
||||
|
||||
const result = await Promise.all(
|
||||
entities.map(async (entity) => {
|
||||
if (entity.isDirectory()) {
|
||||
return readLocaleFiles(path.join(directory, entity.name));
|
||||
}
|
||||
|
||||
return entity.name.endsWith('.ts') ? path.join(directory, entity.name) : [];
|
||||
const gptResponseGuard = z.object({
|
||||
choices: z
|
||||
.object({
|
||||
message: z.object({ role: z.string(), content: z.string() }),
|
||||
finish_reason: z.string(),
|
||||
})
|
||||
.array(),
|
||||
});
|
||||
|
||||
type TranslateConfig = {
|
||||
api: Got;
|
||||
sourceFilePath: string;
|
||||
targetLanguage: LanguageTag;
|
||||
extraPrompt?: string;
|
||||
};
|
||||
|
||||
export const translate = async ({
|
||||
api,
|
||||
targetLanguage,
|
||||
sourceFilePath,
|
||||
extraPrompt,
|
||||
}: TranslateConfig) => {
|
||||
const sourceFileContent = await fs.readFile(sourceFilePath, 'utf8');
|
||||
const response = await trySafe(
|
||||
api
|
||||
.post('chat/completions', {
|
||||
json: {
|
||||
// The full list of OPENAI model can be found at https://platform.openai.com/docs/models.
|
||||
model: process.env.OPENAI_MODEL_NAME ?? 'gpt-3.5-turbo-0125',
|
||||
messages: getTranslationPromptMessages({
|
||||
sourceFileContent,
|
||||
targetLanguage,
|
||||
extraPrompt,
|
||||
}),
|
||||
},
|
||||
})
|
||||
.json(),
|
||||
(error) => {
|
||||
consoleLog.warn(`Error while translating ${sourceFilePath}:`, String(error));
|
||||
|
||||
if (error instanceof HTTPError) {
|
||||
consoleLog.warn(error.response.body);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return result.flat();
|
||||
};
|
||||
|
||||
export const readBaseLocaleFiles = async (directory: string): Promise<string[]> => {
|
||||
const enDirectory = path.join(directory, baseLanguage.toLowerCase());
|
||||
const stat = await fs.stat(enDirectory);
|
||||
|
||||
if (!stat.isDirectory()) {
|
||||
consoleLog.fatal(directory, 'has no `' + baseLanguage.toLowerCase() + '` directory');
|
||||
if (!response) {
|
||||
return;
|
||||
}
|
||||
|
||||
return readLocaleFiles(enDirectory);
|
||||
};
|
||||
const guarded = gptResponseGuard.safeParse(response);
|
||||
|
||||
export type TranslationOptions = {
|
||||
instancePath: string;
|
||||
packageName: string;
|
||||
languageTag: LanguageTag;
|
||||
verbose?: boolean;
|
||||
queue?: PQueue;
|
||||
if (!guarded.success) {
|
||||
consoleLog.warn(`Error while guarding response for ${sourceFilePath}:`, response);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const [entity] = guarded.data.choices;
|
||||
|
||||
if (!entity) {
|
||||
consoleLog.warn(`No choice found in response when translating ${sourceFilePath}`);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (entity.finish_reason !== 'stop') {
|
||||
consoleLog.warn(`Unexpected finish reason ${entity.finish_reason} for ${sourceFilePath}`);
|
||||
}
|
||||
|
||||
const { content } = entity.message;
|
||||
const matched = /```(?:ts)?\n(.*)```/s.exec(content)?.[1];
|
||||
|
||||
if (!matched) {
|
||||
// Treat as pure code
|
||||
if (['const ', 'import '].some((prefix) => content.startsWith(prefix))) {
|
||||
return content;
|
||||
}
|
||||
|
||||
consoleLog.warn('No matching code snippet from response:', content);
|
||||
}
|
||||
|
||||
return matched;
|
||||
};
|
||||
|
||||
export const createFullTranslation = async ({
|
|
@ -5,7 +5,7 @@ import { isLanguageTag } from '@logto/language-kit';
|
|||
import ora from 'ora';
|
||||
import { type CommandModule } from 'yargs';
|
||||
|
||||
import { consoleLog, inquireInstancePath, lintLocaleFiles } from '../../../utils.js';
|
||||
import { consoleLog, inquireInstancePath, lintLocaleFiles } from '../utils.js';
|
||||
|
||||
import { parseLocaleFiles, syncPhraseKeysAndFileStructure } from './utils.js';
|
||||
|
|
@ -5,7 +5,7 @@ import path from 'node:path';
|
|||
import { tryThat } from '@silverhand/essentials';
|
||||
import ts from 'typescript';
|
||||
|
||||
import { consoleLog } from '../../../utils.js';
|
||||
import { consoleLog } from '../utils.js';
|
||||
|
||||
const getValue = (property: ts.PropertyAssignment): string => {
|
||||
if (
|
|
@ -3,9 +3,13 @@ import { isBuiltInLanguageTag as isPhrasesBuiltInLanguageTag } from '@logto/phra
|
|||
import PQueue from 'p-queue';
|
||||
import type { CommandModule } from 'yargs';
|
||||
|
||||
import { inquireInstancePath, lintLocaleFiles } from '../../utils.js';
|
||||
|
||||
import { type TranslationOptions, baseLanguage, syncTranslation } from './utils.js';
|
||||
import { syncTranslation } from './openai.js';
|
||||
import {
|
||||
inquireInstancePath,
|
||||
lintLocaleFiles,
|
||||
type TranslationOptions,
|
||||
baseLanguage,
|
||||
} from './utils.js';
|
||||
|
||||
const sync: CommandModule<
|
||||
{ path?: string; skipCoreCheck?: boolean },
|
174
packages/translate/src/utils.ts
Normal file
174
packages/translate/src/utils.ts
Normal file
|
@ -0,0 +1,174 @@
|
|||
import { execFile } from 'node:child_process';
|
||||
import { existsSync } from 'node:fs';
|
||||
import fs, { readFile } from 'node:fs/promises';
|
||||
import { createRequire } from 'node:module';
|
||||
import path from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
import { type LanguageTag } from '@logto/language-kit';
|
||||
import { ConsoleLog } from '@logto/shared';
|
||||
import { assert, conditional } from '@silverhand/essentials';
|
||||
import chalk from 'chalk';
|
||||
import inquirer from 'inquirer';
|
||||
import ora from 'ora';
|
||||
import type PQueue from 'p-queue';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { coreDirectory, defaultPath } from './constants.js';
|
||||
|
||||
// The explicit type annotation is required to make `.fatal()`
|
||||
// works correctly without `return`:
|
||||
//
|
||||
// ```ts
|
||||
// const foo: number | undefined;
|
||||
// consoleLog.fatal();
|
||||
// typeof foo // Still `number | undefined` without explicit type annotation
|
||||
// ```
|
||||
//
|
||||
// For now I have no idea why.
|
||||
export const consoleLog: ConsoleLog = new ConsoleLog();
|
||||
|
||||
export const getProxy = () => {
|
||||
const { HTTPS_PROXY, HTTP_PROXY, https_proxy, http_proxy } = process.env;
|
||||
|
||||
return HTTPS_PROXY ?? https_proxy ?? HTTP_PROXY ?? http_proxy;
|
||||
};
|
||||
|
||||
export const getPathInModule = (moduleName: string, relativePath = '/') =>
|
||||
// https://stackoverflow.com/a/49455609/12514940
|
||||
path.join(
|
||||
path.dirname(createRequire(import.meta.url).resolve(`${moduleName}/package.json`)),
|
||||
relativePath
|
||||
);
|
||||
|
||||
export const isTty = () => process.stdin.isTTY;
|
||||
|
||||
const buildPathErrorMessage = (value: string) =>
|
||||
`The path ${chalk.green(value)} does not contain a Logto instance. Please try another.`;
|
||||
|
||||
const validatePath = async (value: string) => {
|
||||
const corePackageJsonPath = path.resolve(path.join(value, coreDirectory, 'package.json'));
|
||||
|
||||
if (!existsSync(corePackageJsonPath)) {
|
||||
return buildPathErrorMessage(value);
|
||||
}
|
||||
|
||||
const packageJson = await readFile(corePackageJsonPath, { encoding: 'utf8' });
|
||||
const packageName = await z
|
||||
.object({ name: z.string() })
|
||||
.parseAsync(JSON.parse(packageJson))
|
||||
.then(({ name }) => name)
|
||||
.catch(() => '');
|
||||
|
||||
if (packageName !== '@logto/core') {
|
||||
return buildPathErrorMessage(value);
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export const inquireInstancePath = async (initialPath?: string, skipCoreCheck?: boolean) => {
|
||||
const inquire = async () => {
|
||||
if (!initialPath && (skipCoreCheck ?? (await validatePath('.')) === true)) {
|
||||
return path.resolve('.');
|
||||
}
|
||||
|
||||
if (!isTty()) {
|
||||
assert(initialPath, new Error('Path is missing'));
|
||||
|
||||
return initialPath;
|
||||
}
|
||||
|
||||
const { instancePath } = await inquirer.prompt<{ instancePath: string }>(
|
||||
{
|
||||
name: 'instancePath',
|
||||
message: 'Where is your Logto instance?',
|
||||
type: 'input',
|
||||
default: defaultPath,
|
||||
filter: (value: string) => value.trim(),
|
||||
validate: conditional(!skipCoreCheck && validatePath),
|
||||
},
|
||||
{ instancePath: initialPath }
|
||||
);
|
||||
|
||||
return instancePath;
|
||||
};
|
||||
|
||||
const instancePath = await inquire();
|
||||
|
||||
if (!skipCoreCheck) {
|
||||
const validated = await validatePath(instancePath);
|
||||
|
||||
if (validated !== true) {
|
||||
consoleLog.fatal(validated);
|
||||
}
|
||||
}
|
||||
|
||||
return instancePath;
|
||||
};
|
||||
|
||||
const execPromise = promisify(execFile);
|
||||
|
||||
export const lintLocaleFiles = async (
|
||||
/** Logto instance path */
|
||||
instancePath: string,
|
||||
/** Target package name, ignore to lint both `phrases` and `phrases-experience` packages */
|
||||
packageName?: string
|
||||
) => {
|
||||
const spinner = ora({
|
||||
text: 'Running `eslint --fix` for locales',
|
||||
}).start();
|
||||
|
||||
const targetPackages = packageName ? [packageName] : ['phrases', 'phrases-experience'];
|
||||
|
||||
await Promise.all(
|
||||
targetPackages.map(async (packageName) => {
|
||||
const phrasesPath = path.join(instancePath, 'packages', packageName);
|
||||
const localesPath = path.join(phrasesPath, 'src/locales');
|
||||
await execPromise(
|
||||
'pnpm',
|
||||
['eslint', '--ext', '.ts', path.relative(phrasesPath, localesPath), '--fix'],
|
||||
{ cwd: phrasesPath }
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
spinner.succeed('Ran `eslint --fix` for locales');
|
||||
};
|
||||
|
||||
export const baseLanguage = 'en' satisfies LanguageTag;
|
||||
|
||||
export const readLocaleFiles = async (directory: string): Promise<string[]> => {
|
||||
const entities = await fs.readdir(directory, { withFileTypes: true });
|
||||
|
||||
const result = await Promise.all(
|
||||
entities.map(async (entity) => {
|
||||
if (entity.isDirectory()) {
|
||||
return readLocaleFiles(path.join(directory, entity.name));
|
||||
}
|
||||
|
||||
return entity.name.endsWith('.ts') ? path.join(directory, entity.name) : [];
|
||||
})
|
||||
);
|
||||
|
||||
return result.flat();
|
||||
};
|
||||
|
||||
export const readBaseLocaleFiles = async (directory: string): Promise<string[]> => {
|
||||
const enDirectory = path.join(directory, baseLanguage.toLowerCase());
|
||||
const stat = await fs.stat(enDirectory);
|
||||
|
||||
if (!stat.isDirectory()) {
|
||||
consoleLog.fatal(directory, 'has no `' + baseLanguage.toLowerCase() + '` directory');
|
||||
}
|
||||
|
||||
return readLocaleFiles(enDirectory);
|
||||
};
|
||||
|
||||
export type TranslationOptions = {
|
||||
instancePath: string;
|
||||
packageName: string;
|
||||
languageTag: LanguageTag;
|
||||
verbose?: boolean;
|
||||
queue?: PQueue;
|
||||
};
|
4
packages/translate/tsconfig.build.json
Normal file
4
packages/translate/tsconfig.build.json
Normal file
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"extends": "./tsconfig",
|
||||
"include": ["src"],
|
||||
}
|
10
packages/translate/tsconfig.json
Normal file
10
packages/translate/tsconfig.json
Normal file
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"extends": "@silverhand/ts-config/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"outDir": "lib",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
115
pnpm-lock.yaml
115
pnpm-lock.yaml
|
@ -14,6 +14,9 @@ importers:
|
|||
'@logto/cli':
|
||||
specifier: workspace:^1.1.0
|
||||
version: link:packages/cli
|
||||
'@logto/translate':
|
||||
specifier: workspace:^0.0.0
|
||||
version: link:packages/translate
|
||||
devDependencies:
|
||||
'@changesets/cli':
|
||||
specifier: ^2.26.2
|
||||
|
@ -94,15 +97,6 @@ importers:
|
|||
'@logto/core-kit':
|
||||
specifier: workspace:^2.5.0
|
||||
version: link:../toolkit/core-kit
|
||||
'@logto/language-kit':
|
||||
specifier: workspace:^1.1.0
|
||||
version: link:../toolkit/language-kit
|
||||
'@logto/phrases':
|
||||
specifier: workspace:^1.13.0
|
||||
version: link:../phrases
|
||||
'@logto/phrases-experience':
|
||||
specifier: workspace:^1.7.0
|
||||
version: link:../phrases-experience
|
||||
'@logto/schemas':
|
||||
specifier: workspace:1.19.0
|
||||
version: link:../schemas
|
||||
|
@ -148,27 +142,18 @@ importers:
|
|||
p-limit:
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.0
|
||||
p-queue:
|
||||
specifier: ^8.0.0
|
||||
version: 8.0.1
|
||||
p-retry:
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.0
|
||||
pg-protocol:
|
||||
specifier: ^1.6.0
|
||||
version: 1.6.0
|
||||
roarr:
|
||||
specifier: ^7.11.0
|
||||
version: 7.11.0
|
||||
semver:
|
||||
specifier: ^7.3.8
|
||||
version: 7.5.2
|
||||
tar:
|
||||
specifier: ^7.0.0
|
||||
version: 7.0.1
|
||||
typescript:
|
||||
specifier: ^5.5.3
|
||||
version: 5.5.3
|
||||
yargs:
|
||||
specifier: ^17.6.0
|
||||
version: 17.6.0
|
||||
|
@ -218,6 +203,9 @@ importers:
|
|||
sinon:
|
||||
specifier: ^18.0.0
|
||||
version: 18.0.0
|
||||
typescript:
|
||||
specifier: ^5.5.3
|
||||
version: 5.5.3
|
||||
vitest:
|
||||
specifier: ^2.0.0
|
||||
version: 2.0.0(@types/node@20.10.4)(happy-dom@14.12.3)(jsdom@20.0.2)(lightningcss@1.25.1)(sass@1.77.8)
|
||||
|
@ -3838,6 +3826,85 @@ importers:
|
|||
specifier: ^2.0.0
|
||||
version: 2.0.0(@types/node@20.10.4)(happy-dom@14.12.3)(jsdom@20.0.2)(lightningcss@1.25.1)(sass@1.77.8)
|
||||
|
||||
packages/translate:
|
||||
dependencies:
|
||||
'@logto/core-kit':
|
||||
specifier: workspace:^2.5.0
|
||||
version: link:../toolkit/core-kit
|
||||
'@logto/language-kit':
|
||||
specifier: workspace:^1.1.0
|
||||
version: link:../toolkit/language-kit
|
||||
'@logto/phrases':
|
||||
specifier: workspace:^1.13.0
|
||||
version: link:../phrases
|
||||
'@logto/phrases-experience':
|
||||
specifier: workspace:^1.7.0
|
||||
version: link:../phrases-experience
|
||||
'@logto/shared':
|
||||
specifier: workspace:^3.1.1
|
||||
version: link:../shared
|
||||
'@silverhand/essentials':
|
||||
specifier: ^2.9.1
|
||||
version: 2.9.1
|
||||
chalk:
|
||||
specifier: ^5.3.0
|
||||
version: 5.3.0
|
||||
dotenv:
|
||||
specifier: ^16.4.5
|
||||
version: 16.4.5
|
||||
got:
|
||||
specifier: ^14.0.0
|
||||
version: 14.0.0
|
||||
hpagent:
|
||||
specifier: ^1.2.0
|
||||
version: 1.2.0
|
||||
inquirer:
|
||||
specifier: ^9.0.0
|
||||
version: 9.1.4
|
||||
ora:
|
||||
specifier: ^8.0.1
|
||||
version: 8.0.1
|
||||
p-queue:
|
||||
specifier: ^8.0.0
|
||||
version: 8.0.1
|
||||
typescript:
|
||||
specifier: ^5.5.3
|
||||
version: 5.5.3
|
||||
yargs:
|
||||
specifier: ^17.6.0
|
||||
version: 17.7.2
|
||||
zod:
|
||||
specifier: ^3.23.8
|
||||
version: 3.23.8
|
||||
devDependencies:
|
||||
'@silverhand/eslint-config':
|
||||
specifier: 6.0.1
|
||||
version: 6.0.1(eslint@8.57.0)(prettier@3.0.0)(typescript@5.5.3)
|
||||
'@silverhand/ts-config':
|
||||
specifier: 6.0.0
|
||||
version: 6.0.0(typescript@5.5.3)
|
||||
'@types/inquirer':
|
||||
specifier: ^9.0.0
|
||||
version: 9.0.3
|
||||
'@types/node':
|
||||
specifier: ^20.9.5
|
||||
version: 20.12.7
|
||||
'@types/yargs':
|
||||
specifier: ^17.0.13
|
||||
version: 17.0.13
|
||||
'@vitest/coverage-v8':
|
||||
specifier: ^2.0.0
|
||||
version: 2.0.0(vitest@2.0.0(@types/node@20.12.7)(happy-dom@14.12.3)(jsdom@20.0.2)(lightningcss@1.25.1)(sass@1.77.8))
|
||||
eslint:
|
||||
specifier: ^8.56.0
|
||||
version: 8.57.0
|
||||
lint-staged:
|
||||
specifier: ^15.0.0
|
||||
version: 15.0.2
|
||||
prettier:
|
||||
specifier: ^3.0.0
|
||||
version: 3.0.0
|
||||
|
||||
packages:
|
||||
|
||||
'@75lb/deep-merge@1.1.1':
|
||||
|
@ -16209,7 +16276,7 @@ snapshots:
|
|||
'@typescript-eslint/type-utils': 7.7.0(eslint@8.57.0)(typescript@5.5.3)
|
||||
'@typescript-eslint/utils': 7.7.0(eslint@8.57.0)(typescript@5.5.3)
|
||||
'@typescript-eslint/visitor-keys': 7.7.0
|
||||
debug: 4.3.4
|
||||
debug: 4.3.5
|
||||
eslint: 8.57.0
|
||||
graphemer: 1.4.0
|
||||
ignore: 5.3.1
|
||||
|
@ -16227,7 +16294,7 @@ snapshots:
|
|||
'@typescript-eslint/types': 7.7.0
|
||||
'@typescript-eslint/typescript-estree': 7.7.0(typescript@5.5.3)
|
||||
'@typescript-eslint/visitor-keys': 7.7.0
|
||||
debug: 4.3.4
|
||||
debug: 4.3.5
|
||||
eslint: 8.57.0
|
||||
optionalDependencies:
|
||||
typescript: 5.5.3
|
||||
|
@ -18120,7 +18187,7 @@ snapshots:
|
|||
|
||||
eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.5.3))(eslint-plugin-import@2.29.1)(eslint@8.57.0):
|
||||
dependencies:
|
||||
debug: 4.3.4
|
||||
debug: 4.3.5
|
||||
enhanced-resolve: 5.16.0
|
||||
eslint: 8.57.0
|
||||
eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.5.3))(eslint-plugin-import@2.29.1)(eslint@8.57.0))(eslint@8.57.0)
|
||||
|
@ -18272,7 +18339,7 @@ snapshots:
|
|||
eslint-plugin-sql@2.1.0(eslint@8.57.0):
|
||||
dependencies:
|
||||
astring: 1.8.3
|
||||
debug: 4.3.4
|
||||
debug: 4.3.5
|
||||
eslint: 8.57.0
|
||||
lodash: 4.17.21
|
||||
pg-formatter: 1.3.0
|
||||
|
@ -18282,7 +18349,7 @@ snapshots:
|
|||
|
||||
eslint-plugin-unicorn@52.0.0(eslint@8.57.0):
|
||||
dependencies:
|
||||
'@babel/helper-validator-identifier': 7.22.20
|
||||
'@babel/helper-validator-identifier': 7.24.7
|
||||
'@eslint-community/eslint-utils': 4.4.0(eslint@8.57.0)
|
||||
'@eslint/eslintrc': 2.1.4
|
||||
ci-info: 4.0.0
|
||||
|
@ -24495,7 +24562,7 @@ snapshots:
|
|||
yargs@17.7.2:
|
||||
dependencies:
|
||||
cliui: 8.0.1
|
||||
escalade: 3.1.1
|
||||
escalade: 3.1.2
|
||||
get-caller-file: 2.0.5
|
||||
require-directory: 2.1.1
|
||||
string-width: 4.2.3
|
||||
|
|
Loading…
Reference in a new issue