From 1e1bfa86b7d730bea2cf4783787f040e03cb6c69 Mon Sep 17 00:00:00 2001 From: Yi Cai Date: Thu, 4 Dec 2025 16:16:45 -0500 Subject: [PATCH 1/5] feat(translations): added i18n cli tools Signed-off-by: Yi Cai --- .../translations/packages/cli/.eslintrc.js | 20 + .../translations/packages/cli/TESTING.md | 217 ++++ .../packages/cli/bin/translations-cli | 30 + .../packages/cli/docs/i18n-commands.md | 1040 +++++++++++++++++ .../packages/cli/docs/i18n-solution-review.md | 229 ++++ .../translations/packages/cli/package.json | 61 + .../packages/cli/src/commands/clean.ts | 191 +++ .../packages/cli/src/commands/deploy.ts | 260 +++++ .../packages/cli/src/commands/download.ts | 230 ++++ .../packages/cli/src/commands/generate.ts | 422 +++++++ .../packages/cli/src/commands/index.ts | 245 ++++ .../packages/cli/src/commands/init.ts | 137 +++ .../cli/src/commands/setupMemsource.ts | 239 ++++ .../packages/cli/src/commands/status.ts | 56 + .../packages/cli/src/commands/sync.ts | 265 +++++ .../packages/cli/src/commands/upload.ts | 570 +++++++++ .../translations/packages/cli/src/index.ts | 54 + .../packages/cli/src/lib/errors.ts | 46 + .../cli/src/lib/i18n/analyzeStatus.ts | 148 +++ .../packages/cli/src/lib/i18n/config.ts | 377 ++++++ .../packages/cli/src/lib/i18n/deployFiles.ts | 105 ++ .../packages/cli/src/lib/i18n/extractKeys.ts | 329 ++++++ .../packages/cli/src/lib/i18n/formatReport.ts | 184 +++ .../cli/src/lib/i18n/generateFiles.ts | 177 +++ .../packages/cli/src/lib/i18n/loadFile.ts | 139 +++ .../packages/cli/src/lib/i18n/mergeFiles.ts | 281 +++++ .../packages/cli/src/lib/i18n/saveFile.ts | 104 ++ .../packages/cli/src/lib/i18n/tmsClient.ts | 230 ++++ .../packages/cli/src/lib/i18n/uploadCache.ts | 192 +++ .../packages/cli/src/lib/i18n/validateData.ts | 145 +++ .../packages/cli/src/lib/i18n/validateFile.ts | 275 +++++ .../packages/cli/src/lib/paths.ts | 30 + .../packages/cli/src/lib/version.ts | 55 + .../translations/packages/cli/tsconfig.json | 15 + workspaces/translations/yarn.lock | 976 ++++++++++++++-- 35 files changed, 7967 insertions(+), 107 deletions(-) create mode 100644 workspaces/translations/packages/cli/.eslintrc.js create mode 100644 workspaces/translations/packages/cli/TESTING.md create mode 100755 workspaces/translations/packages/cli/bin/translations-cli create mode 100644 workspaces/translations/packages/cli/docs/i18n-commands.md create mode 100644 workspaces/translations/packages/cli/docs/i18n-solution-review.md create mode 100644 workspaces/translations/packages/cli/package.json create mode 100644 workspaces/translations/packages/cli/src/commands/clean.ts create mode 100644 workspaces/translations/packages/cli/src/commands/deploy.ts create mode 100644 workspaces/translations/packages/cli/src/commands/download.ts create mode 100644 workspaces/translations/packages/cli/src/commands/generate.ts create mode 100644 workspaces/translations/packages/cli/src/commands/index.ts create mode 100644 workspaces/translations/packages/cli/src/commands/init.ts create mode 100644 workspaces/translations/packages/cli/src/commands/setupMemsource.ts create mode 100644 workspaces/translations/packages/cli/src/commands/status.ts create mode 100644 workspaces/translations/packages/cli/src/commands/sync.ts create mode 100644 workspaces/translations/packages/cli/src/commands/upload.ts create mode 100644 workspaces/translations/packages/cli/src/index.ts create mode 100644 workspaces/translations/packages/cli/src/lib/errors.ts create mode 100644 workspaces/translations/packages/cli/src/lib/i18n/analyzeStatus.ts create mode 100644 workspaces/translations/packages/cli/src/lib/i18n/config.ts create mode 100644 workspaces/translations/packages/cli/src/lib/i18n/deployFiles.ts create mode 100644 workspaces/translations/packages/cli/src/lib/i18n/extractKeys.ts create mode 100644 workspaces/translations/packages/cli/src/lib/i18n/formatReport.ts create mode 100644 workspaces/translations/packages/cli/src/lib/i18n/generateFiles.ts create mode 100644 workspaces/translations/packages/cli/src/lib/i18n/loadFile.ts create mode 100644 workspaces/translations/packages/cli/src/lib/i18n/mergeFiles.ts create mode 100644 workspaces/translations/packages/cli/src/lib/i18n/saveFile.ts create mode 100644 workspaces/translations/packages/cli/src/lib/i18n/tmsClient.ts create mode 100644 workspaces/translations/packages/cli/src/lib/i18n/uploadCache.ts create mode 100644 workspaces/translations/packages/cli/src/lib/i18n/validateData.ts create mode 100644 workspaces/translations/packages/cli/src/lib/i18n/validateFile.ts create mode 100644 workspaces/translations/packages/cli/src/lib/paths.ts create mode 100644 workspaces/translations/packages/cli/src/lib/version.ts create mode 100644 workspaces/translations/packages/cli/tsconfig.json diff --git a/workspaces/translations/packages/cli/.eslintrc.js b/workspaces/translations/packages/cli/.eslintrc.js new file mode 100644 index 0000000000..c3a5f5bab4 --- /dev/null +++ b/workspaces/translations/packages/cli/.eslintrc.js @@ -0,0 +1,20 @@ +const baseConfig = require('@backstage/cli/config/eslint-factory')(__dirname); + +module.exports = { + ...baseConfig, + rules: { + ...baseConfig.rules, + // CLI packages need runtime dependencies in dependencies, not devDependencies + '@backstage/no-undeclared-imports': 'off', + }, + overrides: [ + ...(baseConfig.overrides || []), + { + files: ['src/**/*.ts'], + rules: { + '@backstage/no-undeclared-imports': 'off', + }, + }, + ], +}; + diff --git a/workspaces/translations/packages/cli/TESTING.md b/workspaces/translations/packages/cli/TESTING.md new file mode 100644 index 0000000000..2436b9ee9e --- /dev/null +++ b/workspaces/translations/packages/cli/TESTING.md @@ -0,0 +1,217 @@ +# Testing the CLI Locally + +This guide explains how to test the `translations-cli` locally before publishing to npm. + +## Prerequisites + +1. Build the project: + + ```bash + npm run build + ``` + +2. Ensure dependencies are installed: + ```bash + npm install + ``` + +## Method 1: Using npm link (Recommended) + +This method allows you to use `translations-cli` as if it were installed from npm. + +### Step 1: Link the package globally + +```bash +# From the translations-cli directory +npm run link +``` + +This will: + +1. Build the project +2. Create a global symlink to your local package + +### Step 2: Test in a target repository + +```bash +# Navigate to a test repository +cd /path/to/your/test-repo + +# Now you can use translations-cli as if it were installed +translations-cli i18n --help +translations-cli i18n init +translations-cli i18n generate +``` + +### Step 3: Unlink when done + +```bash +# From the translations-cli directory +npm unlink -g translations-cli +``` + +## Method 2: Direct execution (Quick testing) + +Run commands directly using the built binary: + +```bash +# From the translations-cli directory +npm run build +node bin/translations-cli.js i18n --help +node bin/translations-cli.js i18n init +``` + +Or use the test script (builds first, then runs): + +```bash +npm run test:local i18n --help +npm run test:local i18n init +``` + +**Note:** You can also pass arguments: + +```bash +npm run test:local i18n generate --source-dir . --output-dir i18n +``` + +## Method 3: Using ts-node (Development) + +For rapid iteration during development: + +```bash +# Run directly from TypeScript source +npm run dev i18n --help +npm run dev i18n init +``` + +**Note:** This is slower but doesn't require building. + +## Testing Workflow + +### 1. Test Basic Commands + +```bash +# Test help +translations-cli i18n --help + +# Test init +translations-cli i18n init + +# Test generate (in a test repo) +cd /path/to/test-repo +translations-cli i18n generate --source-dir . --output-dir i18n +``` + +### 2. Test Full Workflow + +```bash +# In a test repository +cd /path/to/test-repo + +# 1. Initialize +translations-cli i18n init + +# 2. Generate reference file +translations-cli i18n generate + +# 3. Upload (if TMS configured) +translations-cli i18n upload --source-file i18n/reference.json + +# 4. Download +translations-cli i18n download + +# 5. Deploy +translations-cli i18n deploy +``` + +### 3. Test with Different Options + +```bash +# Test with custom patterns +translations-cli i18n generate \ + --source-dir . \ + --include-pattern "**/*.ts" \ + --exclude-pattern "**/node_modules/**,**/dist/**" + +# Test dry-run +translations-cli i18n upload --source-file i18n/reference.json --dry-run + +# Test force upload +translations-cli i18n upload --source-file i18n/reference.json --force +``` + +## Testing Cache Functionality + +```bash +# First upload +translations-cli i18n upload --source-file i18n/reference.json + +# Second upload (should skip - file unchanged) +translations-cli i18n upload --source-file i18n/reference.json + +# Force upload (should upload anyway) +translations-cli i18n upload --source-file i18n/reference.json --force +``` + +## Testing in Multiple Repos + +Since you mentioned testing across multiple repos: + +```bash +# Link globally once +cd /Users/yicai/redhat/translations-cli +npm run link + +# Then test in each repo +cd /Users/yicai/redhat/rhdh-plugins/workspaces/global-header/plugins/global-header +translations-cli i18n generate + +cd /Users/yicai/redhat/rhdh/packages/app +translations-cli i18n generate + +# etc. +``` + +## Troubleshooting + +### Command not found + +If `translations-cli` is not found: + +1. Make sure you ran `npm run link` +2. Check that `npm prefix -g` is in your PATH +3. Try `npm run test:local` instead + +### Build errors + +If build fails: + +```bash +# Clean and rebuild +rm -rf dist node_modules +npm install +npm run build +``` + +### Cache issues + +To clear cache during testing: + +```bash +translations-cli i18n clean --force +``` + +## Pre-PR Checklist + +Before making a PR, test: + +- [ ] `translations-cli i18n --help` shows all commands +- [ ] `translations-cli i18n init` creates config files +- [ ] `translations-cli i18n generate` extracts keys correctly +- [ ] `translations-cli i18n upload` works (with --dry-run) +- [ ] `translations-cli i18n download` works (with --dry-run) +- [ ] `translations-cli i18n deploy` works (with --dry-run) +- [ ] Cache works (skips unchanged files) +- [ ] All commands show proper error messages +- [ ] Config file patterns are respected +- [ ] Unicode quotes are normalized diff --git a/workspaces/translations/packages/cli/bin/translations-cli b/workspaces/translations/packages/cli/bin/translations-cli new file mode 100755 index 0000000000..34cce60b53 --- /dev/null +++ b/workspaces/translations/packages/cli/bin/translations-cli @@ -0,0 +1,30 @@ +#!/usr/bin/env node +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const path = require('path'); + +// Figure out whether we're running inside the backstage repo or as an installed dependency +/* eslint-disable-next-line no-restricted-syntax */ +const isLocal = require('fs').existsSync(path.resolve(__dirname, '../src')); + +if (!isLocal) { + require('..'); +} else { + require('@backstage/cli/config/nodeTransform.cjs'); + require('../src'); +} + diff --git a/workspaces/translations/packages/cli/docs/i18n-commands.md b/workspaces/translations/packages/cli/docs/i18n-commands.md new file mode 100644 index 0000000000..75b95ce68d --- /dev/null +++ b/workspaces/translations/packages/cli/docs/i18n-commands.md @@ -0,0 +1,1040 @@ +# i18n Translation Commands Guide + +This guide provides comprehensive documentation for using the translations-cli i18n commands to manage translations in your projects. + +## Table of Contents + +- [Prerequisites](#prerequisites) โš ๏ธ **Required setup before using CLI** +- [Available Commands](#available-commands) +- [Configuration](#configuration) +- [Recommended Workflow](#recommended-workflow) โญ **Start here for best practices** +- [Complete Translation Workflow](#complete-translation-workflow) +- [Step-by-Step Usage](#step-by-step-usage) +- [Quick Start](#quick-start) +- [Command Reference](#command-reference) + +--- + +## Prerequisites + +Before using the translations-cli, you need to set up Memsource authentication. This is a **required prerequisite** for upload and download operations. + +### 1. Request Memsource Account + +Request a Memsource account from the localization team. + +### 2. Install Memsource CLI Client + +Install the unofficial Memsource CLI client: + +**Using pip:** + +```bash +pip install memsource-cli-client +``` + +For detailed installation instructions, see: https://github.com/unofficial-memsource/memsource-cli-client#pip-install + +### 3. Configure Memsource Client + +Create `~/.memsourcerc` file in your home directory with your account credentials: + +```bash +vi ~/.memsourcerc +``` + +Paste the following content (replace `username` and `password` with your credentials): + +```bash +source ${HOME}/git/memsource-cli-client/.memsource/bin/activate + +export MEMSOURCE_URL="https://cloud.memsource.com/web" +export MEMSOURCE_USERNAME=username +export MEMSOURCE_PASSWORD=password +export MEMSOURCE_TOKEN=$(memsource auth login --user-name $MEMSOURCE_USERNAME --password "${MEMSOURCE_PASSWORD}" -c token -f value) +``` + +**Note:** Adjust the `source` path to match your Memsource CLI installation location. + +For detailed configuration instructions (including macOS), see: https://github.com/unofficial-memsource/memsource-cli-client#configuration-red-hat-enterprise-linux-derivatives + +### 4. Source the Configuration + +Before running translation commands, source the configuration file: + +```bash +source ~/.memsourcerc +``` + +This sets up the Memsource environment and generates the authentication token automatically. + +**๐Ÿ’ก Tip:** You can add `source ~/.memsourcerc` to your `~/.zshrc` or `~/.bashrc` to automatically load it in new terminal sessions. + +--- + +## Available Commands + +### 1. `i18n init` - Initialize Configuration + +Creates a default configuration file (`.i18n.config.json`) in your project root. + +### 2. `i18n generate` - Extract Translation Keys + +Scans your source code and generates a reference translation file containing all translatable strings. + +### 3. `i18n upload` - Upload to TMS + +Uploads the reference translation file to your Translation Management System (TMS) for translation. + +### 4. `i18n download` - Download Translations + +Downloads completed translations from your TMS. + +### 5. `i18n deploy` - Deploy to Application + +Deploys downloaded translations back to your application's locale files. + +### 6. `i18n status` - Check Status + +Shows translation completion status and statistics across all languages. + +### 7. `i18n clean` - Cleanup + +Removes temporary files, caches, and backup directories. + +### 8. `i18n sync` - All-in-One Workflow + +Runs the complete workflow: generate โ†’ upload โ†’ download โ†’ deploy in one command. + +### 9. `i18n setup-memsource` - Set Up Memsource Configuration + +Creates `.memsourcerc` file following the localization team's instructions format. This sets up the Memsource CLI environment with virtual environment activation and automatic token generation. + +--- + +## Configuration + +The CLI uses a **project configuration file** for project-specific settings, and **Memsource authentication** (via `~/.memsourcerc`) for personal credentials: + +1. **Project Config** (`.i18n.config.json`) - Project-specific settings that can be committed +2. **Memsource Auth** (`~/.memsourcerc`) - Personal credentials (primary method, see [Prerequisites](#prerequisites)) +3. **Fallback Auth** (`~/.i18n.auth.json`) - Optional fallback if not using `.memsourcerc` + +### Initialize Configuration Files + +Initialize the project configuration file with: + +```bash +npx translations-cli i18n init +``` + +This creates: + +#### 1. Project Configuration (`.i18n.config.json`) + +Located in your project root. **This file can be committed to git.** + +```json +{ + "tms": { + "url": "", + "projectId": "" + }, + "directories": { + "sourceDir": "src", + "outputDir": "i18n", + "localesDir": "src/locales" + }, + "languages": [], + "format": "json", + "patterns": { + "include": "**/*.{ts,tsx,js,jsx}", + "exclude": "**/node_modules/**,**/dist/**,**/build/**,**/*.test.ts,**/*.spec.ts" + } +} +``` + +**Contains:** + +- TMS URL (project-specific) +- Project ID (project-specific) +- Directory paths (project-specific) +- Languages (project-specific) +- Format (project-specific) +- File patterns (project-specific) - for scanning source files + +#### 2. Memsource Authentication (`~/.memsourcerc`) - **Primary Method** + +Located in your home directory. **This file should NOT be committed to git.** + +This is the **recommended authentication method** following the localization team's instructions. See [Prerequisites](#prerequisites) for setup instructions. + +**Contains:** + +- Memsource virtual environment activation +- `MEMSOURCE_URL` environment variable +- `MEMSOURCE_USERNAME` environment variable +- `MEMSOURCE_PASSWORD` environment variable +- `MEMSOURCE_TOKEN` (automatically generated) + +**Usage:** + +```bash +source ~/.memsourcerc +translations-cli i18n upload --source-file i18n/reference.json +``` + +#### 3. Fallback Authentication (`~/.i18n.auth.json`) - **Optional** + +Located in your home directory. **This file should NOT be committed to git.** + +Only needed if you're not using `.memsourcerc`. The CLI will create this file if `.memsourcerc` doesn't exist when you run `init`. + +```json +{ + "tms": { + "username": "", + "password": "", + "token": "" + } +} +``` + +**โš ๏ธ Important:** Add both `~/.memsourcerc` and `~/.i18n.auth.json` to your global `.gitignore`: + +```bash +echo ".memsourcerc" >> ~/.gitignore_global +echo ".i18n.auth.json" >> ~/.gitignore_global +git config --global core.excludesfile ~/.gitignore_global +``` + +### Environment Variables + +You can also configure settings using environment variables (these override config file values): + +**Project Settings:** + +```bash +export I18N_TMS_URL="https://your-tms-api.com" +export I18N_TMS_PROJECT_ID="your-project-id" +export I18N_LANGUAGES="es,fr,de,ja,zh" +export I18N_FORMAT="json" +export I18N_SOURCE_DIR="src" +export I18N_OUTPUT_DIR="i18n" +export I18N_LOCALES_DIR="src/locales" +``` + +**Personal Authentication:** + +```bash +export I18N_TMS_TOKEN="your-api-token" +export I18N_TMS_USERNAME="your-username" +export I18N_TMS_PASSWORD="your-password" +``` + +**Backward Compatibility with Memsource CLI:** + +The CLI also supports `MEMSOURCE_*` environment variables for compatibility with existing Memsource CLI setups: + +```bash +export MEMSOURCE_URL="https://cloud.memsource.com/web" +export MEMSOURCE_USERNAME="your-username" +export MEMSOURCE_PASSWORD="your-password" +export MEMSOURCE_TOKEN="your-token" # Optional - will be auto-generated if username/password are provided +``` + +**Automatic Token Generation:** + +If you provide `username` and `password` but no `token`, the CLI will automatically attempt to generate a token using the Memsource CLI (`memsource auth login`). This replicates the behavior of your `.memsourcerc` file: + +```bash +# If memsource CLI is installed and activated, token will be auto-generated +export MEMSOURCE_USERNAME="your-username" +export MEMSOURCE_PASSWORD="your-password" +# Token will be generated automatically: memsource auth login --user-name $USERNAME --password "$PASSWORD" -c token -f value +``` + +### Configuration Priority + +Configuration values are resolved in the following order (highest to lowest priority): + +1. **Command-line options** (highest priority) +2. **Environment variables** +3. **Personal auth file** (`~/.i18n.auth.json`) - for credentials +4. **Project config file** (`.i18n.config.json`) - for project settings +5. **Default values** (lowest priority) + +This means: + +- **Project settings** (URL, project ID, directories, languages) come from `.i18n.config.json` +- **Personal credentials** (username, password, token) come from `~/.i18n.auth.json` +- Both can be overridden by environment variables or command-line options + +--- + +## Output Format + +### Generated Reference File Structure + +The `generate` command creates a `reference.json` file with a nested structure organized by plugin: + +```json +{ + "plugin-name": { + "en": { + "key": "value", + "nested.key": "value" + } + }, + "another-plugin": { + "en": { + "key": "value" + } + } +} +``` + +**Structure Details:** + +- **Top level**: Plugin names (detected from file paths or workspace structure) +- **Second level**: Language code (`en` for English reference) +- **Third level**: Translation keys and their English values + +**Plugin Name Detection:** + +- For workspace structure: `workspaces/{workspace}/plugins/{plugin}/...` โ†’ uses `{plugin}` +- For non-workspace structure: `.../translations/{plugin}/ref.ts` โ†’ uses `{plugin}` (folder name) +- Fallback: Uses parent directory name if no pattern matches + +**After Generation:** +The command outputs a summary table showing: + +- Each plugin included +- Number of keys per plugin +- Total plugins and keys + +Example output: + +``` +๐Ÿ“‹ Included Plugins Summary: +โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + โ€ข adoption-insights 45 keys + โ€ข global-header 32 keys + โ€ข topology 276 keys +โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + Total: 3 plugins, 353 keys +``` + +--- + +## Recommended Workflow + +### For Memsource Users (Recommended) + +**The recommended workflow is to source `.memsourcerc` first, then use CLI commands:** + +```bash +# 1. One-time setup (first time only) +npx translations-cli i18n setup-memsource +source ~/.memsourcerc + +# 2. Daily usage (in each new shell session) +source ~/.memsourcerc # Sets MEMSOURCE_TOKEN in environment +npx translations-cli i18n generate +npx translations-cli i18n upload --source-file i18n/reference.json +npx translations-cli i18n download +npx translations-cli i18n deploy +``` + +**Why this workflow?** + +- โœ… `.memsourcerc` sets `MEMSOURCE_TOKEN` in your environment +- โœ… CLI automatically reads from environment variables (highest priority) +- โœ… No redundant token generation needed +- โœ… Follows localization team's standard workflow +- โœ… Most efficient and reliable + +**Pro Tip**: Add to your shell profile to auto-source: + +```bash +echo "source ~/.memsourcerc" >> ~/.zshrc # or ~/.bashrc +``` + +### For Other TMS Users + +```bash +# 1. One-time setup +npx translations-cli i18n init +# Edit ~/.i18n.auth.json with your credentials + +# 2. Daily usage +npx translations-cli i18n generate +npx translations-cli i18n upload --source-file i18n/reference.json +npx translations-cli i18n download +npx translations-cli i18n deploy +``` + +--- + +## Complete Translation Workflow + +The typical translation workflow consists of four main steps: + +``` +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ 1. Generate โ”‚ Extract translation keys from source code +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ + โ–ผ +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ 2. Upload โ”‚ Send reference file to TMS for translation +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ + โ–ผ +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ 3. Download โ”‚ Get completed translations from TMS +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ + โ–ผ +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ 4. Deploy โ”‚ Update application locale files +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +--- + +## Step-by-Step Usage + +### Option A: Step-by-Step Workflow + +#### Step 1: Initialize Configuration (First Time Only) + +```bash +# Basic initialization +npx translations-cli i18n init + +# Or initialize with Memsource setup (recommended for Memsource users) +npx translations-cli i18n init --setup-memsource +``` + +**For Memsource Users (Localization Team Setup):** + +If you're using Memsource, set up your `.memsourcerc` file following the localization team's instructions: + +```bash +# The command will prompt for username and password if not provided +npx translations-cli i18n setup-memsource + +# Or provide credentials directly (password input will be hidden) +npx translations-cli i18n setup-memsource \ + --username your-username \ + --password your-password \ + --memsource-venv "${HOME}/git/memsource-cli-client/.memsource/bin/activate" +``` + +This creates `~/.memsourcerc` in the exact format specified by the localization team: + +```bash +source ${HOME}/git/memsource-cli-client/.memsource/bin/activate + +export MEMSOURCE_URL="https://cloud.memsource.com/web" +export MEMSOURCE_USERNAME=your-username +export MEMSOURCE_PASSWORD=your-password +export MEMSOURCE_TOKEN=$(memsource auth login --user-name $MEMSOURCE_USERNAME --password "${MEMSOURCE_PASSWORD}" -c token -f value) +``` + +**Important**: After creating `.memsourcerc`, you must source it before using CLI commands: + +```bash +# Source the file to set MEMSOURCE_TOKEN in your environment +source ~/.memsourcerc + +# Now you can use CLI commands - they'll automatically use MEMSOURCE_TOKEN +npx translations-cli i18n generate +``` + +**For convenience**, add it to your shell profile so it's automatically sourced: + +```bash +echo "source ~/.memsourcerc" >> ~/.zshrc # or ~/.bashrc +``` + +**Why this matters**: When you source `.memsourcerc`, it sets `MEMSOURCE_TOKEN` in your environment. The CLI reads this automatically (environment variables have high priority), so you don't need to provide credentials each time. + +Edit `.i18n.config.json` with your project settings (TMS URL, project ID, languages). + +#### Step 2: Generate Translation Reference File + +```bash +npx translations-cli i18n generate \ + --source-dir src \ + --output-dir i18n \ + --format json +``` + +**Options:** + +- `--source-dir`: Source directory to scan (default: `src`, can be set in config) +- `--output-dir`: Output directory for generated files (default: `i18n`, can be set in config) +- `--format`: Output format - `json` or `po` (default: `json`, can be set in config) +- `--include-pattern`: File pattern to include (default: `**/*.{ts,tsx,js,jsx}`, can be set in config) +- `--exclude-pattern`: File pattern to exclude (default: `**/node_modules/**`, can be set in config) +- `--extract-keys`: Extract translation keys from source code (default: `true`) +- `--merge-existing`: Merge with existing translation files (default: `false`) + +**Output Format:** +The generated `reference.json` file uses a nested structure organized by plugin: + +```json +{ + "plugin-name": { + "en": { + "key": "value", + "nested.key": "value" + } + }, + "another-plugin": { + "en": { + "key": "value" + } + } +} +``` + +**File Detection:** +The CLI automatically detects English reference files by looking for: + +- `createTranslationRef` imports from `@backstage/core-plugin-api/alpha` or `@backstage/frontend-plugin-api` +- `createTranslationMessages` imports (for overriding/extending existing translations) +- `createTranslationResource` imports (for setting up translation resources) +- Files are excluded if they match language file patterns (e.g., `de.ts`, `es.ts`, `fr.ts`) + +**After Generation:** +The command outputs a summary showing: + +- Total number of plugins included +- Total number of keys extracted +- A detailed list of each plugin with its key count + +**๐Ÿ’ก Tip:** For monorepos or projects with custom file structures, configure patterns in `.i18n.config.json`: + +```json +{ + "directories": { + "sourceDir": ".", + "outputDir": "i18n" + }, + "patterns": { + "include": "**/*.{ts,tsx}", + "exclude": "**/node_modules/**,**/dist/**,**/build/**,**/*.test.ts,**/*.spec.ts" + } +} +``` + +Then run without passing patterns: + +```bash +npx translations-cli i18n generate +``` + +#### Step 3: Upload to TMS + +```bash +npx translations-cli i18n upload \ + --tms-url https://your-tms-api.com \ + --tms-token YOUR_API_TOKEN \ + --project-id YOUR_PROJECT_ID \ + --source-file i18n/reference.json \ + --target-languages "es,fr,de,ja,zh" +``` + +**Options:** + +- `--tms-url`: TMS API URL (can be set in config) +- `--tms-token`: TMS API token (can be set in config, or from `~/.memsourcerc` via `MEMSOURCE_TOKEN`) +- `--project-id`: TMS project ID (can be set in config) +- `--source-file`: Source translation file to upload (required) +- `--target-languages`: Comma-separated list of target languages (required, or set in config `languages` array) +- `--upload-filename`: Custom filename for the uploaded file (default: auto-generated as `{repo-name}-reference-{YYYY-MM-DD}.json`) +- `--force`: Force upload even if file hasn't changed (bypasses cache check) +- `--dry-run`: Show what would be uploaded without actually uploading + +**Upload Filename:** +The CLI automatically generates unique filenames for uploads to prevent overwriting files in your TMS project: + +- Format: `{repo-name}-reference-{YYYY-MM-DD}.json` +- Example: `rhdh-plugins-reference-2025-11-25.json` +- The repo name is detected from your git remote URL or current directory name +- You can override with `--upload-filename` if needed + +**Caching:** +The CLI caches uploads to avoid re-uploading unchanged files: + +- Cache is stored in `.i18n-cache/` directory +- Cache tracks file content hash and upload filename +- Use `--force` to bypass cache and upload anyway +- Cache is automatically checked before each upload + +#### Step 4: Download Translations (After Translation is Complete) + +```bash +npx translations-cli i18n download \ + --tms-url https://your-tms-api.com \ + --tms-token YOUR_API_TOKEN \ + --project-id YOUR_PROJECT_ID \ + --output-dir i18n \ + --languages "es,fr,de,ja,zh" \ + --format json +``` + +**Options:** + +- `--tms-url`: TMS API URL (can be set in config) +- `--tms-token`: TMS API token (can be set in config) +- `--project-id`: TMS project ID (can be set in config) +- `--output-dir`: Output directory for downloaded translations (default: `i18n`) +- `--languages`: Comma-separated list of languages to download +- `--format`: Download format - `json` or `po` (default: `json`) +- `--include-completed`: Include completed translations only (default: `true`) +- `--include-draft`: Include draft translations (default: `false`) + +#### Step 5: Deploy Translations to Application + +```bash +npx translations-cli i18n deploy \ + --source-dir i18n \ + --target-dir src/locales \ + --languages "es,fr,de,ja,zh" \ + --format json \ + --backup \ + --validate +``` + +**Options:** + +- `--source-dir`: Source directory containing downloaded translations (default: `i18n`) +- `--target-dir`: Target directory for language files (default: `src/locales`) +- `--languages`: Comma-separated list of languages to deploy +- `--format`: Input format - `json` or `po` (default: `json`) +- `--backup`: Create backup of existing language files (default: `true`) +- `--validate`: Validate translations before deploying (default: `true`) + +### Option B: All-in-One Sync Command + +For a complete workflow in one command: + +```bash +npx translations-cli i18n sync \ + --source-dir src \ + --output-dir i18n \ + --locales-dir src/locales \ + --tms-url https://your-tms-api.com \ + --tms-token YOUR_API_TOKEN \ + --project-id YOUR_PROJECT_ID \ + --languages "es,fr,de,ja,zh" +``` + +**Options:** + +- All options from individual commands +- `--skip-upload`: Skip upload step +- `--skip-download`: Skip download step +- `--skip-deploy`: Skip deploy step +- `--dry-run`: Show what would be done without executing + +--- + +## Quick Start + +### For a Typical Repository + +#### 1. Initialize Configuration + +```bash +# For Memsource users (recommended) +npx translations-cli i18n setup-memsource +source ~/.memsourcerc + +# Or basic initialization +npx translations-cli i18n init +``` + +**For Memsource Users (Recommended Workflow):** + +1. **One-time setup**: + + ```bash + npx translations-cli i18n setup-memsource + source ~/.memsourcerc + ``` + +2. **Daily usage** (in each new shell): + + ```bash + # Always source .memsourcerc first to set MEMSOURCE_TOKEN + source ~/.memsourcerc + + # Then use CLI commands - they'll automatically use MEMSOURCE_TOKEN from environment + npx translations-cli i18n generate + npx translations-cli i18n upload --source-file i18n/reference.json + ``` + + **Why source first?** The `.memsourcerc` file sets `MEMSOURCE_TOKEN` in your environment. The CLI reads this automatically, avoiding redundant token generation. + +3. **Optional**: Add to shell profile for automatic sourcing: + ```bash + echo "source ~/.memsourcerc" >> ~/.zshrc # or ~/.bashrc + ``` + +**For Other TMS Users:** +Edit `.i18n.config.json` with your TMS credentials, or set environment variables: + +```bash +export I18N_TMS_URL="https://your-tms-api.com" +export I18N_TMS_TOKEN="your-api-token" +export I18N_TMS_PROJECT_ID="your-project-id" +export I18N_LANGUAGES="es,fr,de,ja,zh" +``` + +#### 2. Generate Reference File + +```bash +npx translations-cli i18n generate +``` + +#### 3. Upload to TMS + +```bash +npx translations-cli i18n upload --source-file i18n/reference.json +``` + +#### 4. Download Translations (After Translation is Complete) + +```bash +npx translations-cli i18n download +``` + +#### 5. Deploy to Application + +```bash +npx translations-cli i18n deploy +``` + +--- + +## Utility Commands + +### Check Translation Status + +```bash +npx translations-cli i18n status \ + --source-dir src \ + --i18n-dir i18n \ + --locales-dir src/locales \ + --format table +``` + +**Options:** + +- `--source-dir`: Source directory to analyze (default: `src`) +- `--i18n-dir`: i18n directory to analyze (default: `i18n`) +- `--locales-dir`: Locales directory to analyze (default: `src/locales`) +- `--format`: Output format - `table` or `json` (default: `table`) +- `--include-stats`: Include detailed statistics (default: `true`) + +**Output includes:** + +- Total translation keys +- Languages configured +- Overall completion percentage +- Per-language completion status +- Missing keys +- Extra keys (keys in language files but not in reference) + +### Clean Up Temporary Files + +```bash +npx translations-cli i18n clean \ + --i18n-dir i18n \ + --force +``` + +**Options:** + +- `--i18n-dir`: i18n directory to clean (default: `i18n`) +- `--cache-dir`: Cache directory to clean (default: `.i18n-cache`) +- `--backup-dir`: Backup directory to clean (default: `.i18n-backup`) +- `--force`: Force cleanup without confirmation (default: `false`) + +--- + +## Command Reference + +### Configuration Priority + +When using commands, values are resolved in this order: + +1. **Command-line options** (highest priority) +2. **Environment variables** (prefixed with `I18N_`) +3. **Config file** (`.i18n.config.json`) +4. **Default values** (lowest priority) + +### Example: Using Config with Overrides + +```bash +# Config file has: tms.url = "https://default-tms.com" +# Environment has: I18N_TMS_URL="https://env-tms.com" +# Command uses: --tms-url "https://override-tms.com" + +# Result: Uses "https://override-tms.com" (command-line wins) +npx translations-cli i18n upload --tms-url "https://override-tms.com" +``` + +### Environment Variables Reference + +| Variable | Description | Example | Config File | +| ---------------------- | ------------------------------------ | --------------------------------- | ---------------- | +| `I18N_TMS_URL` | TMS API URL | `https://tms.example.com` | Project | +| `I18N_TMS_PROJECT_ID` | TMS project ID | `project-123` | Project | +| `I18N_LANGUAGES` | Comma-separated languages | `es,fr,de,ja,zh` | Project | +| `I18N_FORMAT` | File format | `json` or `po` | Project | +| `I18N_SOURCE_DIR` | Source directory | `src` | Project | +| `I18N_OUTPUT_DIR` | Output directory | `i18n` | Project | +| `I18N_LOCALES_DIR` | Locales directory | `src/locales` | Project | +| `I18N_INCLUDE_PATTERN` | File pattern to include | `**/*.{ts,tsx,js,jsx}` | Project (config) | +| `I18N_EXCLUDE_PATTERN` | File pattern to exclude | `**/node_modules/**` | Project (config) | +| `I18N_TMS_TOKEN` | TMS API token | `your-api-token` | Personal Auth | +| `I18N_TMS_USERNAME` | TMS username | `your-username` | Personal Auth | +| `I18N_TMS_PASSWORD` | TMS password | `your-password` | Personal Auth | +| `MEMSOURCE_URL` | Memsource URL (backward compat) | `https://cloud.memsource.com/web` | Project | +| `MEMSOURCE_TOKEN` | Memsource token (backward compat) | `your-token` | Personal Auth | +| `MEMSOURCE_USERNAME` | Memsource username (backward compat) | `your-username` | Personal Auth | +| `MEMSOURCE_PASSWORD` | Memsource password (backward compat) | `your-password` | Personal Auth | + +--- + +## Best Practices + +1. **Separate Project and Personal Config**: + + - Store project settings in `.i18n.config.json` (can be committed) + - Store personal credentials in `~/.i18n.auth.json` (should NOT be committed) + - Add `~/.i18n.auth.json` to your global `.gitignore` + +2. **Version Control**: + + - Commit `.i18n.config.json` with project-specific settings + - Never commit `~/.i18n.auth.json` (contains personal credentials) + - Use environment variables or CI/CD secrets for credentials in CI/CD pipelines + +3. **Backup Before Deploy**: Always use `--backup` when deploying translations to preserve existing files. + +4. **Validate Translations**: Use `--validate` to catch issues before deploying. + +5. **Check Status Regularly**: Run `i18n status` to monitor translation progress. + +6. **Clean Up**: Periodically run `i18n clean` to remove temporary files. + +--- + +## Troubleshooting + +### Missing TMS Configuration + +If you get errors about missing TMS configuration: + +**For Memsource Users:** + +1. Make sure you've sourced `.memsourcerc`: + ```bash + source ~/.memsourcerc + # Verify token is set + echo $MEMSOURCE_TOKEN + ``` +2. If `.memsourcerc` doesn't exist, create it: + ```bash + npx translations-cli i18n setup-memsource + source ~/.memsourcerc + ``` + +**For Other TMS Users:** + +1. Run `npx translations-cli i18n init` to create both config files +2. Edit `.i18n.config.json` with project settings (TMS URL, project ID) +3. Edit `~/.i18n.auth.json` with your personal credentials (username, password, token) +4. Or set environment variables: `I18N_TMS_URL`, `I18N_TMS_PROJECT_ID`, `I18N_TMS_TOKEN` + +### Translation Keys Not Found + +If translation keys aren't being extracted: + +1. Check `--include-pattern` matches your file types +2. Verify source files contain one of these patterns: + - `import { createTranslationRef } from '@backstage/core-plugin-api/alpha'` + - `import { createTranslationMessages } from '@backstage/core-plugin-api/alpha'` + - `import { createTranslationResource } from '@backstage/core-plugin-api/alpha'` + - Or from `@backstage/frontend-plugin-api` +3. Check `--exclude-pattern` isn't excluding your source files +4. Ensure files are English reference files (not `de.ts`, `es.ts`, `fr.ts`, etc.) +5. For monorepos, verify your `sourceDir` and patterns are configured correctly + +### File Format Issues + +If you encounter format errors: + +1. Ensure `--format` matches your file extensions (`.json` or `.po`) +2. Validate files with `i18n status` before deploying +3. Check TMS supports the format you're using + +--- + +## Examples + +### Example 1: Recommended Workflow for Memsource Users + +```bash +# 1. One-time setup +npx translations-cli i18n setup-memsource +source ~/.memsourcerc + +# 2. Daily usage (in each new shell, source first) +source ~/.memsourcerc # Sets MEMSOURCE_TOKEN automatically + +# 3. Use CLI commands - they automatically use MEMSOURCE_TOKEN from environment +npx translations-cli i18n generate +npx translations-cli i18n upload --source-file i18n/reference.json +npx translations-cli i18n download +npx translations-cli i18n deploy +``` + +### Example 2: Basic Workflow with Config Files (Other TMS) + +```bash +# 1. Initialize config files +npx translations-cli i18n init + +# 2. Edit .i18n.config.json with project settings (TMS URL, project ID, languages) +# 3. Edit ~/.i18n.auth.json with your credentials (username, password, token) + +# 4. Generate (uses config defaults) +npx translations-cli i18n generate + +# 5. Upload (uses config defaults) +npx translations-cli i18n upload --source-file i18n/reference.json + +# 6. Download (uses config defaults) +npx translations-cli i18n download + +# 7. Deploy (uses config defaults) +npx translations-cli i18n deploy +``` + +### Example 3: Monorepo Setup with Config Patterns + +For monorepos or projects where you want to scan from the repo root: + +```bash +# 1. Initialize config in repo root +cd /path/to/your/repo +npx translations-cli i18n init + +# 2. Edit .i18n.config.json for monorepo scanning +``` + +```json +{ + "tms": { + "url": "https://your-tms-api.com", + "projectId": "your-project-id" + }, + "directories": { + "sourceDir": ".", + "outputDir": "i18n", + "localesDir": "src/locales" + }, + "languages": ["es", "fr", "de", "ja", "zh"], + "format": "json", + "patterns": { + "include": "**/*.{ts,tsx}", + "exclude": "**/node_modules/**,**/dist/**,**/build/**,**/*.test.ts,**/*.spec.ts" + } +} +``` + +```bash +# 3. Run generate - patterns are automatically used from config +npx translations-cli i18n generate + +# No need to pass --include-pattern or --exclude-pattern every time! +# The command will scan from repo root (.) and find all reference files +``` + +This is especially useful when: + +- Working with monorepos (multiple workspaces/plugins) +- Reference files are in different locations (e.g., `src/translations/ref.ts`, `plugins/*/src/translations/ref.ts`) +- You want project-specific patterns that can be committed to git + +### Example 4: Using Environment Variables + +```bash +# Set environment variables (project settings) +export I18N_TMS_URL="https://tms.example.com" +export I18N_TMS_PROJECT_ID="proj456" +export I18N_LANGUAGES="es,fr,de" + +# Set environment variables (personal credentials) +export I18N_TMS_TOKEN="token123" +# Or use username/password: +# export I18N_TMS_USERNAME="your-username" +# export I18N_TMS_PASSWORD="your-password" + +# Commands will use these values +npx translations-cli i18n generate +npx translations-cli i18n upload --source-file i18n/reference.json +npx translations-cli i18n download +npx translations-cli i18n deploy +``` + +### Example 4: Override Config with Command Options + +```bash +# Config has default languages: ["es", "fr"] +# Override for this command only +npx translations-cli i18n download --languages "es,fr,de,ja,zh" +``` + +### Example 5: Complete Sync Workflow + +```bash +# Run entire workflow in one command +npx translations-cli i18n sync \ + --languages "es,fr,de,ja,zh" \ + --tms-url "https://tms.example.com" \ + --tms-token "token123" \ + --project-id "proj456" +``` + +--- + +## Additional Resources + +- For help with any command: `npx translations-cli i18n [command] --help` +- Check translation status: `npx translations-cli i18n status` +- Clean up files: `npx translations-cli i18n clean --force` + +--- + +## Summary + +The translations-cli i18n commands provide a complete solution for managing translations: + +- โœ… Extract translation keys from source code +- โœ… Upload to Translation Management Systems +- โœ… Download completed translations +- โœ… Deploy translations to your application +- โœ… Monitor translation status +- โœ… Configure defaults via config file or environment variables +- โœ… Override settings per command as needed + +Start with `npx translations-cli i18n init` to set up your configuration, then use the commands as needed for your translation workflow. diff --git a/workspaces/translations/packages/cli/docs/i18n-solution-review.md b/workspaces/translations/packages/cli/docs/i18n-solution-review.md new file mode 100644 index 0000000000..967c3abb48 --- /dev/null +++ b/workspaces/translations/packages/cli/docs/i18n-solution-review.md @@ -0,0 +1,229 @@ +# i18n CLI Solution Review & Best Practices + +## Executive Summary + +The current solution is **well-architected** and follows good practices, with some improvements made for security, efficiency, and user experience. + +## โœ… Strengths + +### 1. **Separation of Concerns** + +- **Two-file configuration system**: Project settings (`.i18n.config.json`) vs Personal auth (`~/.i18n.auth.json`) +- Clear distinction between what can be committed vs what should remain private +- Follows security best practices for credential management + +### 2. **Flexibility & Compatibility** + +- Supports both `I18N_*` and `MEMSOURCE_*` environment variables +- Backward compatible with existing Memsource CLI workflows +- Works with localization team's standard `.memsourcerc` format + +### 3. **User Experience** + +- `setup-memsource` command automates the setup process +- Interactive mode for easy credential entry +- Clear documentation and next steps + +### 4. **Configuration Priority** + +Well-defined priority order: + +1. Command-line options (highest) +2. Environment variables +3. Personal auth file +4. Project config file +5. Defaults (lowest) + +## ๐Ÿ”ง Improvements Made + +### 1. **Token Generation Logic** + +**Before**: Always tried to generate token if username/password available +**After**: + +- Checks if Memsource setup is detected first +- Only generates as fallback when needed +- Prefers environment token (from `.memsourcerc`) over generation + +**Rationale**: If user sources `.memsourcerc`, `MEMSOURCE_TOKEN` is already set. No need to regenerate. + +### 2. **Security Enhancements** + +- Added security warnings about storing passwords in plain text +- Set file permissions to 600 (owner read/write only) for auth files +- Clear warnings about not committing sensitive files + +### 3. **Error Handling** + +- Better detection of memsource CLI availability +- Graceful fallback when CLI is not available +- Clearer error messages + +### 4. **Documentation** + +- Added security notes in setup output +- Better guidance on workflow (source `.memsourcerc` first) +- Clearer next steps after setup + +## ๐Ÿ“‹ Current Architecture + +``` +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Configuration Sources (Priority Order) โ”‚ +โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค +โ”‚ 1. Command-line options โ”‚ +โ”‚ 2. Environment variables โ”‚ +โ”‚ - I18N_TMS_* or MEMSOURCE_* โ”‚ +โ”‚ 3. Personal auth (~/.i18n.auth.json) โ”‚ +โ”‚ 4. Project config (.i18n.config.json) โ”‚ +โ”‚ 5. Defaults โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +## ๐ŸŽฏ Recommended Workflow + +### For Memsource Users (Localization Team) + +1. **Initial Setup**: + + ```bash + npx translations-cli i18n setup-memsource --interactive + source ~/.memsourcerc + ``` + +2. **Daily Usage**: + + ```bash + # In new shell sessions, source the file first + source ~/.memsourcerc + + # Then use CLI commands + npx translations-cli i18n generate + npx translations-cli i18n upload --source-file i18n/reference.json + ``` + +3. **Why This Works**: + - `.memsourcerc` sets `MEMSOURCE_TOKEN` in environment + - CLI reads from environment (highest priority after command-line) + - No redundant token generation needed + +### For Other TMS Users + +1. **Initial Setup**: + + ```bash + npx translations-cli i18n init + # Edit ~/.i18n.auth.json with credentials + ``` + +2. **Daily Usage**: + ```bash + # CLI reads from config files automatically + npx translations-cli i18n generate + ``` + +## โš ๏ธ Security Considerations + +### Current Approach + +- **Password Storage**: Passwords stored in plain text files (`.memsourcerc`, `.i18n.auth.json`) +- **File Permissions**: Set to 600 (owner read/write only) โœ… +- **Git Safety**: Files are in home directory, not project root โœ… + +### Why This is Acceptable + +1. **Follows Localization Team Standards**: The `.memsourcerc` format is required by the team +2. **Standard Practice**: Many CLI tools use similar approaches (AWS CLI, Docker, etc.) +3. **Mitigation**: File permissions and location provide reasonable protection +4. **User Control**: Users can choose to use environment variables instead + +### Best Practices for Users + +1. โœ… Never commit `.memsourcerc` or `.i18n.auth.json` to git +2. โœ… Keep file permissions at 600 +3. โœ… Use environment variables in CI/CD pipelines +4. โœ… Rotate credentials regularly +5. โœ… Use separate credentials for different environments + +## ๐Ÿ” Potential Future Enhancements + +### 1. **Token Caching** (Low Priority) + +- Cache generated tokens to avoid regeneration +- Store in secure temp file with short TTL +- **Current**: Token regenerated each time (acceptable for now) + +### 2. **Password Input Masking** (Medium Priority) + +- Use library like `readline-sync` or `inquirer` for hidden password input +- **Current**: Password visible in terminal (acceptable for setup command) + +### 3. **Credential Validation** (Medium Priority) + +- Test credentials during setup +- Verify token generation works +- **Current**: User must verify manually + +### 4. **Multi-Environment Support** (Low Priority) + +- Support different configs for dev/staging/prod +- Environment-specific project IDs +- **Current**: Single config per project (sufficient for most use cases) + +## โœ… Is This Best Practice? + +### Yes, with caveats: + +1. **For the Use Case**: โœ… + + - Follows localization team's requirements + - Compatible with existing workflows + - Flexible for different TMS systems + +2. **Security**: โš ๏ธ Acceptable + + - Plain text passwords are not ideal, but: + - Required by localization team format + - Protected by file permissions + - Standard practice for CLI tools + - Users can use environment variables instead + +3. **Architecture**: โœ… + + - Clean separation of concerns + - Good configuration priority system + - Extensible for future needs + +4. **User Experience**: โœ… + - Easy setup process + - Clear documentation + - Helpful error messages + +## ๐Ÿ“Š Comparison with Alternatives + +| Approach | Pros | Cons | Our Choice | +| ------------------------------ | ----------------------------------- | ------------------------------- | ----------------------------- | +| **Plain text files** | Simple, compatible with team format | Security concerns | โœ… Used (required) | +| **Environment variables only** | More secure | Less convenient, no persistence | โœ… Supported as option | +| **Keychain/OS secrets** | Most secure | Complex, platform-specific | โŒ Not needed | +| **Encrypted config** | Good security | Requires key management | โŒ Overkill for this use case | + +## ๐ŸŽฏ Conclusion + +The current solution is **well-designed and appropriate** for the use case: + +1. โœ… Follows localization team's requirements +2. โœ… Provides good security within constraints +3. โœ… Offers flexibility for different workflows +4. โœ… Has clear separation of concerns +5. โœ… Includes helpful setup automation + +**Recommendation**: The solution is production-ready. The improvements made address the main concerns (redundant token generation, security warnings, better error handling). No major architectural changes needed. + +## ๐Ÿ“ Action Items for Users + +1. โœ… Use `i18n setup-memsource` for initial setup +2. โœ… Source `.memsourcerc` before using commands +3. โœ… Keep auth files secure (600 permissions) +4. โœ… Never commit sensitive files to git +5. โœ… Use environment variables in CI/CD diff --git a/workspaces/translations/packages/cli/package.json b/workspaces/translations/packages/cli/package.json new file mode 100644 index 0000000000..438e3a265d --- /dev/null +++ b/workspaces/translations/packages/cli/package.json @@ -0,0 +1,61 @@ +{ + "name": "@red-hat-developer-hub/translations-cli", + "description": "CLI tools for translation workflows with our TMS.", + "version": "0.1.0", + "backstage": { + "role": "cli" + }, + "private": true, + "homepage": "https://red.ht/rhdh", + "repository": { + "type": "git", + "url": "https://github.com/redhat-developer/rhdh-plugins", + "directory": "workspaces/translations/packages/cli" + }, + "keywords": [ + "backstage" + ], + "license": "Apache-2.0", + "main": "dist/index.cjs.js", + "scripts": { + "build": "backstage-cli package build", + "lint": "backstage-cli package lint", + "test": "backstage-cli package test", + "clean": "backstage-cli package clean", + "start": "nodemon --", + "dev": "ts-node src/index.ts", + "dev:help": "ts-node src/index.ts --help", + "test:local": "npm run build && node bin/translations-cli", + "test:watch": "vitest" + }, + "bin": "bin/translations-cli", + "files": [ + "bin", + "dist/**/*.js" + ], + "engines": { + "node": ">=20" + }, + "dependencies": { + "@backstage/cli": "^0.34.4", + "axios": "^1.9.0", + "chalk": "^4.0.0", + "commander": "^9.1.0", + "fs-extra": "^10.1.0", + "glob": "^8.0.0" + }, + "nodemonConfig": { + "watch": "./src", + "exec": "bin/translations-cli", + "ext": "ts" + }, + "devDependencies": { + "@types/fs-extra": "^9.0.13", + "@types/glob": "^8.0.0", + "@types/node": "^18.19.34", + "chalk": "^4.1.2", + "commander": "^12.0.0", + "ts-node": "^10.9.2", + "vitest": "^1.0.0" + } +} diff --git a/workspaces/translations/packages/cli/src/commands/clean.ts b/workspaces/translations/packages/cli/src/commands/clean.ts new file mode 100644 index 0000000000..b758f84ed5 --- /dev/null +++ b/workspaces/translations/packages/cli/src/commands/clean.ts @@ -0,0 +1,191 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import path from 'path'; + +import chalk from 'chalk'; +import { OptionValues } from 'commander'; +import fs from 'fs-extra'; + +interface CleanupTask { + name: string; + path: string; + files: string[]; +} + +/** + * Find temporary files in i18n directory + */ +async function findI18nTempFiles(i18nDir: string): Promise { + if (!(await fs.pathExists(i18nDir))) { + return []; + } + + const files = await fs.readdir(i18nDir); + return files.filter( + file => + file.startsWith('.') || file.endsWith('.tmp') || file.endsWith('.cache'), + ); +} + +/** + * Collect all cleanup tasks from specified directories + */ +async function collectCleanupTasks( + i18nDir: string, + cacheDir: string, + backupDir: string, +): Promise { + const cleanupTasks: CleanupTask[] = []; + + const i18nTempFiles = await findI18nTempFiles(i18nDir); + if (i18nTempFiles.length > 0) { + cleanupTasks.push({ + name: 'i18n directory', + path: i18nDir, + files: i18nTempFiles, + }); + } + + if (await fs.pathExists(cacheDir)) { + cleanupTasks.push({ + name: 'cache directory', + path: cacheDir, + files: await fs.readdir(cacheDir), + }); + } + + if (await fs.pathExists(backupDir)) { + cleanupTasks.push({ + name: 'backup directory', + path: backupDir, + files: await fs.readdir(backupDir), + }); + } + + return cleanupTasks; +} + +/** + * Display what will be cleaned + */ +function displayCleanupPreview(cleanupTasks: CleanupTask[]): void { + console.log(chalk.yellow('๐Ÿ“‹ Files to be cleaned:')); + for (const task of cleanupTasks) { + console.log(chalk.gray(` ${task.name}: ${task.files.length} files`)); + for (const file of task.files) { + console.log(chalk.gray(` - ${file}`)); + } + } +} + +/** + * Perform the actual cleanup of files + */ +async function performCleanup(cleanupTasks: CleanupTask[]): Promise { + let totalCleaned = 0; + + for (const task of cleanupTasks) { + console.log(chalk.yellow(`๐Ÿงน Cleaning ${task.name}...`)); + + for (const file of task.files) { + const filePath = path.join(task.path, file); + try { + await fs.remove(filePath); + totalCleaned++; + } catch (error) { + console.warn( + chalk.yellow(`โš ๏ธ Could not remove ${filePath}: ${error}`), + ); + } + } + } + + return totalCleaned; +} + +/** + * Remove empty directories after cleanup + */ +async function removeEmptyDirectories( + cleanupTasks: CleanupTask[], +): Promise { + for (const task of cleanupTasks) { + const remainingFiles = await fs.readdir(task.path).catch(() => []); + if (remainingFiles.length === 0) { + try { + await fs.remove(task.path); + console.log(chalk.gray(` Removed empty directory: ${task.path}`)); + } catch { + // Directory might not be empty or might have subdirectories - ignore silently + // This is expected behavior when directory removal fails + } + } + } +} + +/** + * Display cleanup summary + */ +function displaySummary( + totalCleaned: number, + directoriesProcessed: number, +): void { + console.log(chalk.green(`โœ… Cleanup completed successfully!`)); + console.log(chalk.gray(` Files cleaned: ${totalCleaned}`)); + console.log(chalk.gray(` Directories processed: ${directoriesProcessed}`)); +} + +export async function cleanCommand(opts: OptionValues): Promise { + console.log(chalk.blue('๐Ÿงน Cleaning up temporary i18n files and caches...')); + + const { + i18nDir = 'i18n', + cacheDir = '.i18n-cache', + backupDir = '.i18n-backup', + force = false, + } = opts; + + try { + const cleanupTasks = await collectCleanupTasks( + i18nDir, + cacheDir, + backupDir, + ); + + if (cleanupTasks.length === 0) { + console.log(chalk.yellow('โœจ No temporary files found to clean')); + return; + } + + displayCleanupPreview(cleanupTasks); + + if (!force) { + console.log( + chalk.yellow('โš ๏ธ This will permanently delete the above files.'), + ); + console.log(chalk.yellow(' Use --force to skip this confirmation.')); + return; + } + + const totalCleaned = await performCleanup(cleanupTasks); + await removeEmptyDirectories(cleanupTasks); + displaySummary(totalCleaned, cleanupTasks.length); + } catch (error) { + console.error(chalk.red('โŒ Error during cleanup:'), error); + throw error; + } +} diff --git a/workspaces/translations/packages/cli/src/commands/deploy.ts b/workspaces/translations/packages/cli/src/commands/deploy.ts new file mode 100644 index 0000000000..81e462edb4 --- /dev/null +++ b/workspaces/translations/packages/cli/src/commands/deploy.ts @@ -0,0 +1,260 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import path from 'path'; + +import { OptionValues } from 'commander'; +import chalk from 'chalk'; +import fs from 'fs-extra'; + +import { loadTranslationFile } from '../lib/i18n/loadFile'; +import { validateTranslationData } from '../lib/i18n/validateData'; +import { deployTranslationFiles } from '../lib/i18n/deployFiles'; +import { loadI18nConfig, mergeConfigWithOptions } from '../lib/i18n/config'; + +interface DeployResult { + language: string; + sourcePath: string; + targetPath: string; + keyCount: number; +} + +/** + * Find and filter translation files based on language requirements + */ +async function findTranslationFiles( + sourceDir: string, + format: string, + languages?: string, +): Promise { + const translationFiles = await fs.readdir(sourceDir); + const languageFiles = translationFiles.filter( + file => file.endsWith(`.${format}`) && !file.startsWith('reference.'), + ); + + if (!languages) { + return languageFiles; + } + + const targetLanguages = languages + .split(',') + .map((lang: string) => lang.trim()); + return languageFiles.filter(file => { + const language = file.replace(`.${format}`, ''); + return targetLanguages.includes(language); + }); +} + +/** + * Create backup of existing translation files + */ +async function createBackup(targetDir: string, format: string): Promise { + const backupDir = path.join( + targetDir, + '.backup', + new Date().toISOString().replace(/[:.]/g, '-'), + ); + await fs.ensureDir(backupDir); + console.log(chalk.yellow(`๐Ÿ’พ Creating backup in ${backupDir}...`)); + + const existingFiles = await fs.readdir(targetDir).catch(() => []); + for (const file of existingFiles) { + if (file.endsWith(`.${format}`)) { + await fs.copy(path.join(targetDir, file), path.join(backupDir, file)); + } + } +} + +/** + * Validate translation data if validation is enabled + */ +async function validateTranslations( + translationData: Record, + language: string, + validate: boolean, +): Promise { + if (!validate) { + return; + } + + console.log(chalk.yellow(`๐Ÿ” Validating ${language} translations...`)); + const validationResult = await validateTranslationData( + translationData, + language, + ); + + if (!validationResult.isValid) { + console.warn(chalk.yellow(`โš ๏ธ Validation warnings for ${language}:`)); + for (const warning of validationResult.warnings) { + console.warn(chalk.gray(` ${warning}`)); + } + } +} + +/** + * Process a single translation file + */ +async function processTranslationFile( + fileName: string, + sourceDir: string, + targetDir: string, + format: string, + validate: boolean, +): Promise { + const language = fileName.replace(`.${format}`, ''); + const sourcePath = path.join(sourceDir, fileName); + const targetPath = path.join(targetDir, fileName); + + console.log(chalk.yellow(`๐Ÿ”„ Processing ${language}...`)); + + const translationData = await loadTranslationFile(sourcePath, format); + + if (!translationData || Object.keys(translationData).length === 0) { + console.log(chalk.yellow(`โš ๏ธ No translation data found in ${fileName}`)); + throw new Error(`No translation data in ${fileName}`); + } + + await validateTranslations(translationData, language, validate); + await deployTranslationFiles(translationData, targetPath, format); + + const keyCount = Object.keys(translationData).length; + console.log(chalk.green(`โœ… Deployed ${language}: ${keyCount} keys`)); + + return { + language, + sourcePath, + targetPath, + keyCount, + }; +} + +/** + * Display deployment summary + */ +function displaySummary( + deployResults: DeployResult[], + targetDir: string, + backup: boolean, +): void { + console.log(chalk.green(`โœ… Deployment completed successfully!`)); + console.log(chalk.gray(` Target directory: ${targetDir}`)); + console.log(chalk.gray(` Files deployed: ${deployResults.length}`)); + + if (deployResults.length > 0) { + console.log(chalk.blue('๐Ÿ“ Deployed files:')); + for (const result of deployResults) { + console.log( + chalk.gray( + ` ${result.language}: ${result.targetPath} (${result.keyCount} keys)`, + ), + ); + } + } + + if (backup) { + console.log( + chalk.blue(`๐Ÿ’พ Backup created: ${path.join(targetDir, '.backup')}`), + ); + } +} + +export async function deployCommand(opts: OptionValues): Promise { + console.log( + chalk.blue( + '๐Ÿš€ Deploying translated strings to application language files...', + ), + ); + + const config = await loadI18nConfig(); + const mergedOpts = await mergeConfigWithOptions(config, opts); + + const { + sourceDir = 'i18n', + targetDir = 'src/locales', + languages, + format = 'json', + backup = true, + validate = true, + } = mergedOpts as { + sourceDir?: string; + targetDir?: string; + languages?: string; + format?: string; + backup?: boolean; + validate?: boolean; + }; + + try { + const sourceDirStr = String(sourceDir || 'i18n'); + const targetDirStr = String(targetDir || 'src/locales'); + const formatStr = String(format || 'json'); + const languagesStr = + languages && typeof languages === 'string' ? languages : undefined; + + if (!(await fs.pathExists(sourceDirStr))) { + throw new Error(`Source directory not found: ${sourceDirStr}`); + } + + await fs.ensureDir(targetDirStr); + + const filesToProcess = await findTranslationFiles( + sourceDirStr, + formatStr, + languagesStr, + ); + + if (filesToProcess.length === 0) { + console.log( + chalk.yellow(`โš ๏ธ No translation files found in ${sourceDirStr}`), + ); + return; + } + + console.log( + chalk.yellow( + `๐Ÿ“ Found ${filesToProcess.length} translation files to deploy`, + ), + ); + + if (backup) { + await createBackup(targetDirStr, formatStr); + } + + const deployResults: DeployResult[] = []; + + for (const fileName of filesToProcess) { + try { + const result = await processTranslationFile( + fileName, + sourceDirStr, + targetDirStr, + formatStr, + Boolean(validate), + ); + deployResults.push(result); + } catch (error) { + const language = fileName.replace(`.${formatStr}`, ''); + console.error(chalk.red(`โŒ Error processing ${language}:`), error); + throw error; + } + } + + displaySummary(deployResults, targetDirStr, Boolean(backup)); + } catch (error) { + console.error(chalk.red('โŒ Error deploying translations:'), error); + throw error; + } +} diff --git a/workspaces/translations/packages/cli/src/commands/download.ts b/workspaces/translations/packages/cli/src/commands/download.ts new file mode 100644 index 0000000000..70ad019b6c --- /dev/null +++ b/workspaces/translations/packages/cli/src/commands/download.ts @@ -0,0 +1,230 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import path from 'path'; + +import { OptionValues } from 'commander'; +import chalk from 'chalk'; +import fs from 'fs-extra'; + +import { TMSClient } from '../lib/i18n/tmsClient'; +import { saveTranslationFile } from '../lib/i18n/saveFile'; +import { loadI18nConfig, mergeConfigWithOptions } from '../lib/i18n/config'; + +export async function downloadCommand(opts: OptionValues): Promise { + console.log(chalk.blue('๐Ÿ“ฅ Downloading translated strings from TMS...')); + + // Load config and merge with options + const config = await loadI18nConfig(); + // mergeConfigWithOptions is async (may generate token), so we await it + const mergedOpts = await mergeConfigWithOptions(config, opts); + + const { + tmsUrl, + tmsToken, + projectId, + outputDir = 'i18n', + languages, + format = 'json', + includeCompleted = true, + includeDraft = false, + } = mergedOpts as { + tmsUrl?: string; + tmsToken?: string; + projectId?: string; + outputDir?: string; + languages?: string; + format?: string; + includeCompleted?: boolean; + includeDraft?: boolean; + }; + + // Validate required options + if (!tmsUrl || !tmsToken || !projectId) { + console.error(chalk.red('โŒ Missing required TMS configuration:')); + console.error(''); + + if (!tmsUrl) { + console.error(chalk.yellow(' โœ— TMS URL')); + console.error( + chalk.gray( + ' Set via: --tms-url or I18N_TMS_URL or .i18n.config.json', + ), + ); + } + if (!tmsToken) { + console.error(chalk.yellow(' โœ— TMS Token')); + console.error( + chalk.gray( + ' Primary: Source ~/.memsourcerc (sets MEMSOURCE_TOKEN)', + ), + ); + console.error( + chalk.gray( + ' Fallback: --tms-token or I18N_TMS_TOKEN or ~/.i18n.auth.json', + ), + ); + } + if (!projectId) { + console.error(chalk.yellow(' โœ— Project ID')); + console.error( + chalk.gray( + ' Set via: --project-id or I18N_TMS_PROJECT_ID or .i18n.config.json', + ), + ); + } + + console.error(''); + console.error(chalk.blue('๐Ÿ“‹ Quick Setup Guide:')); + console.error(chalk.gray(' 1. Run: translations-cli i18n init')); + console.error(chalk.gray(' This creates .i18n.config.json')); + console.error(''); + console.error( + chalk.gray(' 2. Edit .i18n.config.json in your project root:'), + ); + console.error( + chalk.gray( + ' - Add your TMS URL (e.g., "https://cloud.memsource.com/web")', + ), + ); + console.error(chalk.gray(' - Add your Project ID')); + console.error(''); + console.error( + chalk.gray(' 3. Set up Memsource authentication (recommended):'), + ); + console.error( + chalk.gray(' - Run: translations-cli i18n setup-memsource'), + ); + console.error( + chalk.gray( + ' - Or manually create ~/.memsourcerc following localization team instructions', + ), + ); + console.error(chalk.gray(' - Then source it: source ~/.memsourcerc')); + console.error(''); + console.error( + chalk.gray( + ' OR use ~/.i18n.auth.json as fallback (run init to create it)', + ), + ); + console.error(''); + console.error( + chalk.gray(' See docs/i18n-commands.md for detailed instructions.'), + ); + process.exit(1); + } + + try { + // Ensure output directory exists + await fs.ensureDir(outputDir); + + // Initialize TMS client + console.log(chalk.yellow(`๐Ÿ”— Connecting to TMS at ${tmsUrl}...`)); + const tmsClient = new TMSClient(tmsUrl, tmsToken); + + // Test connection + await tmsClient.testConnection(); + console.log(chalk.green(`โœ… Connected to TMS successfully`)); + + // Get project information + console.log(chalk.yellow(`๐Ÿ“‹ Getting project information...`)); + const projectInfo = await tmsClient.getProjectInfo(projectId); + console.log(chalk.gray(` Project: ${projectInfo.name}`)); + console.log( + chalk.gray(` Languages: ${projectInfo.languages.join(', ')}`), + ); + + // Parse target languages + const targetLanguages = + languages && typeof languages === 'string' + ? languages.split(',').map((lang: string) => lang.trim()) + : projectInfo.languages; + + // Download translations for each language + const downloadResults = []; + + for (const language of targetLanguages) { + console.log( + chalk.yellow(`๐Ÿ“ฅ Downloading translations for ${language}...`), + ); + + try { + const translationData = await tmsClient.downloadTranslations( + projectId, + language, + { + includeCompleted: Boolean(includeCompleted), + includeDraft: Boolean(includeDraft), + format: String(format || 'json'), + }, + ); + + if (translationData && Object.keys(translationData).length > 0) { + // Save translation file + const fileName = `${language}.${String(format || 'json')}`; + const filePath = path.join(String(outputDir || 'i18n'), fileName); + + await saveTranslationFile( + translationData, + filePath, + String(format || 'json'), + ); + + downloadResults.push({ + language, + filePath, + keyCount: Object.keys(translationData).length, + }); + + console.log( + chalk.green( + `โœ… Downloaded ${language}: ${ + Object.keys(translationData).length + } keys`, + ), + ); + } else { + console.log( + chalk.yellow(`โš ๏ธ No translations found for ${language}`), + ); + } + } catch (error) { + console.warn( + chalk.yellow(`โš ๏ธ Warning: Could not download ${language}: ${error}`), + ); + } + } + + // Summary + console.log(chalk.green(`โœ… Download completed successfully!`)); + console.log(chalk.gray(` Output directory: ${outputDir}`)); + console.log(chalk.gray(` Files downloaded: ${downloadResults.length}`)); + + if (downloadResults.length > 0) { + console.log(chalk.blue('๐Ÿ“ Downloaded files:')); + for (const result of downloadResults) { + console.log( + chalk.gray( + ` ${result.language}: ${result.filePath} (${result.keyCount} keys)`, + ), + ); + } + } + } catch (error) { + console.error(chalk.red('โŒ Error downloading from TMS:'), error); + throw error; + } +} diff --git a/workspaces/translations/packages/cli/src/commands/generate.ts b/workspaces/translations/packages/cli/src/commands/generate.ts new file mode 100644 index 0000000000..3ff8e626f9 --- /dev/null +++ b/workspaces/translations/packages/cli/src/commands/generate.ts @@ -0,0 +1,422 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import path from 'path'; + +import { OptionValues } from 'commander'; +import chalk from 'chalk'; +import fs from 'fs-extra'; +import glob from 'glob'; + +import { extractTranslationKeys } from '../lib/i18n/extractKeys'; +import { generateTranslationFiles } from '../lib/i18n/generateFiles'; +import { mergeTranslationFiles } from '../lib/i18n/mergeFiles'; +import { loadI18nConfig, mergeConfigWithOptions } from '../lib/i18n/config'; + +// Helper to check if data is in nested structure +function isNestedStructure( + data: unknown, +): data is Record }> { + if (typeof data !== 'object' || data === null) return false; + const firstKey = Object.keys(data)[0]; + if (!firstKey) return false; + const firstValue = (data as Record)[firstKey]; + return ( + typeof firstValue === 'object' && firstValue !== null && 'en' in firstValue + ); +} + +export async function generateCommand(opts: OptionValues): Promise { + console.log(chalk.blue('๐ŸŒ Generating translation reference files...')); + + // Load config and merge with options + const config = await loadI18nConfig(); + // mergeConfigWithOptions is async (may generate token), so we await it + const mergedOpts = await mergeConfigWithOptions(config, opts); + + const { + sourceDir = 'src', + outputDir = 'i18n', + format = 'json', + includePattern = '**/*.{ts,tsx,js,jsx}', + excludePattern = '**/node_modules/**', + extractKeys = true, + mergeExisting = false, + } = mergedOpts as { + sourceDir?: string; + outputDir?: string; + format?: string; + includePattern?: string; + excludePattern?: string; + extractKeys?: boolean; + mergeExisting?: boolean; + }; + + try { + // Ensure output directory exists + await fs.ensureDir(outputDir); + + // Can be either flat structure (legacy) or nested structure (new) + const translationKeys: + | Record + | Record }> = {}; + + if (extractKeys) { + console.log( + chalk.yellow(`๐Ÿ“ Scanning ${sourceDir} for translation keys...`), + ); + + // Find all source files matching the pattern + const allSourceFiles = glob.sync( + String(includePattern || '**/*.{ts,tsx,js,jsx}'), + { + cwd: String(sourceDir || 'src'), + ignore: String(excludePattern || '**/node_modules/**'), + absolute: true, + }, + ); + + // Filter to only English reference files: + // 1. Files with createTranslationRef (defines new translation keys) + // 2. Files with createTranslationMessages that are English (overrides/extends existing keys) + // 3. Files with createTranslationResource (sets up translation resources - may contain keys) + // - Exclude language files (de.ts, es.ts, fr.ts, it.ts, etc.) + const sourceFiles: string[] = []; + const languageCodes = [ + 'de', + 'es', + 'fr', + 'it', + 'ja', + 'ko', + 'pt', + 'zh', + 'ru', + 'ar', + 'hi', + 'nl', + 'pl', + 'sv', + 'tr', + 'uk', + 'vi', + ]; + + for (const filePath of allSourceFiles) { + try { + const content = await fs.readFile(filePath, 'utf-8'); + const fileName = path.basename(filePath, path.extname(filePath)); + + // Check if it's a language file: + // 1. Filename is exactly a language code (e.g., "es.ts", "fr.ts") + // 2. Filename ends with language code (e.g., "something-es.ts", "something-fr.ts") + // 3. Filename contains language code with separators (e.g., "something.de.ts") + // Exclude if it's explicitly English (e.g., "something-en.ts", "en.ts") + const isLanguageFile = + languageCodes.some(code => { + if (fileName === code) return true; // Exact match: "es.ts" + if (fileName.endsWith(`-${code}`)) return true; // Ends with: "something-es.ts" + if ( + fileName.includes(`.${code}.`) || + fileName.includes(`-${code}-`) + ) + return true; // Contains: "something.de.ts" + return false; + }) && + !fileName.includes('-en') && + fileName !== 'en'; + + // Check if file contains createTranslationRef import (defines new translation keys) + const hasCreateTranslationRef = + content.includes('createTranslationRef') && + (content.includes("from '@backstage/core-plugin-api/alpha'") || + content.includes("from '@backstage/frontend-plugin-api'")); + + // Check if file contains createTranslationMessages (overrides/extends existing keys) + // Only include if it's an English file (not a language file) + const hasCreateTranslationMessages = + content.includes('createTranslationMessages') && + (content.includes("from '@backstage/core-plugin-api/alpha'") || + content.includes("from '@backstage/frontend-plugin-api'")) && + !isLanguageFile; + + // Check if file contains createTranslationResource (sets up translation resources) + // Only include if it's an English file (not a language file) + const hasCreateTranslationResource = + content.includes('createTranslationResource') && + (content.includes("from '@backstage/core-plugin-api/alpha'") || + content.includes("from '@backstage/frontend-plugin-api'")) && + !isLanguageFile; + + if ( + hasCreateTranslationRef || + hasCreateTranslationMessages || + hasCreateTranslationResource + ) { + sourceFiles.push(filePath); + } + } catch { + // Skip files that can't be read + continue; + } + } + + console.log( + chalk.gray( + `Found ${allSourceFiles.length} files, ${sourceFiles.length} are English reference files`, + ), + ); + + // Structure: { pluginName: { en: { key: value } } } + const pluginGroups: Record> = {}; + + // Extract translation keys from each reference file and group by plugin + for (const filePath of sourceFiles) { + try { + const content = await fs.readFile(filePath, 'utf-8'); + const keys = extractTranslationKeys(content, filePath); + + // Detect plugin name from file path + let pluginName: string | null = null; + + // Pattern 1: workspaces/{workspace}/plugins/{plugin}/... + const workspaceMatch = filePath.match( + /workspaces\/([^/]+)\/plugins\/([^/]+)/, + ); + if (workspaceMatch) { + // Use plugin name (not workspace.plugin) + pluginName = workspaceMatch[2]; + } else { + // Pattern 2: .../translations/{plugin}/ref.ts or .../translations/{plugin}/translation.ts + // Look for a folder named "translations" and use the next folder as plugin name + const translationsMatch = filePath.match(/translations\/([^/]+)\//); + if (translationsMatch) { + pluginName = translationsMatch[1]; + } else { + // Pattern 3: Fallback - use parent directory name if file is in a translations folder + const dirName = path.dirname(filePath); + const parentDir = path.basename(dirName); + if ( + parentDir === 'translations' || + parentDir.includes('translation') + ) { + const grandParentDir = path.basename(path.dirname(dirName)); + pluginName = grandParentDir; + } else { + // Last resort: use the directory containing the file + pluginName = parentDir; + } + } + } + + if (!pluginName) { + console.warn( + chalk.yellow( + `โš ๏ธ Warning: Could not determine plugin name for ${path.relative( + process.cwd(), + filePath, + )}, skipping`, + ), + ); + continue; + } + + // Filter out invalid plugin names (common directory names that shouldn't be plugins) + const invalidPluginNames = [ + 'dist', + 'build', + 'node_modules', + 'packages', + 'src', + 'lib', + 'components', + 'utils', + ]; + if (invalidPluginNames.includes(pluginName.toLowerCase())) { + console.warn( + chalk.yellow( + `โš ๏ธ Warning: Skipping invalid plugin name "${pluginName}" from ${path.relative( + process.cwd(), + filePath, + )}`, + ), + ); + continue; + } + + // Initialize plugin group if it doesn't exist + if (!pluginGroups[pluginName]) { + pluginGroups[pluginName] = {}; + } + + // Merge keys into plugin group (warn about overwrites) + const overwrittenKeys: string[] = []; + for (const [key, value] of Object.entries(keys)) { + if ( + pluginGroups[pluginName][key] && + pluginGroups[pluginName][key] !== value + ) { + overwrittenKeys.push(key); + } + pluginGroups[pluginName][key] = value; + } + + if (overwrittenKeys.length > 0) { + console.warn( + chalk.yellow( + `โš ๏ธ Warning: ${ + overwrittenKeys.length + } keys were overwritten in plugin "${pluginName}" from ${path.relative( + process.cwd(), + filePath, + )}`, + ), + ); + } + } catch (error) { + console.warn( + chalk.yellow( + `โš ๏ธ Warning: Could not process ${filePath}: ${error}`, + ), + ); + } + } + + // Convert plugin groups to the final structure: { plugin: { en: { keys } } } + const structuredData: Record }> = {}; + for (const [pluginName, keys] of Object.entries(pluginGroups)) { + structuredData[pluginName] = { en: keys }; + } + + const totalKeys = Object.values(pluginGroups).reduce( + (sum, keys) => sum + Object.keys(keys).length, + 0, + ); + console.log( + chalk.green( + `โœ… Extracted ${totalKeys} translation keys from ${ + Object.keys(pluginGroups).length + } plugins`, + ), + ); + + // Store structured data in translationKeys (will be passed to generateTranslationFiles) + Object.assign(translationKeys, structuredData); + } + + // Generate translation files + const formatStr = String(format || 'json'); + const outputPath = path.join( + String(outputDir || 'i18n'), + `reference.${formatStr}`, + ); + + if (mergeExisting && (await fs.pathExists(outputPath))) { + console.log(chalk.yellow(`๐Ÿ”„ Merging with existing ${outputPath}...`)); + // mergeTranslationFiles now accepts both structures + await mergeTranslationFiles( + translationKeys as + | Record + | Record }>, + outputPath, + formatStr, + ); + } else { + console.log(chalk.yellow(`๐Ÿ“ Generating ${outputPath}...`)); + await generateTranslationFiles(translationKeys, outputPath, formatStr); + } + + // Validate the generated file + if (formatStr === 'json') { + console.log(chalk.yellow(`๐Ÿ” Validating generated file...`)); + const { validateTranslationFile } = await import( + '../lib/i18n/validateFile' + ); + const isValid = await validateTranslationFile(outputPath); + if (!isValid) { + throw new Error(`Generated file failed validation: ${outputPath}`); + } + console.log(chalk.green(`โœ… Generated file is valid`)); + } + + // Print summary of included plugins + if (extractKeys && isNestedStructure(translationKeys)) { + console.log(chalk.blue('\n๐Ÿ“‹ Included Plugins Summary:')); + console.log(chalk.gray('โ”€'.repeat(60))); + + const plugins = Object.entries( + translationKeys as Record }>, + ) + .map(([pluginName, pluginData]) => ({ + name: pluginName, + keyCount: Object.keys(pluginData.en || {}).length, + })) + .sort((a, b) => a.name.localeCompare(b.name)); + + let totalKeys = 0; + for (const plugin of plugins) { + const keyLabel = plugin.keyCount === 1 ? 'key' : 'keys'; + console.log( + chalk.cyan( + ` โ€ข ${plugin.name.padEnd(35)} ${chalk.yellow( + plugin.keyCount.toString().padStart(4), + )} ${keyLabel}`, + ), + ); + totalKeys += plugin.keyCount; + } + + console.log(chalk.gray('โ”€'.repeat(60))); + const pluginLabel = plugins.length === 1 ? 'plugin' : 'plugins'; + const totalKeyLabel = totalKeys === 1 ? 'key' : 'keys'; + console.log( + chalk.cyan( + ` Total: ${chalk.yellow( + plugins.length.toString(), + )} ${pluginLabel}, ${chalk.yellow( + totalKeys.toString(), + )} ${totalKeyLabel}`, + ), + ); + console.log(''); + } + + console.log( + chalk.green(`โœ… Translation reference files generated successfully!`), + ); + console.log(chalk.gray(` Output: ${outputPath}`)); + + if (extractKeys && isNestedStructure(translationKeys)) { + const totalKeys = Object.values( + translationKeys as Record }>, + ).reduce( + (sum, pluginData) => sum + Object.keys(pluginData.en || {}).length, + 0, + ); + console.log( + chalk.gray(` Plugins: ${Object.keys(translationKeys).length}`), + ); + console.log(chalk.gray(` Keys: ${totalKeys}`)); + } else { + console.log( + chalk.gray(` Keys: ${Object.keys(translationKeys).length}`), + ); + } + } catch (error) { + console.error(chalk.red('โŒ Error generating translation files:'), error); + throw error; + } +} diff --git a/workspaces/translations/packages/cli/src/commands/index.ts b/workspaces/translations/packages/cli/src/commands/index.ts new file mode 100644 index 0000000000..1a78db8324 --- /dev/null +++ b/workspaces/translations/packages/cli/src/commands/index.ts @@ -0,0 +1,245 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Command, OptionValues } from 'commander'; + +import { exitWithError } from '../lib/errors'; + +import { generateCommand } from './generate'; +import { uploadCommand } from './upload'; +import { downloadCommand } from './download'; +import { deployCommand } from './deploy'; +import { statusCommand } from './status'; +import { cleanCommand } from './clean'; +import { syncCommand } from './sync'; +import { initCommand } from './init'; +import { setupMemsourceCommand } from './setupMemsource'; + +export function registerCommands(program: Command) { + const command = program + .command('i18n [command]') + .description( + 'Internationalization (i18n) management commands for translation workflows', + ); + + // Generate command - collect translation reference files + command + .command('generate') + .description('Generate translation reference files from source code') + .option( + '--source-dir ', + 'Source directory to scan for translatable strings', + 'src', + ) + .option( + '--output-dir ', + 'Output directory for generated translation files', + 'i18n', + ) + .option('--format ', 'Output format (json, po)', 'json') + .option( + '--include-pattern ', + 'File pattern to include (glob)', + '**/*.{ts,tsx,js,jsx}', + ) + .option( + '--exclude-pattern ', + 'File pattern to exclude (glob)', + '**/node_modules/**', + ) + .option('--extract-keys', 'Extract translation keys from source code', true) + .option('--merge-existing', 'Merge with existing translation files', false) + .action(wrapCommand(generateCommand)); + + // Upload command - upload translation reference files to TMS + command + .command('upload') + .description( + 'Upload translation reference files to TMS (Translation Management System)', + ) + .option('--tms-url ', 'TMS API URL') + .option('--tms-token ', 'TMS API token') + .option('--project-id ', 'TMS project ID') + .option('--source-file ', 'Source translation file to upload') + .option( + '--upload-filename ', + 'Custom filename for TMS upload (default: {repo-name}-reference-{date}.json)', + ) + .option( + '--target-languages ', + 'Comma-separated list of target languages', + ) + .option( + '--dry-run', + 'Show what would be uploaded without actually uploading', + false, + ) + .option('--force', 'Force upload even if file has not changed', false) + .action(wrapCommand(uploadCommand)); + + // Download command - download translated strings from TMS + command + .command('download') + .description('Download translated strings from TMS') + .option('--tms-url ', 'TMS API URL') + .option('--tms-token ', 'TMS API token') + .option('--project-id ', 'TMS project ID') + .option( + '--output-dir ', + 'Output directory for downloaded translations', + 'i18n', + ) + .option( + '--languages ', + 'Comma-separated list of languages to download', + ) + .option('--format ', 'Download format (json, po)', 'json') + .option('--include-completed', 'Include completed translations only', true) + .option('--include-draft', 'Include draft translations', false) + .action(wrapCommand(downloadCommand)); + + // Deploy command - deploy translated strings back to language files + command + .command('deploy') + .description( + 'Deploy translated strings back to the application language files', + ) + .option( + '--source-dir ', + 'Source directory containing downloaded translations', + 'i18n', + ) + .option( + '--target-dir ', + 'Target directory for language files', + 'src/locales', + ) + .option( + '--languages ', + 'Comma-separated list of languages to deploy', + ) + .option('--format ', 'Input format (json, po)', 'json') + .option('--backup', 'Create backup of existing language files', true) + .option('--validate', 'Validate translations before deploying', true) + .action(wrapCommand(deployCommand)); + + // Status command - show translation status + command + .command('status') + .description('Show translation status and statistics') + .option('--source-dir ', 'Source directory to analyze', 'src') + .option('--i18n-dir ', 'i18n directory to analyze', 'i18n') + .option( + '--locales-dir ', + 'Locales directory to analyze', + 'src/locales', + ) + .option('--format ', 'Output format (table, json)', 'table') + .option('--include-stats', 'Include detailed statistics', true) + .action(wrapCommand(statusCommand)); + + // Clean command - clean up temporary files + command + .command('clean') + .description('Clean up temporary i18n files and caches') + .option('--i18n-dir ', 'i18n directory to clean', 'i18n') + .option('--cache-dir ', 'Cache directory to clean', '.i18n-cache') + .option('--backup-dir ', 'Backup directory to clean', '.i18n-backup') + .option('--force', 'Force cleanup without confirmation', false) + .action(wrapCommand(cleanCommand)); + + // Sync command - all-in-one workflow + command + .command('sync') + .description( + 'Complete i18n workflow: generate โ†’ upload โ†’ download โ†’ deploy', + ) + .option('--source-dir ', 'Source directory to scan', 'src') + .option('--output-dir ', 'Output directory for i18n files', 'i18n') + .option('--locales-dir ', 'Target locales directory', 'src/locales') + .option('--tms-url ', 'TMS API URL') + .option('--tms-token ', 'TMS API token') + .option('--project-id ', 'TMS project ID') + .option( + '--languages ', + 'Comma-separated list of target languages', + ) + .option('--skip-upload', 'Skip upload step', false) + .option('--skip-download', 'Skip download step', false) + .option('--skip-deploy', 'Skip deploy step', false) + .option('--dry-run', 'Show what would be done without executing', false) + .action(wrapCommand(syncCommand)); + + // Init command - initialize config file + command + .command('init') + .description('Initialize i18n configuration files') + .option( + '--setup-memsource', + 'Also set up .memsourcerc file for Memsource CLI', + false, + ) + .option( + '--memsource-venv ', + 'Path to Memsource CLI virtual environment', + '${HOME}/git/memsource-cli-client/.memsource/bin/activate', + ) + .action(wrapCommand(initCommand)); + + // Setup command - set up Memsource configuration + command + .command('setup-memsource') + .description( + 'Set up .memsourcerc file for Memsource CLI (follows localization team instructions)', + ) + .option( + '--memsource-venv ', + 'Path to Memsource CLI virtual environment', + '${HOME}/git/memsource-cli-client/.memsource/bin/activate', + ) + .option( + '--memsource-url ', + 'Memsource URL', + 'https://cloud.memsource.com/web', + ) + .option( + '--username ', + 'Memsource username (will prompt if not provided and in interactive terminal)', + ) + .option( + '--password ', + 'Memsource password (will prompt if not provided and in interactive terminal)', + ) + .option( + '--no-input', + 'Disable interactive prompts (for automation/scripts)', + ) + .action(wrapCommand(setupMemsourceCommand)); +} + +// Wraps an action function so that it always exits and handles errors +function wrapCommand( + actionFunc: (opts: OptionValues) => Promise, +): (opts: OptionValues) => Promise { + return async (opts: OptionValues) => { + try { + await actionFunc(opts); + process.exit(0); + } catch (error) { + exitWithError(error as Error); + } + }; +} diff --git a/workspaces/translations/packages/cli/src/commands/init.ts b/workspaces/translations/packages/cli/src/commands/init.ts new file mode 100644 index 0000000000..6ee14b8977 --- /dev/null +++ b/workspaces/translations/packages/cli/src/commands/init.ts @@ -0,0 +1,137 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import os from 'os'; +import path from 'path'; + +import { OptionValues } from 'commander'; +import chalk from 'chalk'; +import fs from 'fs-extra'; + +import { + createDefaultConfigFile, + createDefaultAuthFile, +} from '../lib/i18n/config'; + +import { setupMemsourceCommand } from './setupMemsource'; + +export async function initCommand(opts: OptionValues): Promise { + console.log(chalk.blue('๐Ÿ”ง Initializing i18n configuration...')); + + try { + // Create project config file (can be committed) + await createDefaultConfigFile(); + + // Check if .memsourcerc exists + const memsourceRcPath = path.join(os.homedir(), '.memsourcerc'); + const hasMemsourceRc = await fs.pathExists(memsourceRcPath); + + // Only create .i18n.auth.json if .memsourcerc doesn't exist (as fallback) + if (!hasMemsourceRc) { + await createDefaultAuthFile(); + } + + console.log(chalk.green('\nโœ… Configuration files created successfully!')); + console.log(chalk.yellow('\n๐Ÿ“ Next steps:')); + console.log(''); + console.log( + chalk.cyan(' 1. Edit .i18n.config.json in your project root:'), + ); + console.log( + chalk.gray( + ' - Add your TMS URL (e.g., "https://cloud.memsource.com/web")', + ), + ); + console.log(chalk.gray(' - Add your TMS Project ID')); + console.log( + chalk.gray( + ' - Adjust directories, languages, and patterns as needed', + ), + ); + console.log(''); + + if (hasMemsourceRc) { + console.log( + chalk.green( + ' โœ“ ~/.memsourcerc found - authentication is already configured!', + ), + ); + console.log( + chalk.gray(' Make sure to source it before running commands:'), + ); + console.log(chalk.gray(' source ~/.memsourcerc')); + console.log(''); + } else { + console.log( + chalk.cyan(' 2. Set up Memsource authentication (recommended):'), + ); + console.log( + chalk.gray(' Run: translations-cli i18n setup-memsource'), + ); + console.log( + chalk.gray( + " This creates ~/.memsourcerc following the localization team's format", + ), + ); + console.log(chalk.gray(' Then source it: source ~/.memsourcerc')); + console.log(''); + console.log(chalk.cyan(' OR use ~/.i18n.auth.json (fallback):')); + console.log(chalk.gray(' - Add your TMS username and password')); + console.log( + chalk.gray( + ' - Token can be left empty (will be generated or read from environment)', + ), + ); + console.log(''); + } + + console.log(chalk.cyan(' 3. Security reminder:')); + console.log( + chalk.gray( + ' - Never commit ~/.i18n.auth.json or ~/.memsourcerc to git', + ), + ); + console.log( + chalk.gray(' - Add them to your global .gitignore if needed'), + ); + console.log(''); + console.log( + chalk.blue('๐Ÿ’ก For detailed instructions, see: docs/i18n-commands.md'), + ); + + // Optionally set up .memsourcerc + if (opts.setupMemsource) { + console.log(chalk.blue('\n๐Ÿ”ง Setting up .memsourcerc file...')); + await setupMemsourceCommand({ + memsourceVenv: opts.memsourceVenv, + }); + } else if (!hasMemsourceRc) { + console.log( + chalk.yellow( + '\n๐Ÿ’ก Tip: Run "translations-cli i18n setup-memsource" to set up .memsourcerc file', + ), + ); + console.log( + chalk.gray( + " This follows the localization team's instructions format.", + ), + ); + } + } catch (error) { + console.error(chalk.red('โŒ Error creating config files:'), error); + throw error; + } +} diff --git a/workspaces/translations/packages/cli/src/commands/setupMemsource.ts b/workspaces/translations/packages/cli/src/commands/setupMemsource.ts new file mode 100644 index 0000000000..6ef9a1cfda --- /dev/null +++ b/workspaces/translations/packages/cli/src/commands/setupMemsource.ts @@ -0,0 +1,239 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import path from 'path'; +import os from 'os'; +import * as readline from 'readline'; +import { stdin, stdout } from 'process'; + +import { OptionValues } from 'commander'; +import chalk from 'chalk'; +import fs from 'fs-extra'; + +/** + * Set up .memsourcerc file following localization team instructions + */ +export async function setupMemsourceCommand(opts: OptionValues): Promise { + console.log( + chalk.blue('๐Ÿ”ง Setting up .memsourcerc file for Memsource CLI...'), + ); + + const { + memsourceVenv = '${HOME}/git/memsource-cli-client/.memsource/bin/activate', + memsourceUrl = 'https://cloud.memsource.com/web', + username, + password, + } = opts; + + try { + let finalUsername = username; + let finalPassword = password; + + // Check if we're in an interactive terminal (TTY) + const isInteractive = stdin.isTTY && stdout.isTTY; + const noInput = opts.noInput === true; + + // Prompt for credentials if not provided and we're in an interactive terminal + if ((!finalUsername || !finalPassword) && isInteractive && !noInput) { + const rl = readline.createInterface({ + input: stdin, + output: stdout, + }); + + const question = (query: string): Promise => { + return new Promise(resolve => { + rl.question(query, resolve); + }); + }; + + // Helper to hide password input (masks with asterisks) + const questionPassword = (query: string): Promise => { + return new Promise(resolve => { + const wasRawMode = stdin.isRaw || false; + + // Set raw mode to capture individual characters + if (stdin.isTTY) { + stdin.setRawMode(true); + } + stdin.resume(); + stdin.setEncoding('utf8'); + + stdout.write(query); + + let inputPassword = ''; + + // Declare cleanup first so it can be referenced in onData + // eslint-disable-next-line prefer-const + let cleanup: () => void; + + const onData = (char: string) => { + // Handle Enter/Return + if (char === '\r' || char === '\n') { + cleanup(); + stdout.write('\n'); + resolve(inputPassword); + return; + } + + // Handle Ctrl+C + if (char === '\u0003') { + cleanup(); + stdout.write('\n'); + process.exit(130); + return; + } + + // Handle backspace/delete + if (char === '\u007f' || char === '\b' || char === '\u001b[3~') { + if (inputPassword.length > 0) { + inputPassword = inputPassword.slice(0, -1); + stdout.write('\b \b'); + } + return; + } + + // Ignore control characters + if (char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127) { + return; + } + + // Add character and mask it + inputPassword += char; + stdout.write('*'); + }; + + cleanup = () => { + stdin.removeListener('data', onData); + if (stdin.isTTY) { + stdin.setRawMode(wasRawMode); + } + stdin.pause(); + }; + + stdin.on('data', onData); + }); + }; + + if (!finalUsername) { + finalUsername = await question( + chalk.yellow('Enter Memsource username: '), + ); + if (!finalUsername || finalUsername.trim() === '') { + rl.close(); + throw new Error('Username is required'); + } + } + + if (!finalPassword) { + finalPassword = await questionPassword( + chalk.yellow('Enter Memsource password: '), + ); + if (!finalPassword || finalPassword.trim() === '') { + rl.close(); + throw new Error('Password is required'); + } + } + + rl.close(); + } + + // Validate required credentials + if (!finalUsername || !finalPassword) { + if (!isInteractive || noInput) { + throw new Error( + 'Username and password are required. ' + + 'Provide them via --username and --password options, ' + + 'or use environment variables (MEMSOURCE_USERNAME, MEMSOURCE_PASSWORD), ' + + 'or run in an interactive terminal to be prompted.', + ); + } + throw new Error('Username and password are required'); + } + + // Keep ${HOME} in venv path (don't expand it - it should be expanded by the shell when sourced) + // The path should remain as ${HOME}/git/memsource-cli-client/.memsource/bin/activate + + // Create .memsourcerc content following localization team format + // Note: Using string concatenation to avoid template literal interpretation of ${MEMSOURCE_PASSWORD} + const memsourceRcContent = `source ${memsourceVenv} + +export MEMSOURCE_URL="${memsourceUrl}" + +export MEMSOURCE_USERNAME=${finalUsername} + +export MEMSOURCE_PASSWORD="${finalPassword}" + +export MEMSOURCE_TOKEN=$(memsource auth login --user-name $MEMSOURCE_USERNAME --password "$"MEMSOURCE_PASSWORD -c token -f value) +`.replace('$"MEMSOURCE_PASSWORD', '${MEMSOURCE_PASSWORD}'); + + // Write to ~/.memsourcerc + const memsourceRcPath = path.join(os.homedir(), '.memsourcerc'); + await fs.writeFile(memsourceRcPath, memsourceRcContent, { mode: 0o600 }); // Read/write for owner only + + console.log( + chalk.green(`โœ… Created .memsourcerc file at ${memsourceRcPath}`), + ); + console.log(chalk.yellow('\nโš ๏ธ Security Note:')); + console.log( + chalk.gray(' This file contains your password in plain text.'), + ); + console.log( + chalk.gray(' File permissions are set to 600 (owner read/write only).'), + ); + console.log( + chalk.gray( + ' Keep this file secure and never commit it to version control.', + ), + ); + + console.log(chalk.yellow('\n๐Ÿ“ Next steps:')); + console.log(chalk.gray(' 1. Source the file in your shell:')); + console.log(chalk.cyan(` source ~/.memsourcerc`)); + console.log( + chalk.gray( + ' 2. Or add it to your shell profile (~/.bashrc, ~/.zshrc, etc.):', + ), + ); + console.log(chalk.cyan(` echo "source ~/.memsourcerc" >> ~/.zshrc`)); + console.log(chalk.gray(' 3. Verify the setup:')); + console.log( + chalk.cyan(` source ~/.memsourcerc && echo $MEMSOURCE_TOKEN`), + ); + console.log( + chalk.gray( + ' 4. After sourcing, you can use i18n commands without additional setup', + ), + ); + + // Check if virtual environment path exists (expand ${HOME} for checking) + const expandedVenvPath = memsourceVenv.replace(/\$\{HOME\}/g, os.homedir()); + if (!(await fs.pathExists(expandedVenvPath))) { + console.log( + chalk.yellow( + `\nโš ๏ธ Warning: Virtual environment not found at ${expandedVenvPath}`, + ), + ); + console.log( + chalk.gray( + ' Please update the path in ~/.memsourcerc if your venv is located elsewhere.', + ), + ); + } + } catch (error) { + console.error(chalk.red('โŒ Error setting up .memsourcerc:'), error); + throw error; + } +} diff --git a/workspaces/translations/packages/cli/src/commands/status.ts b/workspaces/translations/packages/cli/src/commands/status.ts new file mode 100644 index 0000000000..78f186a988 --- /dev/null +++ b/workspaces/translations/packages/cli/src/commands/status.ts @@ -0,0 +1,56 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { OptionValues } from 'commander'; +import chalk from 'chalk'; + +import { analyzeTranslationStatus } from '../lib/i18n/analyzeStatus'; +import { formatStatusReport } from '../lib/i18n/formatReport'; + +export async function statusCommand(opts: OptionValues): Promise { + console.log(chalk.blue('๐Ÿ“Š Analyzing translation status...')); + + const { + sourceDir = 'src', + i18nDir = 'i18n', + localesDir = 'src/locales', + format = 'table', + includeStats = true, + } = opts; + + try { + // Analyze translation status + const status = await analyzeTranslationStatus({ + sourceDir, + i18nDir, + localesDir, + }); + + // Format and display report + const report = await formatStatusReport(status, format, includeStats); + console.log(report); + + // Summary + console.log(chalk.green(`โœ… Status analysis completed!`)); + console.log(chalk.gray(` Source files: ${status.sourceFiles.length}`)); + console.log(chalk.gray(` Translation keys: ${status.totalKeys}`)); + console.log(chalk.gray(` Languages: ${status.languages.length}`)); + console.log(chalk.gray(` Completion: ${status.overallCompletion}%`)); + } catch (error) { + console.error(chalk.red('โŒ Error analyzing translation status:'), error); + throw error; + } +} diff --git a/workspaces/translations/packages/cli/src/commands/sync.ts b/workspaces/translations/packages/cli/src/commands/sync.ts new file mode 100644 index 0000000000..5d25f11077 --- /dev/null +++ b/workspaces/translations/packages/cli/src/commands/sync.ts @@ -0,0 +1,265 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { OptionValues } from 'commander'; +import chalk from 'chalk'; + +import { loadI18nConfig, mergeConfigWithOptions } from '../lib/i18n/config'; + +import { generateCommand } from './generate'; +import { uploadCommand } from './upload'; +import { downloadCommand } from './download'; +import { deployCommand } from './deploy'; + +interface SyncOptions { + sourceDir: string; + outputDir: string; + localesDir: string; + tmsUrl?: string; + tmsToken?: string; + projectId?: string; + languages?: string; + skipUpload: boolean; + skipDownload: boolean; + skipDeploy: boolean; + dryRun: boolean; +} + +/** + * Check if TMS configuration is available + */ +function hasTmsConfig( + tmsUrl?: string, + tmsToken?: string, + projectId?: string, +): boolean { + return !!(tmsUrl && tmsToken && projectId); +} + +/** + * Execute step with dry run support + */ +async function executeStep( + stepName: string, + dryRun: boolean, + action: () => Promise, +): Promise { + if (dryRun) { + console.log(chalk.yellow(`๐Ÿ” Dry run: Would ${stepName}`)); + } else { + await action(); + } +} + +/** + * Step 1: Generate translation reference files + */ +async function stepGenerate( + sourceDir: string, + outputDir: string, + dryRun: boolean, +): Promise { + console.log( + chalk.blue('\n๐Ÿ“ Step 1: Generating translation reference files...'), + ); + + await executeStep('generate translation files', dryRun, async () => { + await generateCommand({ + sourceDir, + outputDir, + format: 'json', + includePattern: '**/*.{ts,tsx,js,jsx}', + excludePattern: '**/node_modules/**', + extractKeys: true, + mergeExisting: false, + }); + }); + + return 'Generate'; +} + +/** + * Step 2: Upload to TMS + */ +async function stepUpload(options: SyncOptions): Promise { + if (options.skipUpload) { + console.log(chalk.yellow('โญ๏ธ Skipping upload: --skip-upload specified')); + return null; + } + + if (!hasTmsConfig(options.tmsUrl, options.tmsToken, options.projectId)) { + console.log(chalk.yellow('โš ๏ธ Skipping upload: Missing TMS configuration')); + return null; + } + + console.log(chalk.blue('\n๐Ÿ“ค Step 2: Uploading to TMS...')); + + await executeStep('upload to TMS', options.dryRun, async () => { + await uploadCommand({ + tmsUrl: options.tmsUrl!, + tmsToken: options.tmsToken!, + projectId: options.projectId!, + sourceFile: `${options.outputDir}/reference.json`, + targetLanguages: options.languages, + dryRun: false, + }); + }); + + return 'Upload'; +} + +/** + * Step 3: Download from TMS + */ +async function stepDownload(options: SyncOptions): Promise { + if (options.skipDownload) { + console.log( + chalk.yellow('โญ๏ธ Skipping download: --skip-download specified'), + ); + return null; + } + + if (!hasTmsConfig(options.tmsUrl, options.tmsToken, options.projectId)) { + console.log( + chalk.yellow('โš ๏ธ Skipping download: Missing TMS configuration'), + ); + return null; + } + + console.log(chalk.blue('\n๐Ÿ“ฅ Step 3: Downloading from TMS...')); + + await executeStep('download from TMS', options.dryRun, async () => { + await downloadCommand({ + tmsUrl: options.tmsUrl!, + tmsToken: options.tmsToken!, + projectId: options.projectId!, + outputDir: options.outputDir, + languages: options.languages, + format: 'json', + includeCompleted: true, + includeDraft: false, + }); + }); + + return 'Download'; +} + +/** + * Step 4: Deploy to application + */ +async function stepDeploy(options: SyncOptions): Promise { + if (options.skipDeploy) { + console.log(chalk.yellow('โญ๏ธ Skipping deploy: --skip-deploy specified')); + return null; + } + + console.log(chalk.blue('\n๐Ÿš€ Step 4: Deploying to application...')); + + await executeStep('deploy to application', options.dryRun, async () => { + await deployCommand({ + sourceDir: options.outputDir, + targetDir: options.localesDir, + languages: options.languages, + format: 'json', + backup: true, + validate: true, + }); + }); + + return 'Deploy'; +} + +/** + * Display workflow summary + */ +function displaySummary(steps: string[], options: SyncOptions): void { + console.log(chalk.green('\nโœ… i18n workflow completed successfully!')); + console.log(chalk.gray(` Steps executed: ${steps.join(' โ†’ ')}`)); + + if (options.dryRun) { + console.log( + chalk.blue('๐Ÿ” This was a dry run - no actual changes were made'), + ); + } else { + console.log(chalk.gray(` Source directory: ${options.sourceDir}`)); + console.log(chalk.gray(` Output directory: ${options.outputDir}`)); + console.log(chalk.gray(` Locales directory: ${options.localesDir}`)); + } +} + +export async function syncCommand(opts: OptionValues): Promise { + console.log(chalk.blue('๐Ÿ”„ Running complete i18n workflow...')); + + const config = await loadI18nConfig(); + const mergedOpts = await mergeConfigWithOptions(config, opts); + + const options: SyncOptions = { + sourceDir: String(mergedOpts.sourceDir || 'src'), + outputDir: String(mergedOpts.outputDir || 'i18n'), + localesDir: String(mergedOpts.localesDir || 'src/locales'), + tmsUrl: + mergedOpts.tmsUrl && typeof mergedOpts.tmsUrl === 'string' + ? mergedOpts.tmsUrl + : undefined, + tmsToken: + mergedOpts.tmsToken && typeof mergedOpts.tmsToken === 'string' + ? mergedOpts.tmsToken + : undefined, + projectId: + mergedOpts.projectId && typeof mergedOpts.projectId === 'string' + ? mergedOpts.projectId + : undefined, + languages: + mergedOpts.languages && typeof mergedOpts.languages === 'string' + ? mergedOpts.languages + : undefined, + skipUpload: Boolean(mergedOpts.skipUpload ?? false), + skipDownload: Boolean(mergedOpts.skipDownload ?? false), + skipDeploy: Boolean(mergedOpts.skipDeploy ?? false), + dryRun: Boolean(mergedOpts.dryRun ?? false), + }; + + try { + const steps: string[] = []; + + const generateStep = await stepGenerate( + options.sourceDir, + options.outputDir, + options.dryRun, + ); + steps.push(generateStep); + + const uploadStep = await stepUpload(options); + if (uploadStep) { + steps.push(uploadStep); + } + + const downloadStep = await stepDownload(options); + if (downloadStep) { + steps.push(downloadStep); + } + + const deployStep = await stepDeploy(options); + if (deployStep) { + steps.push(deployStep); + } + + displaySummary(steps, options); + } catch (error) { + console.error(chalk.red('โŒ Error in i18n workflow:'), error); + throw error; + } +} diff --git a/workspaces/translations/packages/cli/src/commands/upload.ts b/workspaces/translations/packages/cli/src/commands/upload.ts new file mode 100644 index 0000000000..431c269727 --- /dev/null +++ b/workspaces/translations/packages/cli/src/commands/upload.ts @@ -0,0 +1,570 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import path from 'path'; +import { execSync } from 'child_process'; + +import fs from 'fs-extra'; +import { OptionValues } from 'commander'; +import chalk from 'chalk'; + +import { validateTranslationFile } from '../lib/i18n/validateFile'; +import { loadI18nConfig, mergeConfigWithOptions } from '../lib/i18n/config'; +import { + hasFileChanged, + saveUploadCache, + getCachedUpload, +} from '../lib/i18n/uploadCache'; + +/** + * Detect repository name from git or directory + */ +function detectRepoName(): string { + try { + // Try to get repo name from git + const gitRepoUrl = execSync('git config --get remote.origin.url', { + encoding: 'utf-8', + stdio: 'pipe', + }).trim(); + if (gitRepoUrl) { + // Extract repo name from URL (handles both https and ssh formats) + const match = gitRepoUrl.match(/([^/]+?)(?:\.git)?$/); + if (match) { + return match[1]; + } + } + } catch { + // Git not available or not a git repo + } + + // Fallback: use current directory name + return path.basename(process.cwd()); +} + +/** + * Generate upload filename: {repo-name}-reference-{YYYY-MM-DD}.json + */ +function generateUploadFileName( + sourceFile: string, + customName?: string, +): string { + if (customName) { + // Use custom name if provided, ensure it has the right extension + const ext = path.extname(sourceFile); + return customName.endsWith(ext) ? customName : `${customName}${ext}`; + } + + // Auto-generate: {repo-name}-reference-{date}.json + const repoName = detectRepoName(); + const date = new Date().toISOString().split('T')[0]; // YYYY-MM-DD + const ext = path.extname(sourceFile); + return `${repoName}-reference-${date}${ext}`; +} + +/** + * Upload file using memsource CLI (matching the team's script approach) + */ +async function uploadWithMemsourceCLI( + filePath: string, + projectId: string, + targetLanguages: string[], + uploadFileName?: string, +): Promise<{ fileName: string; keyCount: number }> { + // Ensure file path is absolute + const absoluteFilePath = path.resolve(filePath); + + // If a custom upload filename is provided, create a temporary copy with that name + let fileToUpload = absoluteFilePath; + let tempFile: string | null = null; + + if (uploadFileName && path.basename(absoluteFilePath) !== uploadFileName) { + // Create temporary directory and copy file with new name + const tempDir = path.join(path.dirname(absoluteFilePath), '.i18n-temp'); + await fs.ensureDir(tempDir); + tempFile = path.join(tempDir, uploadFileName); + await fs.copy(absoluteFilePath, tempFile); + fileToUpload = tempFile; + console.log( + chalk.gray(` Created temporary file with name: ${uploadFileName}`), + ); + } + + // Check if memsource CLI is available + try { + execSync('which memsource', { stdio: 'pipe' }); + } catch { + throw new Error( + 'memsource CLI not found. Please ensure memsource-cli is installed and ~/.memsourcerc is sourced.', + ); + } + + // Build memsource job create command + // Format: memsource job create --project-id --target-langs ... --filenames + // Note: targetLangs is REQUIRED by memsource API + const args = ['job', 'create', '--project-id', projectId]; + + // Target languages should already be provided by the caller + // This function just uses them directly + const finalTargetLanguages = targetLanguages; + + if (finalTargetLanguages.length === 0) { + throw new Error( + 'Target languages are required. Please specify --target-languages or configure them in .i18n.config.json', + ); + } + + args.push('--target-langs', ...finalTargetLanguages); + args.push('--filenames', fileToUpload); + + // Execute memsource command + // Note: MEMSOURCE_TOKEN should be set from ~/.memsourcerc + try { + const output = execSync(`memsource ${args.join(' ')}`, { + encoding: 'utf-8', + stdio: 'pipe', // Capture both stdout and stderr + env: { + ...process.env, + // Ensure MEMSOURCE_TOKEN is available (should be set from .memsourcerc) + }, + }); + + // Log output if any + if (output && output.trim()) { + console.log(chalk.gray(` ${output.trim()}`)); + } + + // Parse output to get job info if available + // For now, we'll estimate key count from the file + const fileContent = await fs.readFile(fileToUpload, 'utf-8'); + let keyCount = 0; + try { + const data = JSON.parse(fileContent); + // Handle nested structure: { "plugin": { "en": { "key": "value" } } } + if (data && typeof data === 'object') { + const isNested = Object.values(data).some( + (val: unknown) => + typeof val === 'object' && val !== null && 'en' in val, + ); + if (isNested) { + for (const pluginData of Object.values(data)) { + const enData = (pluginData as { en?: Record })?.en; + if (enData && typeof enData === 'object') { + keyCount += Object.keys(enData).length; + } + } + } else { + // Flat structure + const translations = data.translations || data; + keyCount = Object.keys(translations).length; + } + } + } catch { + // If parsing fails, use a default + keyCount = 0; + } + + const result = { + fileName: uploadFileName || path.basename(absoluteFilePath), + keyCount, + }; + + return result; + } catch (error: unknown) { + // Extract error message from execSync error + let errorMessage = 'Unknown error'; + if (error instanceof Error) { + errorMessage = error.message; + // execSync errors include stderr in the message sometimes + if ( + 'stderr' in error && + typeof (error as { stderr?: Buffer }).stderr === 'object' + ) { + const stderr = (error as { stderr: Buffer }).stderr; + if (stderr) { + const stderrText = stderr.toString('utf-8'); + if (stderrText) { + errorMessage = stderrText.trim(); + } + } + } + } + throw new Error(`memsource CLI upload failed: ${errorMessage}`); + } finally { + // Clean up temporary file if created (even on error) + if (tempFile) { + try { + if (await fs.pathExists(tempFile)) { + await fs.remove(tempFile); + } + // Also remove temp directory if empty + const tempDir = path.dirname(tempFile); + if (await fs.pathExists(tempDir)) { + const files = await fs.readdir(tempDir); + if (files.length === 0) { + await fs.remove(tempDir); + } + } + } catch (cleanupError) { + // Log but don't fail on cleanup errors + console.warn( + chalk.yellow( + ` Warning: Failed to clean up temporary file: ${cleanupError}`, + ), + ); + } + } + } +} + +export async function uploadCommand(opts: OptionValues): Promise { + console.log(chalk.blue('๐Ÿ“ค Uploading translation reference files to TMS...')); + + // Load config and merge with options + const config = await loadI18nConfig(); + const mergedOpts = await mergeConfigWithOptions(config, opts); + + const { + tmsUrl, + tmsToken, + projectId, + sourceFile, + targetLanguages, + uploadFileName, + dryRun = false, + force = false, + } = mergedOpts as { + tmsUrl?: string; + tmsToken?: string; + projectId?: string; + sourceFile?: string; + targetLanguages?: string; + uploadFileName?: string; + dryRun?: boolean; + force?: boolean; + }; + + // Validate required options + const tmsUrlStr = tmsUrl && typeof tmsUrl === 'string' ? tmsUrl : undefined; + const tmsTokenStr = + tmsToken && typeof tmsToken === 'string' ? tmsToken : undefined; + const projectIdStr = + projectId && typeof projectId === 'string' ? projectId : undefined; + const sourceFileStr = + sourceFile && typeof sourceFile === 'string' ? sourceFile : undefined; + + if (!tmsUrlStr || !tmsTokenStr || !projectIdStr) { + console.error(chalk.red('โŒ Missing required TMS configuration:')); + console.error(''); + + const missingItems: string[] = []; + if (!tmsUrlStr) { + missingItems.push('TMS URL'); + console.error(chalk.yellow(' โœ— TMS URL')); + console.error( + chalk.gray( + ' Set via: --tms-url or I18N_TMS_URL or .i18n.config.json', + ), + ); + } + if (!tmsTokenStr) { + missingItems.push('TMS Token'); + console.error(chalk.yellow(' โœ— TMS Token')); + console.error( + chalk.gray( + ' Primary: Source ~/.memsourcerc (sets MEMSOURCE_TOKEN)', + ), + ); + console.error( + chalk.gray( + ' Fallback: --tms-token or I18N_TMS_TOKEN or ~/.i18n.auth.json', + ), + ); + } + if (!projectIdStr) { + missingItems.push('Project ID'); + console.error(chalk.yellow(' โœ— Project ID')); + console.error( + chalk.gray( + ' Set via: --project-id or I18N_TMS_PROJECT_ID or .i18n.config.json', + ), + ); + } + + console.error(''); + console.error(chalk.blue('๐Ÿ“‹ Quick Setup Guide:')); + console.error(chalk.gray(' 1. Run: translations-cli i18n init')); + console.error(chalk.gray(' This creates .i18n.config.json')); + console.error(''); + console.error( + chalk.gray(' 2. Edit .i18n.config.json in your project root:'), + ); + console.error( + chalk.gray( + ' - Add your TMS URL (e.g., "https://cloud.memsource.com/web")', + ), + ); + console.error(chalk.gray(' - Add your Project ID')); + console.error(''); + console.error( + chalk.gray(' 3. Set up Memsource authentication (recommended):'), + ); + console.error( + chalk.gray(' - Run: translations-cli i18n setup-memsource'), + ); + console.error( + chalk.gray( + ' - Or manually create ~/.memsourcerc following localization team instructions', + ), + ); + console.error(chalk.gray(' - Then source it: source ~/.memsourcerc')); + console.error(''); + console.error( + chalk.gray( + ' OR use ~/.i18n.auth.json as fallback (run init to create it)', + ), + ); + console.error(''); + console.error( + chalk.gray(' See docs/i18n-commands.md for detailed instructions.'), + ); + process.exit(1); + } + + if (!sourceFileStr) { + console.error(chalk.red('โŒ Missing required option: --source-file')); + process.exit(1); + } + + try { + // Check if source file exists + if (!(await fs.pathExists(sourceFileStr))) { + throw new Error(`Source file not found: ${sourceFileStr}`); + } + + // Validate translation file format + console.log(chalk.yellow(`๐Ÿ” Validating ${sourceFileStr}...`)); + const isValid = await validateTranslationFile(sourceFileStr); + if (!isValid) { + throw new Error(`Invalid translation file format: ${sourceFileStr}`); + } + + console.log(chalk.green(`โœ… Translation file is valid`)); + + // Generate upload filename + const finalUploadFileName = + uploadFileName && typeof uploadFileName === 'string' + ? generateUploadFileName(sourceFileStr, uploadFileName) + : generateUploadFileName(sourceFileStr); + + // Get cached entry for display purposes + const cachedEntry = await getCachedUpload( + sourceFileStr, + projectIdStr, + tmsUrlStr, + ); + + // Check if file has changed since last upload (unless --force is used) + if (!force) { + const fileChanged = await hasFileChanged( + sourceFileStr, + projectIdStr, + tmsUrlStr, + ); + + // Also check if we're uploading with the same filename that was already uploaded + const sameFilename = cachedEntry?.uploadFileName === finalUploadFileName; + + if (!fileChanged && cachedEntry && sameFilename) { + console.log( + chalk.yellow( + `โ„น๏ธ File has not changed since last upload (${new Date( + cachedEntry.uploadedAt, + ).toLocaleString()})`, + ), + ); + console.log(chalk.gray(` Upload filename: ${finalUploadFileName}`)); + console.log(chalk.gray(` Skipping upload to avoid duplicate.`)); + console.log( + chalk.gray( + ` Use --force to upload anyway, or delete .i18n-cache to clear cache.`, + ), + ); + return; + } else if (!fileChanged && cachedEntry && !sameFilename) { + // File content hasn't changed but upload filename is different - warn user + console.log( + chalk.yellow( + `โš ๏ธ File content unchanged, but upload filename differs from last upload:`, + ), + ); + console.log( + chalk.gray( + ` Last upload: ${cachedEntry.uploadFileName || 'unknown'}`, + ), + ); + console.log(chalk.gray(` This upload: ${finalUploadFileName}`)); + console.log(chalk.gray(` This will create a new job in Memsource.`)); + } + } else { + console.log( + chalk.yellow(`โš ๏ธ Force upload enabled - skipping cache check`), + ); + } + + if (dryRun) { + console.log( + chalk.yellow('๐Ÿ” Dry run mode - showing what would be uploaded:'), + ); + console.log(chalk.gray(` TMS URL: ${tmsUrlStr}`)); + console.log(chalk.gray(` Project ID: ${projectIdStr}`)); + console.log(chalk.gray(` Source file: ${sourceFileStr}`)); + console.log(chalk.gray(` Upload filename: ${finalUploadFileName}`)); + console.log( + chalk.gray( + ` Target languages: ${ + targetLanguages || 'All configured languages' + }`, + ), + ); + if (cachedEntry) { + console.log( + chalk.gray( + ` Last uploaded: ${new Date( + cachedEntry.uploadedAt, + ).toLocaleString()}`, + ), + ); + } + return; + } + + // Check if MEMSOURCE_TOKEN is available (should be set from ~/.memsourcerc) + if (!process.env.MEMSOURCE_TOKEN && !tmsTokenStr) { + console.error(chalk.red('โŒ MEMSOURCE_TOKEN not found in environment')); + console.error(chalk.yellow(' Please source ~/.memsourcerc first:')); + console.error(chalk.gray(' source ~/.memsourcerc')); + console.error( + chalk.gray(' Or set MEMSOURCE_TOKEN environment variable'), + ); + process.exit(1); + } + + // Use memsource CLI for upload (matching team's script approach) + console.log( + chalk.yellow( + `๐Ÿ”— Using memsource CLI to upload to project ${projectIdStr}...`, + ), + ); + + // Parse target languages - check config first if not provided via CLI + let languages: string[] = []; + if (targetLanguages && typeof targetLanguages === 'string') { + languages = targetLanguages + .split(',') + .map((lang: string) => lang.trim()) + .filter(Boolean); + } else if ( + config.languages && + Array.isArray(config.languages) && + config.languages.length > 0 + ) { + // Fallback to config languages + languages = config.languages; + console.log( + chalk.gray( + ` Using target languages from config: ${languages.join(', ')}`, + ), + ); + } + + // Target languages are REQUIRED by memsource + if (languages.length === 0) { + console.error(chalk.red('โŒ Target languages are required')); + console.error(chalk.yellow(' Please specify one of:')); + console.error( + chalk.gray(' 1. --target-languages it (or other language codes)'), + ); + console.error( + chalk.gray(' 2. Add "languages": ["it"] to .i18n.config.json'), + ); + process.exit(1); + } + + // Upload using memsource CLI + console.log(chalk.yellow(`๐Ÿ“ค Uploading ${sourceFileStr}...`)); + console.log(chalk.gray(` Upload filename: ${finalUploadFileName}`)); + if (languages.length > 0) { + console.log(chalk.gray(` Target languages: ${languages.join(', ')}`)); + } + + const uploadResult = await uploadWithMemsourceCLI( + sourceFileStr, + projectIdStr, + languages, + finalUploadFileName, // Pass the generated filename + ); + + // Calculate key count for cache + const fileContent = await fs.readFile(sourceFileStr, 'utf-8'); + let keyCount = uploadResult.keyCount; + if (keyCount === 0) { + // Fallback: count keys from file + try { + const data = JSON.parse(fileContent); + if (data && typeof data === 'object') { + const isNested = Object.values(data).some( + (val: unknown) => + typeof val === 'object' && val !== null && 'en' in val, + ); + if (isNested) { + keyCount = 0; + for (const pluginData of Object.values(data)) { + const enData = (pluginData as { en?: Record }) + ?.en; + if (enData && typeof enData === 'object') { + keyCount += Object.keys(enData).length; + } + } + } else { + const translations = data.translations || data; + keyCount = Object.keys(translations).length; + } + } + } catch { + // If parsing fails, use 0 + } + } + + // Save upload cache (include upload filename to prevent duplicates with different names) + await saveUploadCache( + sourceFileStr, + projectIdStr, + tmsUrlStr, + keyCount, + finalUploadFileName, + ); + + console.log(chalk.green(`โœ… Upload completed successfully!`)); + console.log(chalk.gray(` File: ${uploadResult.fileName}`)); + console.log(chalk.gray(` Keys: ${keyCount}`)); + if (languages.length > 0) { + console.log(chalk.gray(` Target languages: ${languages.join(', ')}`)); + } + } catch (error) { + console.error(chalk.red('โŒ Error uploading to TMS:'), error); + throw error; + } +} diff --git a/workspaces/translations/packages/cli/src/index.ts b/workspaces/translations/packages/cli/src/index.ts new file mode 100644 index 0000000000..5dcafd4f94 --- /dev/null +++ b/workspaces/translations/packages/cli/src/index.ts @@ -0,0 +1,54 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * CLI for translation workflows with TMS + * + * @packageDocumentation + */ + +import chalk from 'chalk'; +import { program } from 'commander'; + +import { registerCommands } from './commands'; +import { exitWithError } from './lib/errors'; +import { version } from './lib/version'; + +const main = (argv: string[]) => { + program.name('translations-cli').version(version); + + registerCommands(program); + + program.on('command:*', () => { + console.log(); + console.log(chalk.red(`Invalid command: ${program.args.join(' ')}`)); + console.log(); + program.outputHelp(); + process.exit(1); + }); + + program.parse(argv); +}; + +process.on('unhandledRejection', rejection => { + if (rejection instanceof Error) { + exitWithError(rejection); + } else { + exitWithError(new Error(`Unknown rejection: '${rejection}'`)); + } +}); + +main(process.argv); diff --git a/workspaces/translations/packages/cli/src/lib/errors.ts b/workspaces/translations/packages/cli/src/lib/errors.ts new file mode 100644 index 0000000000..b850de7032 --- /dev/null +++ b/workspaces/translations/packages/cli/src/lib/errors.ts @@ -0,0 +1,46 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import chalk from 'chalk'; + +export class CustomError extends Error { + get name(): string { + return this.constructor.name; + } +} + +export class ExitCodeError extends CustomError { + readonly code: number; + + constructor(code: number, command?: string) { + super( + command + ? `Command '${command}' exited with code ${code}` + : `Child exited with code ${code}`, + ); + this.code = code; + } +} + +export function exitWithError(error: Error): never { + if (error instanceof ExitCodeError) { + process.stderr.write(`\n${chalk.red(error.message)}\n\n`); + process.exit(error.code); + } else { + process.stderr.write(`\n${chalk.red(`${error}`)}\n\n`); + process.exit(1); + } +} diff --git a/workspaces/translations/packages/cli/src/lib/i18n/analyzeStatus.ts b/workspaces/translations/packages/cli/src/lib/i18n/analyzeStatus.ts new file mode 100644 index 0000000000..1b154fdf93 --- /dev/null +++ b/workspaces/translations/packages/cli/src/lib/i18n/analyzeStatus.ts @@ -0,0 +1,148 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import path from 'path'; + +import fs from 'fs-extra'; +import glob from 'glob'; + +export interface TranslationStatus { + sourceFiles: string[]; + totalKeys: number; + languages: string[]; + overallCompletion: number; + languageStats: { + [language: string]: { + total: number; + translated: number; + completion: number; + }; + }; + missingKeys: string[]; + extraKeys: { [language: string]: string[] }; +} + +export interface AnalyzeOptions { + sourceDir: string; + i18nDir: string; + localesDir: string; +} + +/** + * Analyze translation status across the project + */ +export async function analyzeTranslationStatus( + options: AnalyzeOptions, +): Promise { + const { sourceDir, i18nDir, localesDir } = options; + + // Find source files + const sourceFiles = glob.sync('**/*.{ts,tsx,js,jsx}', { + cwd: sourceDir, + ignore: ['**/node_modules/**', '**/dist/**', '**/build/**'], + }); + + // Find reference translation file + const referenceFile = path.join(i18nDir, 'reference.json'); + let referenceKeys: string[] = []; + + if (await fs.pathExists(referenceFile)) { + const referenceData = await fs.readJson(referenceFile); + referenceKeys = Object.keys(referenceData.translations || referenceData); + } + + // Find language files + const languageFiles = await findLanguageFiles(localesDir); + const languages = languageFiles.map(file => + path.basename(file, path.extname(file)), + ); + + // Analyze each language + const languageStats: { + [language: string]: { + total: number; + translated: number; + completion: number; + }; + } = {}; + const extraKeys: { [language: string]: string[] } = {}; + + for (const languageFile of languageFiles) { + const language = path.basename(languageFile, path.extname(languageFile)); + const fileData = await fs.readJson(languageFile); + const languageKeys = Object.keys(fileData.translations || fileData); + + const translated = languageKeys.filter(key => { + const value = (fileData.translations || fileData)[key]; + return value && value.trim() !== '' && value !== key; + }); + + languageStats[language] = { + total: referenceKeys.length, + translated: translated.length, + completion: + referenceKeys.length > 0 + ? (translated.length / referenceKeys.length) * 100 + : 0, + }; + + // Find extra keys (keys in language file but not in reference) + extraKeys[language] = languageKeys.filter( + key => !referenceKeys.includes(key), + ); + } + + // Find missing keys (keys in reference but not in any language file) + const missingKeys = referenceKeys.filter(key => { + return !languages.some(lang => { + const langKeys = Object.keys(languageStats[lang] || {}); + return langKeys.includes(key); + }); + }); + + // Calculate overall completion + const totalTranslations = languages.reduce( + (sum, lang) => sum + (languageStats[lang]?.translated || 0), + 0, + ); + const totalPossible = referenceKeys.length * languages.length; + const overallCompletion = + totalPossible > 0 ? (totalTranslations / totalPossible) * 100 : 0; + + return { + sourceFiles, + totalKeys: referenceKeys.length, + languages, + overallCompletion, + languageStats, + missingKeys, + extraKeys, + }; +} + +/** + * Find language files in the locales directory + */ +async function findLanguageFiles(localesDir: string): Promise { + if (!(await fs.pathExists(localesDir))) { + return []; + } + + const files = await fs.readdir(localesDir); + return files + .filter(file => file.endsWith('.json')) + .map(file => path.join(localesDir, file)); +} diff --git a/workspaces/translations/packages/cli/src/lib/i18n/config.ts b/workspaces/translations/packages/cli/src/lib/i18n/config.ts new file mode 100644 index 0000000000..50de47ea61 --- /dev/null +++ b/workspaces/translations/packages/cli/src/lib/i18n/config.ts @@ -0,0 +1,377 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import path from 'path'; +import os from 'os'; +import { execSync } from 'child_process'; + +import fs from 'fs-extra'; + +import { paths } from '../paths'; + +/** + * Project-specific configuration (can be committed to git) + */ +export interface I18nProjectConfig { + tms?: { + url?: string; + projectId?: string; + }; + directories?: { + sourceDir?: string; + outputDir?: string; + localesDir?: string; + }; + languages?: string[]; + format?: 'json' | 'po'; + patterns?: { + include?: string; + exclude?: string; + }; +} + +/** + * Personal authentication configuration (should NOT be committed) + */ +export interface I18nAuthConfig { + tms?: { + username?: string; + password?: string; + token?: string; + }; +} + +/** + * Combined configuration interface + */ +export interface I18nConfig extends I18nProjectConfig { + auth?: I18nAuthConfig; +} + +/** + * Merged options type - represents all possible command option values + */ +export type MergedOptions = Record; + +const PROJECT_CONFIG_FILE_NAME = '.i18n.config.json'; +const AUTH_CONFIG_FILE_NAME = '.i18n.auth.json'; +const CONFIG_ENV_PREFIX = 'I18N_'; + +/** + * Load project-specific configuration from project root + */ +export async function loadProjectConfig(): Promise { + const config: I18nProjectConfig = {}; + + // Try to load from project config file (can be committed) + const configPath = path.join(paths.targetDir, PROJECT_CONFIG_FILE_NAME); + if (await fs.pathExists(configPath)) { + try { + const fileConfig = await fs.readJson(configPath); + Object.assign(config, fileConfig); + } catch (error) { + console.warn( + `Warning: Could not read project config file ${configPath}: ${error}`, + ); + } + } + + return config; +} + +/** + * Load personal authentication configuration from home directory + */ +export async function loadAuthConfig(): Promise { + const config: I18nAuthConfig = {}; + + // Try to load from personal auth file (should NOT be committed) + const authPath = path.join(os.homedir(), AUTH_CONFIG_FILE_NAME); + if (await fs.pathExists(authPath)) { + try { + const authConfig = await fs.readJson(authPath); + Object.assign(config, authConfig); + } catch (error) { + console.warn( + `Warning: Could not read auth config file ${authPath}: ${error}`, + ); + } + } + + return config; +} + +/** + * Load i18n configuration from both project and personal auth files, plus environment variables + */ +export async function loadI18nConfig(): Promise { + const config: I18nConfig = {}; + + // Load project-specific configuration + const projectConfig = await loadProjectConfig(); + Object.assign(config, projectConfig); + + // Load personal authentication configuration + const authConfig = await loadAuthConfig(); + if (Object.keys(authConfig).length > 0) { + config.auth = authConfig; + } + + // Override with environment variables (project settings) + // Support both I18N_TMS_* and MEMSOURCE_* (for backward compatibility) + const tmsUrl = + process.env[`${CONFIG_ENV_PREFIX}TMS_URL`] || process.env.MEMSOURCE_URL; + if (tmsUrl) { + config.tms = config.tms || {}; + config.tms.url = tmsUrl; + } + if (process.env[`${CONFIG_ENV_PREFIX}TMS_PROJECT_ID`]) { + config.tms = config.tms || {}; + config.tms.projectId = process.env[`${CONFIG_ENV_PREFIX}TMS_PROJECT_ID`]; + } + + // Override with environment variables (authentication) + // Support both I18N_TMS_* and MEMSOURCE_* (for backward compatibility) + const tmsToken = + process.env[`${CONFIG_ENV_PREFIX}TMS_TOKEN`] || process.env.MEMSOURCE_TOKEN; + if (tmsToken) { + config.auth = config.auth || {}; + config.auth.tms = config.auth.tms || {}; + config.auth.tms.token = tmsToken; + } + const tmsUsername = + process.env[`${CONFIG_ENV_PREFIX}TMS_USERNAME`] || + process.env.MEMSOURCE_USERNAME; + if (tmsUsername) { + config.auth = config.auth || {}; + config.auth.tms = config.auth.tms || {}; + config.auth.tms.username = tmsUsername; + } + const tmsPassword = + process.env[`${CONFIG_ENV_PREFIX}TMS_PASSWORD`] || + process.env.MEMSOURCE_PASSWORD; + if (tmsPassword) { + config.auth = config.auth || {}; + config.auth.tms = config.auth.tms || {}; + config.auth.tms.password = tmsPassword; + } + const languagesEnv = process.env[`${CONFIG_ENV_PREFIX}LANGUAGES`]; + if (languagesEnv) { + config.languages = languagesEnv.split(',').map(l => l.trim()); + } + if (process.env[`${CONFIG_ENV_PREFIX}FORMAT`]) { + config.format = process.env[`${CONFIG_ENV_PREFIX}FORMAT`] as 'json' | 'po'; + } + if (process.env[`${CONFIG_ENV_PREFIX}SOURCE_DIR`]) { + config.directories = config.directories || {}; + config.directories.sourceDir = + process.env[`${CONFIG_ENV_PREFIX}SOURCE_DIR`]; + } + if (process.env[`${CONFIG_ENV_PREFIX}OUTPUT_DIR`]) { + config.directories = config.directories || {}; + config.directories.outputDir = + process.env[`${CONFIG_ENV_PREFIX}OUTPUT_DIR`]; + } + if (process.env[`${CONFIG_ENV_PREFIX}LOCALES_DIR`]) { + config.directories = config.directories || {}; + config.directories.localesDir = + process.env[`${CONFIG_ENV_PREFIX}LOCALES_DIR`]; + } + + return config; +} + +/** + * Merge command options with config, command options take precedence + * This function is async because it may need to generate a token using memsource CLI + */ +export async function mergeConfigWithOptions( + config: I18nConfig, + options: Record, +): Promise { + const merged: MergedOptions = {}; + + // Apply config defaults + if (config.directories?.sourceDir && !options.sourceDir) { + merged.sourceDir = config.directories.sourceDir; + } + if (config.directories?.outputDir && !options.outputDir) { + merged.outputDir = config.directories.outputDir; + } + if ( + config.directories?.localesDir && + !options.targetDir && + !options.localesDir + ) { + merged.targetDir = config.directories.localesDir; + merged.localesDir = config.directories.localesDir; + } + if (config.tms?.url && !options.tmsUrl) { + merged.tmsUrl = config.tms.url; + } + + // Get token from auth config (personal only, not in project config) + // Priority: environment variable > config file > generate from username/password + // Note: If user sources .memsourcerc, MEMSOURCE_TOKEN will be in environment and used first + let token = config.auth?.tms?.token; + + // Only generate token if: + // 1. Token is not already set + // 2. Username and password are available + // 3. Not provided via command-line option + // 4. Memsource CLI is likely available (user is using memsource workflow) + if ( + !token && + config.auth?.tms?.username && + config.auth?.tms?.password && + !options.tmsToken + ) { + // Check if this looks like a Memsource setup (has MEMSOURCE_URL or username suggests memsource) + const isMemsourceSetup = + process.env.MEMSOURCE_URL || + process.env.MEMSOURCE_USERNAME || + config.tms?.url?.includes('memsource'); + + if (isMemsourceSetup) { + // For Memsource, prefer using .memsourcerc workflow + // Only generate if memsource CLI is available and token generation is needed + token = await generateMemsourceToken( + config.auth.tms.username, + config.auth.tms.password, + ); + } + } + + if (token && !options.tmsToken) { + merged.tmsToken = token; + } + + // Get username/password from auth config + if (config.auth?.tms?.username && !options.tmsUsername) { + merged.tmsUsername = config.auth.tms.username; + } + if (config.auth?.tms?.password && !options.tmsPassword) { + merged.tmsPassword = config.auth.tms.password; + } + if (config.tms?.projectId && !options.projectId) { + merged.projectId = config.tms.projectId; + } + if (config.languages && !options.languages && !options.targetLanguages) { + merged.languages = config.languages.join(','); + merged.targetLanguages = config.languages.join(','); + } + if (config.format && !options.format) { + merged.format = config.format; + } + if (config.patterns?.include && !options.includePattern) { + merged.includePattern = config.patterns.include; + } + if (config.patterns?.exclude && !options.excludePattern) { + merged.excludePattern = config.patterns.exclude; + } + + // Command options override config + // Ensure we always return a Promise (async function always returns Promise) + const result = { ...merged, ...options }; + return Promise.resolve(result); +} + +/** + * Create a default project config file template (can be committed) + */ +export async function createDefaultConfigFile(): Promise { + const configPath = path.join(paths.targetDir, PROJECT_CONFIG_FILE_NAME); + const defaultConfig: I18nProjectConfig = { + tms: { + url: '', + projectId: '', + }, + directories: { + sourceDir: 'src', + outputDir: 'i18n', + localesDir: 'src/locales', + }, + languages: [], + format: 'json', + patterns: { + include: '**/*.{ts,tsx,js,jsx}', + exclude: + '**/node_modules/**,**/dist/**,**/build/**,**/*.test.ts,**/*.spec.ts', + }, + }; + + await fs.writeJson(configPath, defaultConfig, { spaces: 2 }); + console.log(`Created project config file: ${configPath}`); + console.log(` This file can be committed to git.`); +} + +/** + * Create a default auth config file template (should NOT be committed) + */ +export async function createDefaultAuthFile(): Promise { + const authPath = path.join(os.homedir(), AUTH_CONFIG_FILE_NAME); + const defaultAuth: I18nAuthConfig = { + tms: { + username: '', + password: '', + token: '', + }, + }; + + await fs.writeJson(authPath, defaultAuth, { spaces: 2, mode: 0o600 }); // Secure file permissions + console.log(`Created personal auth config file: ${authPath}`); + console.log(` โš ๏ธ This file should NOT be committed to git.`); + console.log( + ` โš ๏ธ This file contains sensitive credentials - keep it secure.`, + ); + console.log(` Add ${AUTH_CONFIG_FILE_NAME} to your global .gitignore.`); +} + +/** + * Generate Memsource token using memsource CLI + * This replicates the functionality: memsource auth login --user-name $USERNAME --password "$PASSWORD" -c token -f value + * + * Note: This is a fallback. The preferred workflow is to source ~/.memsourcerc which sets MEMSOURCE_TOKEN + */ +async function generateMemsourceToken( + username: string, + password: string, +): Promise { + try { + // Check if memsource CLI is available by trying to run it + execSync('which memsource', { stdio: 'pipe' }); + + // Generate token using memsource CLI + const token = execSync( + `memsource auth login --user-name ${username} --password "${password}" -c token -f value`, + { + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + maxBuffer: 1024 * 1024, + }, + ).trim(); + + if (token && token.length > 0) { + return token; + } + } catch { + // memsource CLI not available or authentication failed + // This is expected if user hasn't set up memsource CLI or virtual environment + // The workflow should use .memsourcerc file instead + } + + return undefined; +} diff --git a/workspaces/translations/packages/cli/src/lib/i18n/deployFiles.ts b/workspaces/translations/packages/cli/src/lib/i18n/deployFiles.ts new file mode 100644 index 0000000000..08af53e33a --- /dev/null +++ b/workspaces/translations/packages/cli/src/lib/i18n/deployFiles.ts @@ -0,0 +1,105 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import path from 'path'; + +import fs from 'fs-extra'; + +export interface TranslationData { + [key: string]: string; +} + +/** + * Deploy translation files to application language files + */ +export async function deployTranslationFiles( + data: TranslationData, + targetPath: string, + format: string, +): Promise { + const outputDir = path.dirname(targetPath); + await fs.ensureDir(outputDir); + + switch (format.toLowerCase()) { + case 'json': + await deployJsonFile(data, targetPath); + break; + case 'po': + await deployPoFile(data, targetPath); + break; + default: + throw new Error(`Unsupported format: ${format}`); + } +} + +/** + * Deploy JSON translation file + */ +async function deployJsonFile( + data: TranslationData, + targetPath: string, +): Promise { + // For JSON files, we can deploy directly as they are commonly used in applications + const output = { + metadata: { + generated: new Date().toISOString(), + version: '1.0', + totalKeys: Object.keys(data).length, + }, + translations: data, + }; + + await fs.writeJson(targetPath, output, { spaces: 2 }); +} + +/** + * Deploy PO translation file + */ +async function deployPoFile( + data: TranslationData, + targetPath: string, +): Promise { + const lines: string[] = []; + + // PO file header + lines.push('msgid ""'); + lines.push('msgstr ""'); + lines.push('"Content-Type: text/plain; charset=UTF-8\\n"'); + lines.push(`"Generated: ${new Date().toISOString()}\\n"`); + lines.push(`"Total-Keys: ${Object.keys(data).length}\\n"`); + lines.push(''); + + // Translation entries + for (const [key, value] of Object.entries(data)) { + lines.push(`msgid "${escapePoString(key)}"`); + lines.push(`msgstr "${escapePoString(value)}"`); + lines.push(''); + } + + await fs.writeFile(targetPath, lines.join('\n'), 'utf-8'); +} + +/** + * Escape string for PO format + */ +function escapePoString(str: string): string { + return str + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"') + .replace(/\n/g, '\\n') + .replace(/\r/g, '\\r') + .replace(/\t/g, '\\t'); +} diff --git a/workspaces/translations/packages/cli/src/lib/i18n/extractKeys.ts b/workspaces/translations/packages/cli/src/lib/i18n/extractKeys.ts new file mode 100644 index 0000000000..58d153a849 --- /dev/null +++ b/workspaces/translations/packages/cli/src/lib/i18n/extractKeys.ts @@ -0,0 +1,329 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as ts from 'typescript'; + +export interface TranslationKey { + key: string; + value: string; + context?: string; + line: number; + column: number; +} + +/** + * Extract translation keys from TypeScript/JavaScript source code + */ +export function extractTranslationKeys( + content: string, + filePath: string, +): Record { + const keys: Record = {}; + + try { + // Parse the source code + const sourceFile = ts.createSourceFile( + filePath, + content, + ts.ScriptTarget.Latest, + true, + ); + + // Extract from exported object literals (Backstage translation ref pattern) + // Pattern: export const messages = { key: 'value', nested: { key: 'value' } } + // Also handles type assertions: { ... } as any + const extractFromObjectLiteral = (node: ts.Node, prefix = ''): void => { + // Handle type assertions: { ... } as any + let objectNode: ts.Node = node; + if (ts.isAsExpression(node)) { + objectNode = node.expression; + } + + if (ts.isObjectLiteralExpression(objectNode)) { + for (const property of objectNode.properties) { + if (ts.isPropertyAssignment(property) && property.name) { + let keyName = ''; + if (ts.isIdentifier(property.name)) { + keyName = property.name.text; + } else if (ts.isStringLiteral(property.name)) { + keyName = property.name.text; + } + + if (keyName) { + const fullKey = prefix ? `${prefix}.${keyName}` : keyName; + + // Handle type assertions in property initializers too + let initializer = property.initializer; + if (ts.isAsExpression(initializer)) { + initializer = initializer.expression; + } + + if (ts.isStringLiteral(initializer)) { + // Leaf node - this is a translation value + keys[fullKey] = initializer.text; + } else if (ts.isObjectLiteralExpression(initializer)) { + // Nested object - recurse + extractFromObjectLiteral(initializer, fullKey); + } + } + } + } + } + }; + + // Visit all nodes in the AST + const visit = (node: ts.Node) => { + // Look for createTranslationRef calls with messages property + // Pattern: createTranslationRef({ id: '...', messages: { key: 'value' } }) + if ( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === 'createTranslationRef' + ) { + const args = node.arguments; + if (args.length > 0 && ts.isObjectLiteralExpression(args[0])) { + // Find the 'messages' property in the object literal + for (const property of args[0].properties) { + if ( + ts.isPropertyAssignment(property) && + ts.isIdentifier(property.name) && + property.name.text === 'messages' + ) { + // Handle type assertions: { ... } as any + let messagesNode = property.initializer; + if (ts.isAsExpression(messagesNode)) { + messagesNode = messagesNode.expression; + } + + if (ts.isObjectLiteralExpression(messagesNode)) { + // Extract keys from the messages object + extractFromObjectLiteral(messagesNode); + } + } + } + } + } + + // Look for createTranslationResource calls + // Pattern: createTranslationResource({ ref: ..., translations: { ... } }) + // Note: Most files using this don't contain keys directly, but we check anyway + if ( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === 'createTranslationResource' + ) { + const args = node.arguments; + if (args.length > 0 && ts.isObjectLiteralExpression(args[0])) { + // Look for any object literals in the arguments that might contain keys + // Most createTranslationResource calls just set up imports, but check for direct keys + for (const property of args[0].properties) { + if (ts.isPropertyAssignment(property)) { + // If there's a 'translations' property with an object literal, extract from it + if ( + ts.isIdentifier(property.name) && + property.name.text === 'translations' && + ts.isObjectLiteralExpression(property.initializer) + ) { + extractFromObjectLiteral(property.initializer); + } + } + } + } + } + + // Look for createTranslationMessages calls + // Pattern: createTranslationMessages({ ref: ..., messages: { key: 'value' } }) + // Also handles: messages: { ... } as any + if ( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === 'createTranslationMessages' + ) { + const args = node.arguments; + if (args.length > 0 && ts.isObjectLiteralExpression(args[0])) { + // Find the 'messages' property in the object literal + for (const property of args[0].properties) { + if ( + ts.isPropertyAssignment(property) && + ts.isIdentifier(property.name) && + property.name.text === 'messages' + ) { + // Handle type assertions: { ... } as any + let messagesNode = property.initializer; + if (ts.isAsExpression(messagesNode)) { + messagesNode = messagesNode.expression; + } + + if (ts.isObjectLiteralExpression(messagesNode)) { + // Extract keys from the messages object + extractFromObjectLiteral(messagesNode); + } + } + } + } + } + + // Look for exported const declarations with object literals (Backstage pattern) + // Pattern: export const messages = { ... } + if (ts.isVariableStatement(node)) { + for (const declaration of node.declarationList.declarations) { + if ( + declaration.initializer && + ts.isObjectLiteralExpression(declaration.initializer) + ) { + // Check if it's exported and has a name suggesting it's a messages object + const isExported = node.modifiers?.some( + m => m.kind === ts.SyntaxKind.ExportKeyword, + ); + const varName = ts.isIdentifier(declaration.name) + ? declaration.name.text + : ''; + if ( + isExported && + (varName.includes('Messages') || + varName.includes('messages') || + varName.includes('translations')) + ) { + extractFromObjectLiteral(declaration.initializer); + } + } + } + } + + // Look for t() function calls + if ( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === 't' + ) { + const args = node.arguments; + if (args.length > 0 && ts.isStringLiteral(args[0])) { + const key = args[0].text; + const value = + args.length > 1 && ts.isStringLiteral(args[1]) ? args[1].text : key; + keys[key] = value; + } + } + + // Look for i18n.t() method calls + if ( + ts.isCallExpression(node) && + ts.isPropertyAccessExpression(node.expression) && + ts.isIdentifier(node.expression.expression) && + node.expression.expression.text === 'i18n' && + ts.isIdentifier(node.expression.name) && + node.expression.name.text === 't' + ) { + const args = node.arguments; + if (args.length > 0 && ts.isStringLiteral(args[0])) { + const key = args[0].text; + const value = + args.length > 1 && ts.isStringLiteral(args[1]) ? args[1].text : key; + keys[key] = value; + } + } + + // Look for useTranslation hook usage + if ( + ts.isCallExpression(node) && + ts.isPropertyAccessExpression(node.expression) && + ts.isCallExpression(node.expression.expression) && + ts.isIdentifier(node.expression.expression.expression) && + node.expression.expression.expression.text === 'useTranslation' && + ts.isIdentifier(node.expression.name) && + node.expression.name.text === 't' + ) { + const args = node.arguments; + if (args.length > 0 && ts.isStringLiteral(args[0])) { + const key = args[0].text; + const value = + args.length > 1 && ts.isStringLiteral(args[1]) ? args[1].text : key; + keys[key] = value; + } + } + + // Look for translation key patterns in JSX + if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) { + const tagName = ts.isJsxElement(node) + ? node.openingElement.tagName + : node.tagName; + if (ts.isIdentifier(tagName) && tagName.text === 'Trans') { + // Handle react-i18next Trans component + const attributes = ts.isJsxElement(node) + ? node.openingElement.attributes + : node.attributes; + if (ts.isJsxAttributes(attributes)) { + attributes.properties.forEach((attr: any) => { + if ( + ts.isJsxAttribute(attr) && + ts.isIdentifier(attr.name) && + attr.name.text === 'i18nKey' && + attr.initializer && + ts.isStringLiteral(attr.initializer) + ) { + const key = attr.initializer.text; + keys[key] = key; // Default value is the key itself + } + }); + } + } + } + + // Recursively visit child nodes + ts.forEachChild(node, visit); + }; + + visit(sourceFile); + } catch { + // If TypeScript parsing fails, fall back to regex-based extraction + return extractKeysWithRegex(content); + } + + return keys; +} + +/** + * Fallback regex-based key extraction for non-TypeScript files + */ +function extractKeysWithRegex(content: string): Record { + const keys: Record = {}; + + // Common patterns for translation keys + // Note: createTranslationMessages pattern is handled by AST parser above + // This regex fallback is for non-TypeScript files or when AST parsing fails + const patterns = [ + // t('key', 'value') + /t\s*\(\s*['"`]([^'"`]+)['"`]\s*(?:,\s*['"`]([^'"`]*)['"`])?\s*\)/g, + // i18n.t('key', 'value') + /i18n\s*\.\s*t\s*\(\s*['"`]([^'"`]+)['"`]\s*(?:,\s*['"`]([^'"`]*)['"`])?\s*\)/g, + // useTranslation().t('key', 'value') + /useTranslation\s*\(\s*\)\s*\.\s*t\s*\(\s*['"`]([^'"`]+)['"`]\s*(?:,\s*['"`]([^'"`]*)['"`])?\s*\)/g, + // Trans i18nKey="key" + /i18nKey\s*=\s*['"`]([^'"`]+)['"`]/g, + ]; + + for (const pattern of patterns) { + let match; + // eslint-disable-next-line no-cond-assign + while ((match = pattern.exec(content)) !== null) { + const key = match[1]; + const value = match[2] || key; + keys[key] = value; + } + } + + return keys; +} diff --git a/workspaces/translations/packages/cli/src/lib/i18n/formatReport.ts b/workspaces/translations/packages/cli/src/lib/i18n/formatReport.ts new file mode 100644 index 0000000000..c553120213 --- /dev/null +++ b/workspaces/translations/packages/cli/src/lib/i18n/formatReport.ts @@ -0,0 +1,184 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import chalk from 'chalk'; + +import { TranslationStatus } from './analyzeStatus'; + +/** + * Format translation status report + */ +export async function formatStatusReport( + status: TranslationStatus, + format: string, + includeStats: boolean, +): Promise { + switch (format.toLowerCase()) { + case 'table': + return formatTableReport(status, includeStats); + case 'json': + return formatJsonReport(status, includeStats); + default: + throw new Error(`Unsupported format: ${format}`); + } +} + +/** + * Format table report + */ +function formatTableReport( + status: TranslationStatus, + includeStats: boolean, +): string { + const lines: string[] = []; + + // Header + lines.push(chalk.blue('๐Ÿ“Š Translation Status Report')); + lines.push(chalk.gray('โ•'.repeat(50))); + + // Summary + lines.push(chalk.yellow('\n๐Ÿ“ˆ Summary:')); + lines.push(` Total Keys: ${status.totalKeys}`); + lines.push(` Languages: ${status.languages.length}`); + lines.push(` Overall Completion: ${status.overallCompletion.toFixed(1)}%`); + + // Language breakdown + if (status.languages.length > 0) { + lines.push(chalk.yellow('\n๐ŸŒ Language Status:')); + lines.push(chalk.gray(' Language | Translated | Total | Completion')); + lines.push(chalk.gray(' โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€')); + + for (const language of status.languages) { + const stats = status.languageStats[language]; + const completion = stats.completion.toFixed(1); + const completionBar = getCompletionBar(stats.completion); + + lines.push( + ` ${language.padEnd(12)} | ${stats.translated + .toString() + .padStart(10)} | ${stats.total + .toString() + .padStart(5)} | ${completion.padStart(8)}% ${completionBar}`, + ); + } + } + + // Missing keys + if (status.missingKeys.length > 0) { + lines.push(chalk.red(`\nโŒ Missing Keys (${status.missingKeys.length}):`)); + for (const key of status.missingKeys.slice(0, 10)) { + lines.push(chalk.gray(` ${key}`)); + } + if (status.missingKeys.length > 10) { + lines.push( + chalk.gray(` ... and ${status.missingKeys.length - 10} more`), + ); + } + } + + // Extra keys + const languagesWithExtraKeys = status.languages.filter( + lang => status.extraKeys[lang] && status.extraKeys[lang].length > 0, + ); + + if (languagesWithExtraKeys.length > 0) { + lines.push(chalk.yellow(`\nโš ๏ธ Extra Keys:`)); + for (const language of languagesWithExtraKeys) { + const extraKeys = status.extraKeys[language]; + lines.push(chalk.gray(` ${language}: ${extraKeys.length} extra keys`)); + for (const key of extraKeys.slice(0, 5)) { + lines.push(chalk.gray(` ${key}`)); + } + if (extraKeys.length > 5) { + lines.push(chalk.gray(` ... and ${extraKeys.length - 5} more`)); + } + } + } + + // Detailed stats + if (includeStats) { + lines.push(chalk.yellow('\n๐Ÿ“Š Detailed Statistics:')); + lines.push(` Source Files: ${status.sourceFiles.length}`); + lines.push( + ` Total Translations: ${status.languages.reduce( + (sum, lang) => sum + (status.languageStats[lang]?.translated || 0), + 0, + )}`, + ); + lines.push( + ` Average Completion: ${( + status.languages.reduce( + (sum, lang) => sum + (status.languageStats[lang]?.completion || 0), + 0, + ) / status.languages.length + ).toFixed(1)}%`, + ); + } + + return lines.join('\n'); +} + +/** + * Format JSON report + */ +function formatJsonReport( + status: TranslationStatus, + includeStats: boolean, +): string { + const summary: { + totalKeys: number; + languages: number; + overallCompletion: number; + sourceFiles?: number; + totalTranslations?: number; + averageCompletion?: number; + } = { + totalKeys: status.totalKeys, + languages: status.languages.length, + overallCompletion: status.overallCompletion, + }; + + if (includeStats) { + summary.sourceFiles = status.sourceFiles.length; + summary.totalTranslations = status.languages.reduce( + (sum, lang) => sum + (status.languageStats[lang]?.translated || 0), + 0, + ); + summary.averageCompletion = + status.languages.reduce( + (sum, lang) => sum + (status.languageStats[lang]?.completion || 0), + 0, + ) / status.languages.length; + } + + const report = { + summary, + languages: status.languageStats, + missingKeys: status.missingKeys, + extraKeys: status.extraKeys, + }; + + return JSON.stringify(report, null, 2); +} + +/** + * Get completion bar visualization + */ +function getCompletionBar(completion: number): string { + const filled = Math.floor(completion / 10); + const empty = 10 - filled; + return 'โ–ˆ'.repeat(filled) + 'โ–‘'.repeat(empty); +} diff --git a/workspaces/translations/packages/cli/src/lib/i18n/generateFiles.ts b/workspaces/translations/packages/cli/src/lib/i18n/generateFiles.ts new file mode 100644 index 0000000000..b2c0dc6758 --- /dev/null +++ b/workspaces/translations/packages/cli/src/lib/i18n/generateFiles.ts @@ -0,0 +1,177 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import path from 'path'; + +import fs from 'fs-extra'; + +export interface TranslationData { + [key: string]: string; +} + +/** + * Nested translation structure: { plugin: { en: { key: value } } } + */ +export interface NestedTranslationData { + [pluginName: string]: { + en: Record; + }; +} + +/** + * Generate translation files in various formats + * Accepts either flat structure (Record) or nested structure (NestedTranslationData) + */ +export async function generateTranslationFiles( + keys: Record | NestedTranslationData, + outputPath: string, + format: string, +): Promise { + const outputDir = path.dirname(outputPath); + await fs.ensureDir(outputDir); + + switch (format.toLowerCase()) { + case 'json': + await generateJsonFile(keys, outputPath); + break; + case 'po': + await generatePoFile(keys, outputPath); + break; + default: + throw new Error(`Unsupported format: ${format}`); + } +} + +/** + * Check if data is in nested structure + */ +function isNestedStructure( + data: Record | NestedTranslationData, +): data is NestedTranslationData { + // Check if it's nested: { plugin: { en: { ... } } } + const firstKey = Object.keys(data)[0]; + if (!firstKey) return false; + const firstValue = data[firstKey]; + return ( + typeof firstValue === 'object' && firstValue !== null && 'en' in firstValue + ); +} + +/** + * Generate JSON translation file + */ +async function generateJsonFile( + keys: Record | NestedTranslationData, + outputPath: string, +): Promise { + // Normalize Unicode curly quotes/apostrophes to standard ASCII equivalents + // This ensures compatibility with TMS systems that might not handle Unicode quotes well + const normalizeValue = (value: string): string => { + return value + .replace(/'/g, "'") // U+2018 LEFT SINGLE QUOTATION MARK โ†’ U+0027 APOSTROPHE + .replace(/'/g, "'") // U+2019 RIGHT SINGLE QUOTATION MARK โ†’ U+0027 APOSTROPHE + .replace(/"/g, '"') // U+201C LEFT DOUBLE QUOTATION MARK โ†’ U+0022 QUOTATION MARK + .replace(/"/g, '"'); // U+201D RIGHT DOUBLE QUOTATION MARK โ†’ U+0022 QUOTATION MARK + }; + + if (isNestedStructure(keys)) { + // New nested structure: { plugin: { en: { key: value } } } + const normalizedData: NestedTranslationData = {}; + + for (const [pluginName, pluginData] of Object.entries(keys)) { + normalizedData[pluginName] = { + en: {}, + }; + + for (const [key, value] of Object.entries(pluginData.en)) { + normalizedData[pluginName].en[key] = normalizeValue(value); + } + } + + // Write nested structure directly (no metadata wrapper) + await fs.writeJson(outputPath, normalizedData, { spaces: 2 }); + } else { + // Legacy flat structure: { key: value } + const normalizedKeys: Record = {}; + for (const [key, value] of Object.entries(keys)) { + normalizedKeys[key] = normalizeValue(value); + } + + const data = { + metadata: { + generated: new Date().toISOString(), + version: '1.0', + totalKeys: Object.keys(normalizedKeys).length, + }, + translations: normalizedKeys, + }; + + await fs.writeJson(outputPath, data, { spaces: 2 }); + } +} + +/** + * Generate PO (Portable Object) translation file + */ +async function generatePoFile( + keys: Record | NestedTranslationData, + outputPath: string, +): Promise { + const lines: string[] = []; + + // Flatten nested structure if needed + let flatKeys: Record; + if (isNestedStructure(keys)) { + flatKeys = {}; + for (const [pluginName, pluginData] of Object.entries(keys)) { + for (const [key, value] of Object.entries(pluginData.en)) { + // Use plugin.key format for PO files to maintain structure + flatKeys[`${pluginName}.${key}`] = value; + } + } + } else { + flatKeys = keys; + } + + // PO file header + lines.push('msgid ""'); + lines.push('msgstr ""'); + lines.push('"Content-Type: text/plain; charset=UTF-8\\n"'); + lines.push(`"Generated: ${new Date().toISOString()}\\n"`); + lines.push(`"Total-Keys: ${Object.keys(flatKeys).length}\\n"`); + lines.push(''); + + // Translation entries + for (const [key, value] of Object.entries(flatKeys)) { + lines.push(`msgid "${escapePoString(key)}"`); + lines.push(`msgstr "${escapePoString(value)}"`); + lines.push(''); + } + + await fs.writeFile(outputPath, lines.join('\n'), 'utf-8'); +} + +/** + * Escape string for PO format + */ +function escapePoString(str: string): string { + return str + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"') + .replace(/\n/g, '\\n') + .replace(/\r/g, '\\r') + .replace(/\t/g, '\\t'); +} diff --git a/workspaces/translations/packages/cli/src/lib/i18n/loadFile.ts b/workspaces/translations/packages/cli/src/lib/i18n/loadFile.ts new file mode 100644 index 0000000000..32f67778de --- /dev/null +++ b/workspaces/translations/packages/cli/src/lib/i18n/loadFile.ts @@ -0,0 +1,139 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import path from 'path'; + +import fs from 'fs-extra'; + +export interface TranslationData { + [key: string]: string; +} + +/** + * Load translation file in various formats + */ +export async function loadTranslationFile( + filePath: string, + format: string, +): Promise { + const ext = path.extname(filePath).toLowerCase(); + const actualFormat = ext.substring(1); // Remove the dot + + if (actualFormat !== format.toLowerCase()) { + throw new Error( + `File format mismatch: expected ${format}, got ${actualFormat}`, + ); + } + + switch (format.toLowerCase()) { + case 'json': + return await loadJsonFile(filePath); + case 'po': + return await loadPoFile(filePath); + default: + throw new Error(`Unsupported format: ${format}`); + } +} + +/** + * Load JSON translation file + */ +async function loadJsonFile(filePath: string): Promise { + try { + const data = await fs.readJson(filePath); + + // Handle different JSON structures + if (data.translations && typeof data.translations === 'object') { + return data.translations; + } + + if (typeof data === 'object' && data !== null) { + return data; + } + + return {}; + } catch (error) { + throw new Error(`Failed to load JSON file ${filePath}: ${error}`); + } +} + +/** + * Load PO translation file + */ +async function loadPoFile(filePath: string): Promise { + try { + const content = await fs.readFile(filePath, 'utf-8'); + const data: TranslationData = {}; + + const lines = content.split('\n'); + let currentKey = ''; + let currentValue = ''; + let inMsgId = false; + let inMsgStr = false; + + for (const line of lines) { + const trimmed = line.trim(); + + if (trimmed.startsWith('msgid ')) { + // Save previous entry if exists + if (currentKey && currentValue) { + data[currentKey] = currentValue; + } + + currentKey = unescapePoString( + trimmed.substring(6).replace(/^["']|["']$/g, ''), + ); + currentValue = ''; + inMsgId = true; + inMsgStr = false; + } else if (trimmed.startsWith('msgstr ')) { + currentValue = unescapePoString( + trimmed.substring(7).replace(/^["']|["']$/g, ''), + ); + inMsgId = false; + inMsgStr = true; + } else if (trimmed.startsWith('"') && (inMsgId || inMsgStr)) { + const value = unescapePoString(trimmed.replace(/^["']|["']$/g, '')); + if (inMsgId) { + currentKey += value; + } else if (inMsgStr) { + currentValue += value; + } + } + } + + // Add the last entry + if (currentKey && currentValue) { + data[currentKey] = currentValue; + } + + return data; + } catch (error) { + throw new Error(`Failed to load PO file ${filePath}: ${error}`); + } +} + +/** + * Unescape string from PO format + */ +function unescapePoString(str: string): string { + return str + .replace(/\\n/g, '\n') + .replace(/\\r/g, '\r') + .replace(/\\t/g, '\t') + .replace(/\\"/g, '"') + .replace(/\\\\/g, '\\'); +} diff --git a/workspaces/translations/packages/cli/src/lib/i18n/mergeFiles.ts b/workspaces/translations/packages/cli/src/lib/i18n/mergeFiles.ts new file mode 100644 index 0000000000..e949b6a833 --- /dev/null +++ b/workspaces/translations/packages/cli/src/lib/i18n/mergeFiles.ts @@ -0,0 +1,281 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import fs from 'fs-extra'; + +export interface TranslationData { + [key: string]: string; +} + +/** + * Nested translation structure: { plugin: { en: { key: value } } } + */ +export interface NestedTranslationData { + [pluginName: string]: { + en: Record; + }; +} + +/** + * Check if data is in nested structure + */ +function isNestedStructure(data: unknown): data is NestedTranslationData { + if (typeof data !== 'object' || data === null) return false; + const firstKey = Object.keys(data)[0]; + if (!firstKey) return false; + const firstValue = (data as Record)[firstKey]; + return ( + typeof firstValue === 'object' && firstValue !== null && 'en' in firstValue + ); +} + +/** + * Merge translation keys with existing translation file + * Supports both flat and nested structures + */ +export async function mergeTranslationFiles( + newKeys: Record | NestedTranslationData, + existingPath: string, + format: string, +): Promise { + if (!(await fs.pathExists(existingPath))) { + throw new Error(`Existing file not found: ${existingPath}`); + } + + let existingData: unknown = {}; + + try { + switch (format.toLowerCase()) { + case 'json': + existingData = await loadJsonFile(existingPath); + break; + case 'po': + existingData = await loadPoFile(existingPath); + break; + default: + throw new Error(`Unsupported format: ${format}`); + } + } catch (error) { + console.warn( + `Warning: Could not load existing file ${existingPath}: ${error}`, + ); + existingData = {}; + } + + // Handle merging based on structure + if (isNestedStructure(newKeys)) { + // New keys are in nested structure + let mergedData: NestedTranslationData; + + if (isNestedStructure(existingData)) { + // Both are nested - merge plugin by plugin + mergedData = { ...existingData }; + for (const [pluginName, pluginData] of Object.entries(newKeys)) { + if (mergedData[pluginName]) { + // Merge keys within the plugin + mergedData[pluginName] = { + en: { ...mergedData[pluginName].en, ...pluginData.en }, + }; + } else { + // New plugin + mergedData[pluginName] = pluginData; + } + } + } else { + // Existing is flat, new is nested - convert existing to nested and merge + // This is a migration scenario - we'll use the new nested structure + mergedData = newKeys; + } + + // Save merged nested data + await saveNestedJsonFile(mergedData, existingPath); + } else { + // New keys are flat (legacy) + const existingFlat = isNestedStructure(existingData) + ? {} // Can't merge flat with nested - use new keys only + : (existingData as TranslationData); + + const mergedData = { ...existingFlat, ...newKeys }; + + // Save merged flat data + switch (format.toLowerCase()) { + case 'json': + await saveJsonFile(mergedData, existingPath); + break; + case 'po': + await savePoFile(mergedData, existingPath); + break; + default: + throw new Error(`Unsupported format: ${format}`); + } + } +} + +/** + * Load JSON translation file (returns either flat or nested structure) + */ +async function loadJsonFile( + filePath: string, +): Promise { + const data = await fs.readJson(filePath); + + // Check if it's nested structure + if (isNestedStructure(data)) { + return data; + } + + // Check if it has translations wrapper (legacy flat structure) + if (data.translations && typeof data.translations === 'object') { + return data.translations as TranslationData; + } + + // Assume flat structure + if (typeof data === 'object' && data !== null) { + return data as TranslationData; + } + + return {}; +} + +/** + * Save JSON translation file (flat structure with metadata) + */ +async function saveJsonFile( + data: TranslationData, + filePath: string, +): Promise { + const output = { + metadata: { + generated: new Date().toISOString(), + version: '1.0', + totalKeys: Object.keys(data).length, + }, + translations: data, + }; + + await fs.writeJson(filePath, output, { spaces: 2 }); +} + +/** + * Save nested JSON translation file (no metadata wrapper) + */ +async function saveNestedJsonFile( + data: NestedTranslationData, + filePath: string, +): Promise { + await fs.writeJson(filePath, data, { spaces: 2 }); +} + +/** + * Load PO translation file + */ +async function loadPoFile(filePath: string): Promise { + const content = await fs.readFile(filePath, 'utf-8'); + const data: TranslationData = {}; + + const lines = content.split('\n'); + let currentKey = ''; + let currentValue = ''; + let inMsgId = false; + let inMsgStr = false; + + for (const line of lines) { + const trimmed = line.trim(); + + if (trimmed.startsWith('msgid ')) { + if (currentKey && currentValue) { + data[currentKey] = currentValue; + } + currentKey = unescapePoString( + trimmed.substring(6).replace(/^["']|["']$/g, ''), + ); + currentValue = ''; + inMsgId = true; + inMsgStr = false; + } else if (trimmed.startsWith('msgstr ')) { + currentValue = unescapePoString( + trimmed.substring(7).replace(/^["']|["']$/g, ''), + ); + inMsgId = false; + inMsgStr = true; + } else if (trimmed.startsWith('"') && (inMsgId || inMsgStr)) { + const value = unescapePoString(trimmed.replace(/^["']|["']$/g, '')); + if (inMsgId) { + currentKey += value; + } else if (inMsgStr) { + currentValue += value; + } + } + } + + // Add the last entry + if (currentKey && currentValue) { + data[currentKey] = currentValue; + } + + return data; +} + +/** + * Save PO translation file + */ +async function savePoFile( + data: TranslationData, + filePath: string, +): Promise { + const lines: string[] = []; + + // PO file header + lines.push('msgid ""'); + lines.push('msgstr ""'); + lines.push('"Content-Type: text/plain; charset=UTF-8\\n"'); + lines.push(`"Generated: ${new Date().toISOString()}\\n"`); + lines.push(`"Total-Keys: ${Object.keys(data).length}\\n"`); + lines.push(''); + + // Translation entries + for (const [key, value] of Object.entries(data)) { + lines.push(`msgid "${escapePoString(key)}"`); + lines.push(`msgstr "${escapePoString(value)}"`); + lines.push(''); + } + + await fs.writeFile(filePath, lines.join('\n'), 'utf-8'); +} + +/** + * Escape string for PO format + */ +function escapePoString(str: string): string { + return str + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"') + .replace(/\n/g, '\\n') + .replace(/\r/g, '\\r') + .replace(/\t/g, '\\t'); +} + +/** + * Unescape string from PO format + */ +function unescapePoString(str: string): string { + return str + .replace(/\\n/g, '\n') + .replace(/\\r/g, '\r') + .replace(/\\t/g, '\t') + .replace(/\\"/g, '"') + .replace(/\\\\/g, '\\'); +} diff --git a/workspaces/translations/packages/cli/src/lib/i18n/saveFile.ts b/workspaces/translations/packages/cli/src/lib/i18n/saveFile.ts new file mode 100644 index 0000000000..4f77e6b8c3 --- /dev/null +++ b/workspaces/translations/packages/cli/src/lib/i18n/saveFile.ts @@ -0,0 +1,104 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import path from 'path'; + +import fs from 'fs-extra'; + +export interface TranslationData { + [key: string]: string; +} + +/** + * Save translation file in various formats + */ +export async function saveTranslationFile( + data: TranslationData, + filePath: string, + format: string, +): Promise { + const outputDir = path.dirname(filePath); + await fs.ensureDir(outputDir); + + switch (format.toLowerCase()) { + case 'json': + await saveJsonFile(data, filePath); + break; + case 'po': + await savePoFile(data, filePath); + break; + default: + throw new Error(`Unsupported format: ${format}`); + } +} + +/** + * Save JSON translation file + */ +async function saveJsonFile( + data: TranslationData, + filePath: string, +): Promise { + const output = { + metadata: { + generated: new Date().toISOString(), + version: '1.0', + totalKeys: Object.keys(data).length, + }, + translations: data, + }; + + await fs.writeJson(filePath, output, { spaces: 2 }); +} + +/** + * Save PO translation file + */ +async function savePoFile( + data: TranslationData, + filePath: string, +): Promise { + const lines: string[] = []; + + // PO file header + lines.push('msgid ""'); + lines.push('msgstr ""'); + lines.push('"Content-Type: text/plain; charset=UTF-8\\n"'); + lines.push(`"Generated: ${new Date().toISOString()}\\n"`); + lines.push(`"Total-Keys: ${Object.keys(data).length}\\n"`); + lines.push(''); + + // Translation entries + for (const [key, value] of Object.entries(data)) { + lines.push(`msgid "${escapePoString(key)}"`); + lines.push(`msgstr "${escapePoString(value)}"`); + lines.push(''); + } + + await fs.writeFile(filePath, lines.join('\n'), 'utf-8'); +} + +/** + * Escape string for PO format + */ +function escapePoString(str: string): string { + return str + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"') + .replace(/\n/g, '\\n') + .replace(/\r/g, '\\r') + .replace(/\t/g, '\\t'); +} diff --git a/workspaces/translations/packages/cli/src/lib/i18n/tmsClient.ts b/workspaces/translations/packages/cli/src/lib/i18n/tmsClient.ts new file mode 100644 index 0000000000..7febb37665 --- /dev/null +++ b/workspaces/translations/packages/cli/src/lib/i18n/tmsClient.ts @@ -0,0 +1,230 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import axios, { AxiosInstance } from 'axios'; + +export interface TMSProject { + id: string; + name: string; + languages: string[]; + status: string; +} + +export interface TMSUploadResult { + projectName: string; + fileName: string; + keyCount: number; + languages: string[]; + translationJobId?: string; +} + +export interface TMSDownloadOptions { + includeCompleted: boolean; + includeDraft: boolean; + format: string; +} + +export class TMSClient { + private client: AxiosInstance; + private baseUrl: string; + // private token: string; + + constructor(baseUrl: string, token: string) { + // Normalize URL: if it's a web UI URL, convert to API URL + // Web UI: https://cloud.memsource.com/web/project2/show/... + // Base: https://cloud.memsource.com/web + // API: https://cloud.memsource.com/web/api2 (Memsource uses /api2 for v2 API) + let normalizedUrl = baseUrl.replace(/\/$/, ''); // Remove trailing slash + + // If URL contains web UI paths, extract base and use API endpoint + if ( + normalizedUrl.includes('/project2/show/') || + normalizedUrl.includes('/project/') + ) { + // Extract base URL (e.g., https://cloud.memsource.com/web) + const urlMatch = normalizedUrl.match(/^(https?:\/\/[^\/]+\/web)/); + if (urlMatch) { + normalizedUrl = `${urlMatch[1]}/api2`; // Memsource uses /api2 + } else { + // Fallback: try to extract domain and use /web/api2 + const domainMatch = normalizedUrl.match(/^(https?:\/\/[^\/]+)/); + if (domainMatch) { + normalizedUrl = `${domainMatch[1]}/web/api2`; + } + } + } else if ( + normalizedUrl === 'https://cloud.memsource.com/web' || + normalizedUrl.endsWith('/web') + ) { + // If it's the base web URL, append /api2 (Memsource API v2) + normalizedUrl = `${normalizedUrl}/api2`; + } else if (!normalizedUrl.includes('/api')) { + // If URL doesn't contain /api and isn't the base web URL, append /api2 + normalizedUrl = `${normalizedUrl}/api2`; + } else if ( + normalizedUrl.includes('/api') && + !normalizedUrl.includes('/api2') + ) { + // If it has /api but not /api2, replace with /api2 + normalizedUrl = normalizedUrl.replace(/\/api(\/|$)/, '/api2$1'); + } + + this.baseUrl = normalizedUrl; + + this.client = axios.create({ + baseURL: this.baseUrl, + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + timeout: 30000, // 30 seconds + }); + } + + /** + * Test connection to TMS + * For Memsource, we skip health check and verify connection by trying to get project info instead + */ + async testConnection(): Promise { + // Memsource API doesn't have a standard /health endpoint + // Connection will be tested when we actually make API calls + // This is a no-op for now - actual connection test happens in API calls + return Promise.resolve(); + } + + /** + * Get project information + */ + async getProjectInfo(projectId: string): Promise { + try { + // baseURL already includes /api, so use /projects/{id} + const response = await this.client.get(`/projects/${projectId}`); + return response.data; + } catch (error) { + throw new Error(`Failed to get project info: ${error}`); + } + } + + /** + * Upload translation file to TMS + */ + async uploadTranslationFile( + projectId: string, + content: string, + fileExtension: string, + targetLanguages?: string[], + fileName?: string, + ): Promise { + try { + const formData = new FormData(); + const blob = new Blob([content], { type: 'application/json' }); + // Use provided filename or default to "reference" + const uploadFileName = fileName || `reference${fileExtension}`; + formData.append('file', blob, uploadFileName); + formData.append('projectId', projectId); + + if (targetLanguages && targetLanguages.length > 0) { + formData.append('targetLanguages', targetLanguages.join(',')); + } + + // baseURL already includes /api, so use /translations/upload + const response = await this.client.post( + '/translations/upload', + formData, + { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }, + ); + + return response.data; + } catch (error) { + throw new Error(`Failed to upload translation file: ${error}`); + } + } + + /** + * Download translations from TMS + */ + async downloadTranslations( + projectId: string, + language: string, + options: TMSDownloadOptions, + ): Promise> { + try { + const params = new URLSearchParams({ + projectId, + language, + includeCompleted: options.includeCompleted.toString(), + includeDraft: options.includeDraft.toString(), + format: options.format, + }); + + // baseURL already includes /api, so use /translations/download + const response = await this.client.get( + `/translations/download?${params}`, + ); + return response.data; + } catch (error) { + throw new Error(`Failed to download translations: ${error}`); + } + } + + /** + * Get translation status for a project + */ + async getTranslationStatus(projectId: string): Promise<{ + totalKeys: number; + completedKeys: number; + languages: { [language: string]: { completed: number; total: number } }; + }> { + try { + // baseURL already includes /api, so use /projects/{id}/status + const response = await this.client.get(`/projects/${projectId}/status`); + return response.data; + } catch (error) { + throw new Error(`Failed to get translation status: ${error}`); + } + } + + /** + * List available projects + */ + async listProjects(): Promise { + try { + const response = await this.client.get('/api/projects'); + return response.data; + } catch (error) { + throw new Error(`Failed to list projects: ${error}`); + } + } + + /** + * Create a new translation project + */ + async createProject(name: string, languages: string[]): Promise { + try { + const response = await this.client.post('/api/projects', { + name, + languages, + }); + return response.data; + } catch (error) { + throw new Error(`Failed to create project: ${error}`); + } + } +} diff --git a/workspaces/translations/packages/cli/src/lib/i18n/uploadCache.ts b/workspaces/translations/packages/cli/src/lib/i18n/uploadCache.ts new file mode 100644 index 0000000000..44f0096123 --- /dev/null +++ b/workspaces/translations/packages/cli/src/lib/i18n/uploadCache.ts @@ -0,0 +1,192 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createHash } from 'crypto'; +import path from 'path'; + +import fs from 'fs-extra'; + +export interface UploadCacheEntry { + filePath: string; + fileHash: string; + projectId: string; + tmsUrl: string; + uploadedAt: string; + keyCount: number; + uploadFileName?: string; // Track the actual filename uploaded to Memsource +} + +/** + * Get cache directory path + */ +function getCacheDir(): string { + return path.join(process.cwd(), '.i18n-cache'); +} + +/** + * Get cache file path for a project + */ +function getCacheFilePath(projectId: string, tmsUrl: string): string { + const cacheDir = getCacheDir(); + // Create a safe filename from projectId and URL + const safeProjectId = projectId.replace(/[^a-zA-Z0-9]/g, '_'); + const urlHash = createHash('md5') + .update(tmsUrl) + .digest('hex') + .substring(0, 8); + return path.join(cacheDir, `upload_${safeProjectId}_${urlHash}.json`); +} + +/** + * Calculate file hash (SHA-256) + */ +export async function calculateFileHash(filePath: string): Promise { + const content = await fs.readFile(filePath, 'utf-8'); + // Normalize content (remove metadata that changes on each generation) + const data = JSON.parse(content); + // Remove metadata that changes on each generation + if (data.metadata) { + delete data.metadata.generated; + } + const normalizedContent = JSON.stringify(data, null, 2); + + return createHash('sha256').update(normalizedContent).digest('hex'); +} + +/** + * Get cached upload entry for a file + */ +export async function getCachedUpload( + filePath: string, + projectId: string, + tmsUrl: string, +): Promise { + try { + const cacheFilePath = getCacheFilePath(projectId, tmsUrl); + + if (!(await fs.pathExists(cacheFilePath))) { + return null; + } + + const cacheData = await fs.readJson(cacheFilePath); + const entry: UploadCacheEntry = cacheData; + + // Verify the entry is for the same file path + if (entry.filePath !== filePath) { + return null; + } + + return entry; + } catch { + // If cache file is corrupted, return null (will re-upload) + return null; + } +} + +/** + * Check if file has changed since last upload + */ +export async function hasFileChanged( + filePath: string, + projectId: string, + tmsUrl: string, +): Promise { + const cachedEntry = await getCachedUpload(filePath, projectId, tmsUrl); + + if (!cachedEntry) { + return true; // No cache, consider it changed + } + + // Check if file still exists + if (!(await fs.pathExists(filePath))) { + return true; + } + + // Calculate current file hash + const currentHash = await calculateFileHash(filePath); + + // Compare with cached hash + return currentHash !== cachedEntry.fileHash; +} + +/** + * Save upload cache entry + */ +export async function saveUploadCache( + filePath: string, + projectId: string, + tmsUrl: string, + keyCount: number, + uploadFileName?: string, +): Promise { + try { + const cacheDir = getCacheDir(); + await fs.ensureDir(cacheDir); + + const fileHash = await calculateFileHash(filePath); + const cacheEntry: UploadCacheEntry = { + filePath, + fileHash, + projectId, + tmsUrl, + uploadedAt: new Date().toISOString(), + keyCount, + uploadFileName, // Store the upload filename to track what was actually uploaded + }; + + const cacheFilePath = getCacheFilePath(projectId, tmsUrl); + await fs.writeJson(cacheFilePath, cacheEntry, { spaces: 2 }); + } catch (error) { + // Don't fail upload if cache save fails + console.warn(`Warning: Failed to save upload cache: ${error}`); + } +} + +/** + * Clear upload cache for a project + */ +export async function clearUploadCache( + projectId: string, + tmsUrl: string, +): Promise { + try { + const cacheFilePath = getCacheFilePath(projectId, tmsUrl); + if (await fs.pathExists(cacheFilePath)) { + await fs.remove(cacheFilePath); + } + } catch { + // Ignore errors when clearing cache + } +} + +/** + * Clear all upload caches + */ +export async function clearAllUploadCaches(): Promise { + try { + const cacheDir = getCacheDir(); + if (await fs.pathExists(cacheDir)) { + const files = await fs.readdir(cacheDir); + for (const file of files) { + if (file.startsWith('upload_') && file.endsWith('.json')) { + await fs.remove(path.join(cacheDir, file)); + } + } + } + } catch { + // Ignore errors when clearing cache + } +} diff --git a/workspaces/translations/packages/cli/src/lib/i18n/validateData.ts b/workspaces/translations/packages/cli/src/lib/i18n/validateData.ts new file mode 100644 index 0000000000..d861892d88 --- /dev/null +++ b/workspaces/translations/packages/cli/src/lib/i18n/validateData.ts @@ -0,0 +1,145 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export interface ValidationResult { + isValid: boolean; + errors: string[]; + warnings: string[]; +} + +/** + * Validate translation data + */ +export async function validateTranslationData( + data: Record, + language: string, +): Promise { + const result: ValidationResult = { + isValid: true, + errors: [], + warnings: [], + }; + + // Check for empty keys + const emptyKeys = Object.entries(data).filter( + ([key]) => !key || key.trim() === '', + ); + if (emptyKeys.length > 0) { + result.errors.push(`Found ${emptyKeys.length} empty keys`); + result.isValid = false; + } + + // Check for empty values + const emptyValues = Object.entries(data).filter( + ([, value]) => !value || value.trim() === '', + ); + if (emptyValues.length > 0) { + result.warnings.push( + `Found ${emptyValues.length} empty values for language ${language}`, + ); + } + + // Check for duplicate keys + const keys = Object.keys(data); + const uniqueKeys = new Set(keys); + if (keys.length !== uniqueKeys.size) { + result.errors.push(`Found duplicate keys`); + result.isValid = false; + } + + // Check for very long keys (potential issues) + const longKeys = Object.entries(data).filter(([key]) => key.length > 100); + if (longKeys.length > 0) { + result.warnings.push( + `Found ${longKeys.length} very long keys (>100 chars)`, + ); + } + + // Check for very long values (potential issues) + const longValues = Object.entries(data).filter( + ([, value]) => value.length > 500, + ); + if (longValues.length > 0) { + result.warnings.push( + `Found ${longValues.length} very long values (>500 chars)`, + ); + } + + // Check for keys with special characters that might cause issues + const specialCharKeys = Object.entries(data).filter(([key]) => + /[<>{}[\]()\\\/]/.test(key), + ); + if (specialCharKeys.length > 0) { + result.warnings.push( + `Found ${specialCharKeys.length} keys with special characters`, + ); + } + + // Check for missing translations (keys that are the same as values) + const missingTranslations = Object.entries(data).filter( + ([key, value]) => key === value, + ); + if (missingTranslations.length > 0) { + result.warnings.push( + `Found ${missingTranslations.length} keys that match their values (possible missing translations)`, + ); + } + + // Check for HTML tags in translations + const htmlTags = Object.entries(data).filter(([, value]) => + /<[^>]*>/.test(value), + ); + if (htmlTags.length > 0) { + result.warnings.push(`Found ${htmlTags.length} values with HTML tags`); + } + + // Check for placeholder patterns + const placeholderPatterns = Object.entries(data).filter(([, value]) => + /\{\{|\$\{|\%\{|\{.*\}/.test(value), + ); + if (placeholderPatterns.length > 0) { + result.warnings.push( + `Found ${placeholderPatterns.length} values with placeholder patterns`, + ); + } + + // Check for Unicode curly apostrophes/quotes (typographic quotes) + // U+2018: LEFT SINGLE QUOTATION MARK (') + // U+2019: RIGHT SINGLE QUOTATION MARK (') + // U+201C: LEFT DOUBLE QUOTATION MARK (") + // U+201D: RIGHT DOUBLE QUOTATION MARK (") + const curlyApostrophes = Object.entries(data).filter(([, value]) => + /['']/.test(value), + ); + if (curlyApostrophes.length > 0) { + result.warnings.push( + `Found ${curlyApostrophes.length} values with Unicode curly apostrophes (', ') - ` + + `consider normalizing to standard apostrophe (')`, + ); + } + + const curlyQuotes = Object.entries(data).filter(([, value]) => + /[""]/.test(value), + ); + if (curlyQuotes.length > 0) { + result.warnings.push( + `Found ${curlyQuotes.length} values with Unicode curly quotes (", ") - ` + + `consider normalizing to standard quotes (")`, + ); + } + + return result; +} diff --git a/workspaces/translations/packages/cli/src/lib/i18n/validateFile.ts b/workspaces/translations/packages/cli/src/lib/i18n/validateFile.ts new file mode 100644 index 0000000000..ceb4f8de44 --- /dev/null +++ b/workspaces/translations/packages/cli/src/lib/i18n/validateFile.ts @@ -0,0 +1,275 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import path from 'path'; + +import fs from 'fs-extra'; + +export interface ValidationResult { + isValid: boolean; + errors: string[]; + warnings: string[]; +} + +/** + * Validate translation file format and content + */ +export async function validateTranslationFile( + filePath: string, +): Promise { + try { + const ext = path.extname(filePath).toLowerCase(); + + switch (ext) { + case '.json': + return await validateJsonFile(filePath); + case '.po': + return await validatePoFile(filePath); + default: + throw new Error(`Unsupported file format: ${ext}`); + } + } catch (error) { + console.error(`Validation error for ${filePath}:`, error); + return false; + } +} + +/** + * Validate JSON translation file + */ +async function validateJsonFile(filePath: string): Promise { + try { + const content = await fs.readFile(filePath, 'utf-8'); + + // Check for invalid unicode sequences + if (!isValidUTF8(content)) { + throw new Error('File contains invalid UTF-8 sequences'); + } + + // Check for null bytes which are never valid in JSON strings + if (content.includes('\x00')) { + throw new Error( + 'File contains null bytes (\\x00) which are not valid in JSON', + ); + } + + // Try to parse JSON - this will catch syntax errors + let data: Record; + try { + data = JSON.parse(content) as Record; + } catch (parseError) { + throw new Error( + `JSON parse error: ${ + parseError instanceof Error ? parseError.message : 'Unknown error' + }`, + ); + } + + // Check if it's a valid JSON object + if (typeof data !== 'object' || data === null) { + throw new Error('Root element must be a JSON object'); + } + + // Check if it's nested structure: { plugin: { en: { keys } } } + const isNested = ( + obj: unknown, + ): obj is Record }> => { + if (typeof obj !== 'object' || obj === null) return false; + const firstKey = Object.keys(obj)[0]; + if (!firstKey) return false; + const firstValue = (obj as Record)[firstKey]; + return ( + typeof firstValue === 'object' && + firstValue !== null && + 'en' in firstValue + ); + }; + + let totalKeys = 0; + + if (isNested(data)) { + // Nested structure: { plugin: { en: { key: value } } } + for (const [pluginName, pluginData] of Object.entries(data)) { + if (typeof pluginData !== 'object' || pluginData === null) { + throw new Error(`Plugin "${pluginName}" must be an object`); + } + + if (!('en' in pluginData)) { + throw new Error(`Plugin "${pluginName}" must have an "en" property`); + } + + const enData = pluginData.en; + if (typeof enData !== 'object' || enData === null) { + throw new Error(`Plugin "${pluginName}".en must be an object`); + } + + // Validate that all values are strings + for (const [key, value] of Object.entries(enData)) { + if (typeof value !== 'string') { + throw new Error( + `Translation value for "${pluginName}.en.${key}" must be a string, got ${typeof value}`, + ); + } + + // Check for null bytes + if (value.includes('\x00')) { + throw new Error( + `Translation value for "${pluginName}.en.${key}" contains null byte`, + ); + } + + // Check for Unicode curly quotes/apostrophes + const curlyApostrophe = /['']/; + const curlyQuotes = /[""]/; + if (curlyApostrophe.test(value) || curlyQuotes.test(value)) { + console.warn( + `Warning: Translation value for "${pluginName}.en.${key}" contains Unicode curly quotes/apostrophes.`, + ); + console.warn( + ` Consider normalizing to standard quotes: ' โ†’ ' and " โ†’ "`, + ); + } + + totalKeys++; + } + } + } else { + // Legacy structure: { translations: { key: value } } or flat { key: value } + const translations = data.translations || data; + + if (typeof translations !== 'object' || translations === null) { + throw new Error('Translations must be an object'); + } + + // Validate that all values are strings + for (const [key, value] of Object.entries(translations)) { + if (typeof value !== 'string') { + throw new Error( + `Translation value for key "${key}" must be a string, got ${typeof value}`, + ); + } + + // Check for null bytes + if (value.includes('\x00')) { + throw new Error( + `Translation value for key "${key}" contains null byte`, + ); + } + + // Check for Unicode curly quotes/apostrophes + const curlyApostrophe = /['']/; + const curlyQuotes = /[""]/; + if (curlyApostrophe.test(value) || curlyQuotes.test(value)) { + console.warn( + `Warning: Translation value for key "${key}" contains Unicode curly quotes/apostrophes.`, + ); + console.warn( + ` Consider normalizing to standard quotes: ' โ†’ ' and " โ†’ "`, + ); + } + + totalKeys++; + } + } + + // Verify the file can be re-stringified (round-trip test) + const reStringified = JSON.stringify(data, null, 2); + const reparsed = JSON.parse(reStringified); + + // Compare key counts to ensure nothing was lost + let reparsedKeys = 0; + if (isNested(reparsed)) { + for (const pluginData of Object.values(reparsed)) { + if (pluginData.en && typeof pluginData.en === 'object') { + reparsedKeys += Object.keys(pluginData.en).length; + } + } + } else { + const reparsedTranslations = reparsed.translations || reparsed; + reparsedKeys = Object.keys(reparsedTranslations).length; + } + + if (totalKeys !== reparsedKeys) { + throw new Error( + `Key count mismatch: original has ${totalKeys} keys, reparsed has ${reparsedKeys} keys`, + ); + } + + return true; + } catch (error) { + console.error( + `JSON validation error for ${filePath}:`, + error instanceof Error ? error.message : error, + ); + return false; + } +} + +/** + * Check if string is valid UTF-8 + */ +function isValidUTF8(str: string): boolean { + try { + // Try to encode and decode - if it fails, it's invalid UTF-8 + const encoded = Buffer.from(str, 'utf-8'); + const decoded = encoded.toString('utf-8'); + return decoded === str; + } catch { + return false; + } +} + +/** + * Validate PO translation file + */ +async function validatePoFile(filePath: string): Promise { + try { + const content = await fs.readFile(filePath, 'utf-8'); + const lines = content.split('\n'); + + let hasMsgId = false; + let hasMsgStr = false; + let inEntry = false; + + for (const line of lines) { + const trimmed = line.trim(); + + if (trimmed.startsWith('msgid ')) { + hasMsgId = true; + inEntry = true; + } else if (trimmed.startsWith('msgstr ')) { + hasMsgStr = true; + } else if (trimmed === '' && inEntry) { + // End of entry + if (!hasMsgId || !hasMsgStr) { + return false; + } + hasMsgId = false; + hasMsgStr = false; + inEntry = false; + } + } + + // Check final entry if file doesn't end with empty line + if (inEntry && (!hasMsgId || !hasMsgStr)) { + return false; + } + + return true; + } catch { + return false; + } +} diff --git a/workspaces/translations/packages/cli/src/lib/paths.ts b/workspaces/translations/packages/cli/src/lib/paths.ts new file mode 100644 index 0000000000..a12ae854d3 --- /dev/null +++ b/workspaces/translations/packages/cli/src/lib/paths.ts @@ -0,0 +1,30 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +// eslint-disable-next-line no-restricted-syntax +const __dirname = path.dirname(__filename); + +// Simplified paths for translations-cli +export const paths = { + targetDir: process.cwd(), + // eslint-disable-next-line no-restricted-syntax + resolveOwn: (relativePath: string) => + path.resolve(__dirname, '..', '..', relativePath), +}; diff --git a/workspaces/translations/packages/cli/src/lib/version.ts b/workspaces/translations/packages/cli/src/lib/version.ts new file mode 100644 index 0000000000..e18637c75c --- /dev/null +++ b/workspaces/translations/packages/cli/src/lib/version.ts @@ -0,0 +1,55 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import path from 'path'; +import { fileURLToPath } from 'url'; + +import fs from 'fs-extra'; + +function findVersion(): string { + try { + // Try to find package.json relative to this file + // When built, this will be in dist/lib/version.js + // When running from bin, we need to go up to the repo root + const __filename = fileURLToPath(import.meta.url); + // eslint-disable-next-line no-restricted-syntax + const __dirname = path.dirname(__filename); + + // Try multiple possible locations + const possiblePaths = [ + // eslint-disable-next-line no-restricted-syntax + path.resolve(__dirname, '..', '..', 'package.json'), // dist/lib -> dist -> repo root + // eslint-disable-next-line no-restricted-syntax + path.resolve(__dirname, '..', '..', '..', 'package.json'), // dist/lib -> dist -> repo root (alternative) + path.resolve(process.cwd(), 'package.json'), // Current working directory + ]; + + for (const pkgPath of possiblePaths) { + if (fs.existsSync(pkgPath)) { + const pkgContent = fs.readFileSync(pkgPath, 'utf8'); + return JSON.parse(pkgContent).version; + } + } + + // Fallback version if package.json not found + return '0.1.0'; + } catch { + // Fallback version on error + return '0.1.0'; + } +} + +export const version = findVersion(); diff --git a/workspaces/translations/packages/cli/tsconfig.json b/workspaces/translations/packages/cli/tsconfig.json new file mode 100644 index 0000000000..d0908a74d4 --- /dev/null +++ b/workspaces/translations/packages/cli/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "outDir": "dist", + "rootDir": "src", + "declaration": true, + "strict": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "skipLibCheck": true + }, + "include": ["src"] +} diff --git a/workspaces/translations/yarn.lock b/workspaces/translations/yarn.lock index b064b0a015..6a0e800aa5 100644 --- a/workspaces/translations/yarn.lock +++ b/workspaces/translations/yarn.lock @@ -4866,6 +4866,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/aix-ppc64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/aix-ppc64@npm:0.21.5" + conditions: os=aix & cpu=ppc64 + languageName: node + linkType: hard + "@esbuild/aix-ppc64@npm:0.25.9": version: 0.25.9 resolution: "@esbuild/aix-ppc64@npm:0.25.9" @@ -4873,6 +4880,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/android-arm64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/android-arm64@npm:0.21.5" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/android-arm64@npm:0.25.9": version: 0.25.9 resolution: "@esbuild/android-arm64@npm:0.25.9" @@ -4880,6 +4894,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/android-arm@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/android-arm@npm:0.21.5" + conditions: os=android & cpu=arm + languageName: node + linkType: hard + "@esbuild/android-arm@npm:0.25.9": version: 0.25.9 resolution: "@esbuild/android-arm@npm:0.25.9" @@ -4887,6 +4908,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/android-x64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/android-x64@npm:0.21.5" + conditions: os=android & cpu=x64 + languageName: node + linkType: hard + "@esbuild/android-x64@npm:0.25.9": version: 0.25.9 resolution: "@esbuild/android-x64@npm:0.25.9" @@ -4894,6 +4922,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/darwin-arm64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/darwin-arm64@npm:0.21.5" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/darwin-arm64@npm:0.25.9": version: 0.25.9 resolution: "@esbuild/darwin-arm64@npm:0.25.9" @@ -4901,6 +4936,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/darwin-x64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/darwin-x64@npm:0.21.5" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + "@esbuild/darwin-x64@npm:0.25.9": version: 0.25.9 resolution: "@esbuild/darwin-x64@npm:0.25.9" @@ -4908,6 +4950,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/freebsd-arm64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/freebsd-arm64@npm:0.21.5" + conditions: os=freebsd & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/freebsd-arm64@npm:0.25.9": version: 0.25.9 resolution: "@esbuild/freebsd-arm64@npm:0.25.9" @@ -4915,6 +4964,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/freebsd-x64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/freebsd-x64@npm:0.21.5" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + "@esbuild/freebsd-x64@npm:0.25.9": version: 0.25.9 resolution: "@esbuild/freebsd-x64@npm:0.25.9" @@ -4922,6 +4978,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-arm64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/linux-arm64@npm:0.21.5" + conditions: os=linux & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/linux-arm64@npm:0.25.9": version: 0.25.9 resolution: "@esbuild/linux-arm64@npm:0.25.9" @@ -4929,6 +4992,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-arm@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/linux-arm@npm:0.21.5" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + "@esbuild/linux-arm@npm:0.25.9": version: 0.25.9 resolution: "@esbuild/linux-arm@npm:0.25.9" @@ -4936,6 +5006,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-ia32@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/linux-ia32@npm:0.21.5" + conditions: os=linux & cpu=ia32 + languageName: node + linkType: hard + "@esbuild/linux-ia32@npm:0.25.9": version: 0.25.9 resolution: "@esbuild/linux-ia32@npm:0.25.9" @@ -4943,6 +5020,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-loong64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/linux-loong64@npm:0.21.5" + conditions: os=linux & cpu=loong64 + languageName: node + linkType: hard + "@esbuild/linux-loong64@npm:0.25.9": version: 0.25.9 resolution: "@esbuild/linux-loong64@npm:0.25.9" @@ -4950,6 +5034,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-mips64el@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/linux-mips64el@npm:0.21.5" + conditions: os=linux & cpu=mips64el + languageName: node + linkType: hard + "@esbuild/linux-mips64el@npm:0.25.9": version: 0.25.9 resolution: "@esbuild/linux-mips64el@npm:0.25.9" @@ -4957,6 +5048,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-ppc64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/linux-ppc64@npm:0.21.5" + conditions: os=linux & cpu=ppc64 + languageName: node + linkType: hard + "@esbuild/linux-ppc64@npm:0.25.9": version: 0.25.9 resolution: "@esbuild/linux-ppc64@npm:0.25.9" @@ -4964,6 +5062,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-riscv64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/linux-riscv64@npm:0.21.5" + conditions: os=linux & cpu=riscv64 + languageName: node + linkType: hard + "@esbuild/linux-riscv64@npm:0.25.9": version: 0.25.9 resolution: "@esbuild/linux-riscv64@npm:0.25.9" @@ -4971,6 +5076,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-s390x@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/linux-s390x@npm:0.21.5" + conditions: os=linux & cpu=s390x + languageName: node + linkType: hard + "@esbuild/linux-s390x@npm:0.25.9": version: 0.25.9 resolution: "@esbuild/linux-s390x@npm:0.25.9" @@ -4978,6 +5090,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-x64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/linux-x64@npm:0.21.5" + conditions: os=linux & cpu=x64 + languageName: node + linkType: hard + "@esbuild/linux-x64@npm:0.25.9": version: 0.25.9 resolution: "@esbuild/linux-x64@npm:0.25.9" @@ -4992,6 +5111,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/netbsd-x64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/netbsd-x64@npm:0.21.5" + conditions: os=netbsd & cpu=x64 + languageName: node + linkType: hard + "@esbuild/netbsd-x64@npm:0.25.9": version: 0.25.9 resolution: "@esbuild/netbsd-x64@npm:0.25.9" @@ -5006,6 +5132,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/openbsd-x64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/openbsd-x64@npm:0.21.5" + conditions: os=openbsd & cpu=x64 + languageName: node + linkType: hard + "@esbuild/openbsd-x64@npm:0.25.9": version: 0.25.9 resolution: "@esbuild/openbsd-x64@npm:0.25.9" @@ -5020,6 +5153,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/sunos-x64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/sunos-x64@npm:0.21.5" + conditions: os=sunos & cpu=x64 + languageName: node + linkType: hard + "@esbuild/sunos-x64@npm:0.25.9": version: 0.25.9 resolution: "@esbuild/sunos-x64@npm:0.25.9" @@ -5027,6 +5167,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/win32-arm64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/win32-arm64@npm:0.21.5" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/win32-arm64@npm:0.25.9": version: 0.25.9 resolution: "@esbuild/win32-arm64@npm:0.25.9" @@ -5034,6 +5181,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/win32-ia32@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/win32-ia32@npm:0.21.5" + conditions: os=win32 & cpu=ia32 + languageName: node + linkType: hard + "@esbuild/win32-ia32@npm:0.25.9": version: 0.25.9 resolution: "@esbuild/win32-ia32@npm:0.25.9" @@ -5041,6 +5195,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/win32-x64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/win32-x64@npm:0.21.5" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + "@esbuild/win32-x64@npm:0.25.9": version: 0.25.9 resolution: "@esbuild/win32-x64@npm:0.25.9" @@ -10425,6 +10586,26 @@ __metadata: languageName: unknown linkType: soft +"@red-hat-developer-hub/translations-cli@workspace:packages/cli": + version: 0.0.0-use.local + resolution: "@red-hat-developer-hub/translations-cli@workspace:packages/cli" + dependencies: + "@backstage/cli": ^0.34.4 + "@types/fs-extra": ^9.0.13 + "@types/glob": ^8.0.0 + "@types/node": ^18.19.34 + axios: ^1.9.0 + chalk: ^4.1.2 + commander: ^12.0.0 + fs-extra: ^10.1.0 + glob: ^8.0.0 + ts-node: ^10.9.2 + vitest: ^1.0.0 + bin: + translations-cli: bin/translations-cli + languageName: unknown + linkType: soft + "@redis/client@npm:^1.6.0": version: 1.6.1 resolution: "@redis/client@npm:1.6.1" @@ -10600,149 +10781,156 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-android-arm-eabi@npm:4.50.2": - version: 4.50.2 - resolution: "@rollup/rollup-android-arm-eabi@npm:4.50.2" +"@rollup/rollup-android-arm-eabi@npm:4.53.3": + version: 4.53.3 + resolution: "@rollup/rollup-android-arm-eabi@npm:4.53.3" conditions: os=android & cpu=arm languageName: node linkType: hard -"@rollup/rollup-android-arm64@npm:4.50.2": - version: 4.50.2 - resolution: "@rollup/rollup-android-arm64@npm:4.50.2" +"@rollup/rollup-android-arm64@npm:4.53.3": + version: 4.53.3 + resolution: "@rollup/rollup-android-arm64@npm:4.53.3" conditions: os=android & cpu=arm64 languageName: node linkType: hard -"@rollup/rollup-darwin-arm64@npm:4.50.2": - version: 4.50.2 - resolution: "@rollup/rollup-darwin-arm64@npm:4.50.2" +"@rollup/rollup-darwin-arm64@npm:4.53.3": + version: 4.53.3 + resolution: "@rollup/rollup-darwin-arm64@npm:4.53.3" conditions: os=darwin & cpu=arm64 languageName: node linkType: hard -"@rollup/rollup-darwin-x64@npm:4.50.2": - version: 4.50.2 - resolution: "@rollup/rollup-darwin-x64@npm:4.50.2" +"@rollup/rollup-darwin-x64@npm:4.53.3": + version: 4.53.3 + resolution: "@rollup/rollup-darwin-x64@npm:4.53.3" conditions: os=darwin & cpu=x64 languageName: node linkType: hard -"@rollup/rollup-freebsd-arm64@npm:4.50.2": - version: 4.50.2 - resolution: "@rollup/rollup-freebsd-arm64@npm:4.50.2" +"@rollup/rollup-freebsd-arm64@npm:4.53.3": + version: 4.53.3 + resolution: "@rollup/rollup-freebsd-arm64@npm:4.53.3" conditions: os=freebsd & cpu=arm64 languageName: node linkType: hard -"@rollup/rollup-freebsd-x64@npm:4.50.2": - version: 4.50.2 - resolution: "@rollup/rollup-freebsd-x64@npm:4.50.2" +"@rollup/rollup-freebsd-x64@npm:4.53.3": + version: 4.53.3 + resolution: "@rollup/rollup-freebsd-x64@npm:4.53.3" conditions: os=freebsd & cpu=x64 languageName: node linkType: hard -"@rollup/rollup-linux-arm-gnueabihf@npm:4.50.2": - version: 4.50.2 - resolution: "@rollup/rollup-linux-arm-gnueabihf@npm:4.50.2" +"@rollup/rollup-linux-arm-gnueabihf@npm:4.53.3": + version: 4.53.3 + resolution: "@rollup/rollup-linux-arm-gnueabihf@npm:4.53.3" conditions: os=linux & cpu=arm & libc=glibc languageName: node linkType: hard -"@rollup/rollup-linux-arm-musleabihf@npm:4.50.2": - version: 4.50.2 - resolution: "@rollup/rollup-linux-arm-musleabihf@npm:4.50.2" +"@rollup/rollup-linux-arm-musleabihf@npm:4.53.3": + version: 4.53.3 + resolution: "@rollup/rollup-linux-arm-musleabihf@npm:4.53.3" conditions: os=linux & cpu=arm & libc=musl languageName: node linkType: hard -"@rollup/rollup-linux-arm64-gnu@npm:4.50.2": - version: 4.50.2 - resolution: "@rollup/rollup-linux-arm64-gnu@npm:4.50.2" +"@rollup/rollup-linux-arm64-gnu@npm:4.53.3": + version: 4.53.3 + resolution: "@rollup/rollup-linux-arm64-gnu@npm:4.53.3" conditions: os=linux & cpu=arm64 & libc=glibc languageName: node linkType: hard -"@rollup/rollup-linux-arm64-musl@npm:4.50.2": - version: 4.50.2 - resolution: "@rollup/rollup-linux-arm64-musl@npm:4.50.2" +"@rollup/rollup-linux-arm64-musl@npm:4.53.3": + version: 4.53.3 + resolution: "@rollup/rollup-linux-arm64-musl@npm:4.53.3" conditions: os=linux & cpu=arm64 & libc=musl languageName: node linkType: hard -"@rollup/rollup-linux-loong64-gnu@npm:4.50.2": - version: 4.50.2 - resolution: "@rollup/rollup-linux-loong64-gnu@npm:4.50.2" +"@rollup/rollup-linux-loong64-gnu@npm:4.53.3": + version: 4.53.3 + resolution: "@rollup/rollup-linux-loong64-gnu@npm:4.53.3" conditions: os=linux & cpu=loong64 & libc=glibc languageName: node linkType: hard -"@rollup/rollup-linux-ppc64-gnu@npm:4.50.2": - version: 4.50.2 - resolution: "@rollup/rollup-linux-ppc64-gnu@npm:4.50.2" +"@rollup/rollup-linux-ppc64-gnu@npm:4.53.3": + version: 4.53.3 + resolution: "@rollup/rollup-linux-ppc64-gnu@npm:4.53.3" conditions: os=linux & cpu=ppc64 & libc=glibc languageName: node linkType: hard -"@rollup/rollup-linux-riscv64-gnu@npm:4.50.2": - version: 4.50.2 - resolution: "@rollup/rollup-linux-riscv64-gnu@npm:4.50.2" +"@rollup/rollup-linux-riscv64-gnu@npm:4.53.3": + version: 4.53.3 + resolution: "@rollup/rollup-linux-riscv64-gnu@npm:4.53.3" conditions: os=linux & cpu=riscv64 & libc=glibc languageName: node linkType: hard -"@rollup/rollup-linux-riscv64-musl@npm:4.50.2": - version: 4.50.2 - resolution: "@rollup/rollup-linux-riscv64-musl@npm:4.50.2" +"@rollup/rollup-linux-riscv64-musl@npm:4.53.3": + version: 4.53.3 + resolution: "@rollup/rollup-linux-riscv64-musl@npm:4.53.3" conditions: os=linux & cpu=riscv64 & libc=musl languageName: node linkType: hard -"@rollup/rollup-linux-s390x-gnu@npm:4.50.2": - version: 4.50.2 - resolution: "@rollup/rollup-linux-s390x-gnu@npm:4.50.2" +"@rollup/rollup-linux-s390x-gnu@npm:4.53.3": + version: 4.53.3 + resolution: "@rollup/rollup-linux-s390x-gnu@npm:4.53.3" conditions: os=linux & cpu=s390x & libc=glibc languageName: node linkType: hard -"@rollup/rollup-linux-x64-gnu@npm:4.50.2": - version: 4.50.2 - resolution: "@rollup/rollup-linux-x64-gnu@npm:4.50.2" +"@rollup/rollup-linux-x64-gnu@npm:4.53.3": + version: 4.53.3 + resolution: "@rollup/rollup-linux-x64-gnu@npm:4.53.3" conditions: os=linux & cpu=x64 & libc=glibc languageName: node linkType: hard -"@rollup/rollup-linux-x64-musl@npm:4.50.2": - version: 4.50.2 - resolution: "@rollup/rollup-linux-x64-musl@npm:4.50.2" +"@rollup/rollup-linux-x64-musl@npm:4.53.3": + version: 4.53.3 + resolution: "@rollup/rollup-linux-x64-musl@npm:4.53.3" conditions: os=linux & cpu=x64 & libc=musl languageName: node linkType: hard -"@rollup/rollup-openharmony-arm64@npm:4.50.2": - version: 4.50.2 - resolution: "@rollup/rollup-openharmony-arm64@npm:4.50.2" +"@rollup/rollup-openharmony-arm64@npm:4.53.3": + version: 4.53.3 + resolution: "@rollup/rollup-openharmony-arm64@npm:4.53.3" conditions: os=openharmony & cpu=arm64 languageName: node linkType: hard -"@rollup/rollup-win32-arm64-msvc@npm:4.50.2": - version: 4.50.2 - resolution: "@rollup/rollup-win32-arm64-msvc@npm:4.50.2" +"@rollup/rollup-win32-arm64-msvc@npm:4.53.3": + version: 4.53.3 + resolution: "@rollup/rollup-win32-arm64-msvc@npm:4.53.3" conditions: os=win32 & cpu=arm64 languageName: node linkType: hard -"@rollup/rollup-win32-ia32-msvc@npm:4.50.2": - version: 4.50.2 - resolution: "@rollup/rollup-win32-ia32-msvc@npm:4.50.2" +"@rollup/rollup-win32-ia32-msvc@npm:4.53.3": + version: 4.53.3 + resolution: "@rollup/rollup-win32-ia32-msvc@npm:4.53.3" conditions: os=win32 & cpu=ia32 languageName: node linkType: hard -"@rollup/rollup-win32-x64-msvc@npm:4.50.2": - version: 4.50.2 - resolution: "@rollup/rollup-win32-x64-msvc@npm:4.50.2" +"@rollup/rollup-win32-x64-gnu@npm:4.53.3": + version: 4.53.3 + resolution: "@rollup/rollup-win32-x64-gnu@npm:4.53.3" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@rollup/rollup-win32-x64-msvc@npm:4.53.3": + version: 4.53.3 + resolution: "@rollup/rollup-win32-x64-msvc@npm:4.53.3" conditions: os=win32 & cpu=x64 languageName: node linkType: hard @@ -13204,6 +13392,25 @@ __metadata: languageName: node linkType: hard +"@types/fs-extra@npm:^9.0.13": + version: 9.0.13 + resolution: "@types/fs-extra@npm:9.0.13" + dependencies: + "@types/node": "*" + checksum: add79e212acd5ac76b97b9045834e03a7996aef60a814185e0459088fd290519a3c1620865d588fa36c4498bf614210d2a703af5cf80aa1dbc125db78f6edac3 + languageName: node + linkType: hard + +"@types/glob@npm:^8.0.0": + version: 8.1.0 + resolution: "@types/glob@npm:8.1.0" + dependencies: + "@types/minimatch": ^5.1.2 + "@types/node": "*" + checksum: 9101f3a9061e40137190f70626aa0e202369b5ec4012c3fabe6f5d229cce04772db9a94fa5a0eb39655e2e4ad105c38afbb4af56a56c0996a8c7d4fc72350e3d + languageName: node + linkType: hard + "@types/graceful-fs@npm:^4.1.3": version: 4.1.9 resolution: "@types/graceful-fs@npm:4.1.9" @@ -13432,6 +13639,13 @@ __metadata: languageName: node linkType: hard +"@types/minimatch@npm:^5.1.2": + version: 5.1.2 + resolution: "@types/minimatch@npm:5.1.2" + checksum: 0391a282860c7cb6fe262c12b99564732401bdaa5e395bee9ca323c312c1a0f45efbf34dce974682036e857db59a5c9b1da522f3d6055aeead7097264c8705a8 + languageName: node + linkType: hard + "@types/ms@npm:*": version: 2.1.0 resolution: "@types/ms@npm:2.1.0" @@ -13483,12 +13697,12 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:^18.11.18, @types/node@npm:^18.11.9": - version: 18.19.125 - resolution: "@types/node@npm:18.19.125" +"@types/node@npm:^18.11.18, @types/node@npm:^18.11.9, @types/node@npm:^18.19.34": + version: 18.19.130 + resolution: "@types/node@npm:18.19.130" dependencies: undici-types: ~5.26.4 - checksum: b05252814da510f2d520569f7296a78a7beec6fc2d4dae4779b6596d70e3138cfaac79c6854a5f845a13a9cfb4374c3445ba3dda70ca6b46ef270a0ac7d70b36 + checksum: b7032363581c416e721a88cffdc2b47662337cacd20f8294f5619a1abf79615c7fef1521964c2aa9d36ed6aae733e1a03e8c704661bd5a0c2f34b390f41ea395 languageName: node linkType: hard @@ -14164,6 +14378,60 @@ __metadata: languageName: node linkType: hard +"@vitest/expect@npm:1.6.1": + version: 1.6.1 + resolution: "@vitest/expect@npm:1.6.1" + dependencies: + "@vitest/spy": 1.6.1 + "@vitest/utils": 1.6.1 + chai: ^4.3.10 + checksum: a9092797b5763b110cdf9d077e25ca3737725a889ef0a7a17850ecfbb5069b417d5aa27b98613d79a4fc928d3a0cfcb76aa2067d3ce0310d3634715d86812b14 + languageName: node + linkType: hard + +"@vitest/runner@npm:1.6.1": + version: 1.6.1 + resolution: "@vitest/runner@npm:1.6.1" + dependencies: + "@vitest/utils": 1.6.1 + p-limit: ^5.0.0 + pathe: ^1.1.1 + checksum: 67968a6430a3d4355519630ac636aed96f9039142e5fd50e261d2750c8dc0817806fac9393cbd59d41eb03fdd0a7b819e5d32f284952f6a09f8e7f98f38841f9 + languageName: node + linkType: hard + +"@vitest/snapshot@npm:1.6.1": + version: 1.6.1 + resolution: "@vitest/snapshot@npm:1.6.1" + dependencies: + magic-string: ^0.30.5 + pathe: ^1.1.1 + pretty-format: ^29.7.0 + checksum: dfd611c57f5ef9d242da543b7f3d53472f05a1b0671bc933b4bcebd9c6230214ce31b23327561df0febd69668fb90cbb0fa86fbdf31cd70f3b3150e12fd0c7a5 + languageName: node + linkType: hard + +"@vitest/spy@npm:1.6.1": + version: 1.6.1 + resolution: "@vitest/spy@npm:1.6.1" + dependencies: + tinyspy: ^2.2.0 + checksum: 1f9d0faac67bd501ff3dd9a416a3bd360593807e6fd77f0e52ca5e77dcc81912f619e8a1b8f5b123982048f39331d80ba5903cb50c21eb724a9a3908f8419c63 + languageName: node + linkType: hard + +"@vitest/utils@npm:1.6.1": + version: 1.6.1 + resolution: "@vitest/utils@npm:1.6.1" + dependencies: + diff-sequences: ^29.6.3 + estree-walker: ^3.0.3 + loupe: ^2.3.7 + pretty-format: ^29.7.0 + checksum: 616e8052acba37ad0c2920e5c434454bca826309eeef71c461b0e1e6c86dcb7ff40b7d1d4e31dbc19ee255357807f61faeb54887032b9fbebc70dc556a038c73 + languageName: node + linkType: hard + "@whatwg-node/disposablestack@npm:^0.0.6": version: 0.0.6 resolution: "@whatwg-node/disposablestack@npm:0.0.6" @@ -14334,7 +14602,7 @@ __metadata: languageName: node linkType: hard -"acorn-walk@npm:^8.0.2, acorn-walk@npm:^8.1.1": +"acorn-walk@npm:^8.0.2, acorn-walk@npm:^8.1.1, acorn-walk@npm:^8.3.2": version: 8.3.4 resolution: "acorn-walk@npm:8.3.4" dependencies: @@ -14982,6 +15250,13 @@ __metadata: languageName: node linkType: hard +"assertion-error@npm:^1.1.0": + version: 1.1.0 + resolution: "assertion-error@npm:1.1.0" + checksum: fd9429d3a3d4fd61782eb3962ae76b6d08aa7383123fca0596020013b3ebd6647891a85b05ce821c47d1471ed1271f00b0545cf6a4326cf2fc91efcc3b0fbecf + languageName: node + linkType: hard + "ast-types-flow@npm:^0.0.8": version: 0.0.8 resolution: "ast-types-flow@npm:0.0.8" @@ -15141,7 +15416,7 @@ __metadata: languageName: node linkType: hard -"axios@npm:^1.0.0, axios@npm:^1.12.2, axios@npm:^1.7.4": +"axios@npm:^1.0.0, axios@npm:^1.12.2, axios@npm:^1.7.4, axios@npm:^1.9.0": version: 1.13.2 resolution: "axios@npm:1.13.2" dependencies: @@ -15913,6 +16188,13 @@ __metadata: languageName: node linkType: hard +"cac@npm:^6.7.14": + version: 6.7.14 + resolution: "cac@npm:6.7.14" + checksum: 45a2496a9443abbe7f52a49b22fbe51b1905eff46e03fd5e6c98e3f85077be3f8949685a1849b1a9cd2bc3e5567dfebcf64f01ce01847baf918f1b37c839791a + languageName: node + linkType: hard + "cacache@npm:^16.1.0": version: 16.1.3 resolution: "cacache@npm:16.1.3" @@ -16099,6 +16381,21 @@ __metadata: languageName: node linkType: hard +"chai@npm:^4.3.10": + version: 4.5.0 + resolution: "chai@npm:4.5.0" + dependencies: + assertion-error: ^1.1.0 + check-error: ^1.0.3 + deep-eql: ^4.1.3 + get-func-name: ^2.0.2 + loupe: ^2.3.6 + pathval: ^1.1.1 + type-detect: ^4.1.0 + checksum: 70e5a8418a39e577e66a441cc0ce4f71fd551a650a71de30dd4e3e31e75ed1f5aa7119cf4baf4a2cb5e85c0c6befdb4d8a05811fad8738c1a6f3aa6a23803821 + languageName: node + linkType: hard + "chalk@npm:2.4.2, chalk@npm:^2.3.2, chalk@npm:^2.4.2": version: 2.4.2 resolution: "chalk@npm:2.4.2" @@ -16186,6 +16483,15 @@ __metadata: languageName: node linkType: hard +"check-error@npm:^1.0.3": + version: 1.0.3 + resolution: "check-error@npm:1.0.3" + dependencies: + get-func-name: ^2.0.2 + checksum: e2131025cf059b21080f4813e55b3c480419256914601750b0fee3bd9b2b8315b531e551ef12560419b8b6d92a3636511322752b1ce905703239e7cc451b6399 + languageName: node + linkType: hard + "check-types@npm:^11.2.3": version: 11.2.3 resolution: "check-types@npm:11.2.3" @@ -16735,6 +17041,13 @@ __metadata: languageName: node linkType: hard +"confbox@npm:^0.1.8": + version: 0.1.8 + resolution: "confbox@npm:0.1.8" + checksum: 5c7718ab22cf9e35a31c21ef124156076ae8c9dc65e6463d54961caf5a1d529284485a0fdf83fd23b27329f3b75b0c8c07d2e36c699f5151a2efe903343f976a + languageName: node + linkType: hard + "connect-history-api-fallback@npm:^2.0.0": version: 2.0.0 resolution: "connect-history-api-fallback@npm:2.0.0" @@ -17748,6 +18061,15 @@ __metadata: languageName: node linkType: hard +"deep-eql@npm:^4.1.3": + version: 4.1.4 + resolution: "deep-eql@npm:4.1.4" + dependencies: + type-detect: ^4.0.0 + checksum: 01c3ca78ff40d79003621b157054871411f94228ceb9b2cab78da913c606631c46e8aa79efc4aa0faf3ace3092acd5221255aab3ef0e8e7b438834f0ca9a16c7 + languageName: node + linkType: hard + "deep-equal@npm:^2.0.5": version: 2.2.3 resolution: "deep-equal@npm:2.2.3" @@ -18710,6 +19032,86 @@ __metadata: languageName: node linkType: hard +"esbuild@npm:^0.21.3": + version: 0.21.5 + resolution: "esbuild@npm:0.21.5" + dependencies: + "@esbuild/aix-ppc64": 0.21.5 + "@esbuild/android-arm": 0.21.5 + "@esbuild/android-arm64": 0.21.5 + "@esbuild/android-x64": 0.21.5 + "@esbuild/darwin-arm64": 0.21.5 + "@esbuild/darwin-x64": 0.21.5 + "@esbuild/freebsd-arm64": 0.21.5 + "@esbuild/freebsd-x64": 0.21.5 + "@esbuild/linux-arm": 0.21.5 + "@esbuild/linux-arm64": 0.21.5 + "@esbuild/linux-ia32": 0.21.5 + "@esbuild/linux-loong64": 0.21.5 + "@esbuild/linux-mips64el": 0.21.5 + "@esbuild/linux-ppc64": 0.21.5 + "@esbuild/linux-riscv64": 0.21.5 + "@esbuild/linux-s390x": 0.21.5 + "@esbuild/linux-x64": 0.21.5 + "@esbuild/netbsd-x64": 0.21.5 + "@esbuild/openbsd-x64": 0.21.5 + "@esbuild/sunos-x64": 0.21.5 + "@esbuild/win32-arm64": 0.21.5 + "@esbuild/win32-ia32": 0.21.5 + "@esbuild/win32-x64": 0.21.5 + dependenciesMeta: + "@esbuild/aix-ppc64": + optional: true + "@esbuild/android-arm": + optional: true + "@esbuild/android-arm64": + optional: true + "@esbuild/android-x64": + optional: true + "@esbuild/darwin-arm64": + optional: true + "@esbuild/darwin-x64": + optional: true + "@esbuild/freebsd-arm64": + optional: true + "@esbuild/freebsd-x64": + optional: true + "@esbuild/linux-arm": + optional: true + "@esbuild/linux-arm64": + optional: true + "@esbuild/linux-ia32": + optional: true + "@esbuild/linux-loong64": + optional: true + "@esbuild/linux-mips64el": + optional: true + "@esbuild/linux-ppc64": + optional: true + "@esbuild/linux-riscv64": + optional: true + "@esbuild/linux-s390x": + optional: true + "@esbuild/linux-x64": + optional: true + "@esbuild/netbsd-x64": + optional: true + "@esbuild/openbsd-x64": + optional: true + "@esbuild/sunos-x64": + optional: true + "@esbuild/win32-arm64": + optional: true + "@esbuild/win32-ia32": + optional: true + "@esbuild/win32-x64": + optional: true + bin: + esbuild: bin/esbuild + checksum: 2911c7b50b23a9df59a7d6d4cdd3a4f85855787f374dce751148dbb13305e0ce7e880dde1608c2ab7a927fc6cec3587b80995f7fc87a64b455f8b70b55fd8ec1 + languageName: node + linkType: hard + "esbuild@npm:^0.25.0": version: 0.25.9 resolution: "esbuild@npm:0.25.9" @@ -19232,6 +19634,15 @@ __metadata: languageName: node linkType: hard +"estree-walker@npm:^3.0.3": + version: 3.0.3 + resolution: "estree-walker@npm:3.0.3" + dependencies: + "@types/estree": ^1.0.0 + checksum: a65728d5727b71de172c5df323385755a16c0fdab8234dc756c3854cfee343261ddfbb72a809a5660fac8c75d960bb3e21aa898c2d7e9b19bb298482ca58a3af + languageName: node + linkType: hard + "esutils@npm:^2.0.2": version: 2.0.3 resolution: "esutils@npm:2.0.3" @@ -19311,6 +19722,23 @@ __metadata: languageName: node linkType: hard +"execa@npm:^8.0.1": + version: 8.0.1 + resolution: "execa@npm:8.0.1" + dependencies: + cross-spawn: ^7.0.3 + get-stream: ^8.0.1 + human-signals: ^5.0.0 + is-stream: ^3.0.0 + merge-stream: ^2.0.0 + npm-run-path: ^5.1.0 + onetime: ^6.0.0 + signal-exit: ^4.1.0 + strip-final-newline: ^3.0.0 + checksum: cac1bf86589d1d9b73bdc5dda65c52012d1a9619c44c526891956745f7b366ca2603d29fe3f7460bacc2b48c6eab5d6a4f7afe0534b31473d3708d1265545e1f + languageName: node + linkType: hard + "exit@npm:^0.1.2": version: 0.1.2 resolution: "exit@npm:0.1.2" @@ -20118,7 +20546,7 @@ __metadata: languageName: node linkType: hard -"fs-extra@npm:^10.0.0": +"fs-extra@npm:^10.0.0, fs-extra@npm:^10.1.0": version: 10.1.0 resolution: "fs-extra@npm:10.1.0" dependencies: @@ -20211,7 +20639,7 @@ __metadata: languageName: node linkType: hard -"fsevents@npm:^2.3.2, fsevents@npm:~2.3.2": +"fsevents@npm:^2.3.2, fsevents@npm:~2.3.2, fsevents@npm:~2.3.3": version: 2.3.3 resolution: "fsevents@npm:2.3.3" dependencies: @@ -20230,7 +20658,7 @@ __metadata: languageName: node linkType: hard -"fsevents@patch:fsevents@^2.3.2#~builtin, fsevents@patch:fsevents@~2.3.2#~builtin": +"fsevents@patch:fsevents@^2.3.2#~builtin, fsevents@patch:fsevents@~2.3.2#~builtin, fsevents@patch:fsevents@~2.3.3#~builtin": version: 2.3.3 resolution: "fsevents@patch:fsevents@npm%3A2.3.3#~builtin::version=2.3.3&hash=df0bf1" dependencies: @@ -20353,6 +20781,13 @@ __metadata: languageName: node linkType: hard +"get-func-name@npm:^2.0.1, get-func-name@npm:^2.0.2": + version: 2.0.2 + resolution: "get-func-name@npm:2.0.2" + checksum: 3f62f4c23647de9d46e6f76d2b3eafe58933a9b3830c60669e4180d6c601ce1b4aa310ba8366143f55e52b139f992087a9f0647274e8745621fa2af7e0acf13b + languageName: node + linkType: hard + "get-intrinsic@npm:^1.1.3, get-intrinsic@npm:^1.2.2, get-intrinsic@npm:^1.2.4, get-intrinsic@npm:^1.2.5, get-intrinsic@npm:^1.2.6, get-intrinsic@npm:^1.2.7, get-intrinsic@npm:^1.3.0": version: 1.3.0 resolution: "get-intrinsic@npm:1.3.0" @@ -20416,6 +20851,13 @@ __metadata: languageName: node linkType: hard +"get-stream@npm:^8.0.1": + version: 8.0.1 + resolution: "get-stream@npm:8.0.1" + checksum: 01e3d3cf29e1393f05f44d2f00445c5f9ec3d1c49e8179b31795484b9c117f4c695e5e07b88b50785d5c8248a788c85d9913a79266fc77e3ef11f78f10f1b974 + languageName: node + linkType: hard + "get-symbol-description@npm:^1.1.0": version: 1.1.0 resolution: "get-symbol-description@npm:1.1.0" @@ -20553,7 +20995,7 @@ __metadata: languageName: node linkType: hard -"glob@npm:^8.0.1, glob@npm:^8.0.3, glob@npm:^8.1.0": +"glob@npm:^8.0.0, glob@npm:^8.0.1, glob@npm:^8.0.3, glob@npm:^8.1.0": version: 8.1.0 resolution: "glob@npm:8.1.0" dependencies: @@ -21478,6 +21920,13 @@ __metadata: languageName: node linkType: hard +"human-signals@npm:^5.0.0": + version: 5.0.0 + resolution: "human-signals@npm:5.0.0" + checksum: 6504560d5ed91444f16bea3bd9dfc66110a339442084e56c3e7fa7bbdf3f406426d6563d662bdce67064b165eac31eeabfc0857ed170aaa612cf14ec9f9a464c + languageName: node + linkType: hard + "humanize-duration@npm:^3.25.1": version: 3.33.1 resolution: "humanize-duration@npm:3.33.1" @@ -22343,6 +22792,13 @@ __metadata: languageName: node linkType: hard +"is-stream@npm:^3.0.0": + version: 3.0.0 + resolution: "is-stream@npm:3.0.0" + checksum: 172093fe99119ffd07611ab6d1bcccfe8bc4aa80d864b15f43e63e54b7abc71e779acd69afdb854c4e2a67fdc16ae710e370eda40088d1cfc956a50ed82d8f16 + languageName: node + linkType: hard + "is-string@npm:^1.0.7, is-string@npm:^1.1.1": version: 1.1.1 resolution: "is-string@npm:1.1.1" @@ -23180,6 +23636,13 @@ __metadata: languageName: node linkType: hard +"js-tokens@npm:^9.0.1": + version: 9.0.1 + resolution: "js-tokens@npm:9.0.1" + checksum: 8b604020b1a550e575404bfdde4d12c11a7991ffe0c58a2cf3515b9a512992dc7010af788f0d8b7485e403d462d9e3d3b96c4ff03201550fdbb09e17c811e054 + languageName: node + linkType: hard + "js-yaml@npm:=4.1.0, js-yaml@npm:^4.0.0, js-yaml@npm:^4.1.0": version: 4.1.0 resolution: "js-yaml@npm:4.1.0" @@ -24057,6 +24520,16 @@ __metadata: languageName: node linkType: hard +"local-pkg@npm:^0.5.0": + version: 0.5.1 + resolution: "local-pkg@npm:0.5.1" + dependencies: + mlly: ^1.7.3 + pkg-types: ^1.2.1 + checksum: 478effb440780d412bff78ed80d1593d707a504931a7e5899d6570d207da1e661a6128c3087286ff964696a55c607c2bbd2bbe98377401c7d395891c160fa6e1 + languageName: node + linkType: hard + "locate-path@npm:^3.0.0": version: 3.0.0 resolution: "locate-path@npm:3.0.0" @@ -24336,6 +24809,15 @@ __metadata: languageName: node linkType: hard +"loupe@npm:^2.3.6, loupe@npm:^2.3.7": + version: 2.3.7 + resolution: "loupe@npm:2.3.7" + dependencies: + get-func-name: ^2.0.1 + checksum: 96c058ec7167598e238bb7fb9def2f9339215e97d6685d9c1e3e4bdb33d14600e11fe7a812cf0c003dfb73ca2df374f146280b2287cae9e8d989e9d7a69a203b + languageName: node + linkType: hard + "lower-case@npm:^2.0.2": version: 2.0.2 resolution: "lower-case@npm:2.0.2" @@ -24438,12 +24920,12 @@ __metadata: languageName: node linkType: hard -"magic-string@npm:^0.30.17, magic-string@npm:^0.30.3": - version: 0.30.19 - resolution: "magic-string@npm:0.30.19" +"magic-string@npm:^0.30.17, magic-string@npm:^0.30.3, magic-string@npm:^0.30.5": + version: 0.30.21 + resolution: "magic-string@npm:0.30.21" dependencies: "@jridgewell/sourcemap-codec": ^1.5.5 - checksum: f360b87febeceddb35238e55963b70ef68381688c1aada6d842833a7be440a08cb0a8776e23b5e4e34785edc6b42b92dc08c829f43ecdb58547122f3fd79fdc7 + checksum: 4ff76a4e8d439431cf49f039658751ed351962d044e5955adc257489569bd676019c906b631f86319217689d04815d7d064ee3ff08ab82ae65b7655a7e82a414 languageName: node linkType: hard @@ -25357,6 +25839,13 @@ __metadata: languageName: node linkType: hard +"mimic-fn@npm:^4.0.0": + version: 4.0.0 + resolution: "mimic-fn@npm:4.0.0" + checksum: 995dcece15ee29aa16e188de6633d43a3db4611bcf93620e7e62109ec41c79c0f34277165b8ce5e361205049766e371851264c21ac64ca35499acb5421c2ba56 + languageName: node + linkType: hard + "mimic-response@npm:^3.1.0": version: 3.1.0 resolution: "mimic-response@npm:3.1.0" @@ -25632,6 +26121,18 @@ __metadata: languageName: node linkType: hard +"mlly@npm:^1.7.3, mlly@npm:^1.7.4": + version: 1.8.0 + resolution: "mlly@npm:1.8.0" + dependencies: + acorn: ^8.15.0 + pathe: ^2.0.3 + pkg-types: ^1.3.1 + ufo: ^1.6.1 + checksum: cccd626d910f139881cc861bae1af8747a0911c1a5414cca059558b81286e43f271652931eec87ef3c07d9faf4225987ae3219b65a939b94e18b533fa0d22c89 + languageName: node + linkType: hard + "mockttp@npm:^3.13.0": version: 3.17.1 resolution: "mockttp@npm:3.17.1" @@ -26390,6 +26891,15 @@ __metadata: languageName: node linkType: hard +"npm-run-path@npm:^5.1.0": + version: 5.3.0 + resolution: "npm-run-path@npm:5.3.0" + dependencies: + path-key: ^4.0.0 + checksum: ae8e7a89da9594fb9c308f6555c73f618152340dcaae423e5fb3620026fefbec463618a8b761920382d666fa7a2d8d240b6fe320e8a6cdd54dc3687e2b659d25 + languageName: node + linkType: hard + "npmlog@npm:^6.0.0": version: 6.0.2 resolution: "npmlog@npm:6.0.2" @@ -26597,6 +27107,15 @@ __metadata: languageName: node linkType: hard +"onetime@npm:^6.0.0": + version: 6.0.0 + resolution: "onetime@npm:6.0.0" + dependencies: + mimic-fn: ^4.0.0 + checksum: 0846ce78e440841335d4e9182ef69d5762e9f38aa7499b19f42ea1c4cd40f0b4446094c455c713f9adac3f4ae86f613bb5e30c99e52652764d06a89f709b3788 + languageName: node + linkType: hard + "only@npm:~0.0.2": version: 0.0.2 resolution: "only@npm:0.0.2" @@ -26912,6 +27431,15 @@ __metadata: languageName: node linkType: hard +"p-limit@npm:^5.0.0": + version: 5.0.0 + resolution: "p-limit@npm:5.0.0" + dependencies: + yocto-queue: ^1.0.0 + checksum: 87bf5837dee6942f0dbeff318436179931d9a97848d1b07dbd86140a477a5d2e6b90d9701b210b4e21fe7beaea2979dfde366e4f576fa644a59bd4d6a6371da7 + languageName: node + linkType: hard + "p-locate@npm:^3.0.0": version: 3.0.0 resolution: "p-locate@npm:3.0.0" @@ -27269,6 +27797,13 @@ __metadata: languageName: node linkType: hard +"path-key@npm:^4.0.0": + version: 4.0.0 + resolution: "path-key@npm:4.0.0" + checksum: 8e6c314ae6d16b83e93032c61020129f6f4484590a777eed709c4a01b50e498822b00f76ceaf94bc64dbd90b327df56ceadce27da3d83393790f1219e07721d7 + languageName: node + linkType: hard + "path-parse@npm:^1.0.7": version: 1.0.7 resolution: "path-parse@npm:1.0.7" @@ -27331,13 +27866,27 @@ __metadata: languageName: node linkType: hard -"pathe@npm:^2.0.3": +"pathe@npm:^1.1.1": + version: 1.1.2 + resolution: "pathe@npm:1.1.2" + checksum: ec5f778d9790e7b9ffc3e4c1df39a5bb1ce94657a4e3ad830c1276491ca9d79f189f47609884671db173400256b005f4955f7952f52a2aeb5834ad5fb4faf134 + languageName: node + linkType: hard + +"pathe@npm:^2.0.1, pathe@npm:^2.0.3": version: 2.0.3 resolution: "pathe@npm:2.0.3" checksum: 0602bdd4acb54d91044e0c56f1fb63467ae7d44ab3afea1f797947b0eb2b4d1d91cf0d58d065fdb0a8ab0c4acbbd8d3a5b424983eaf10dd5285d37a16f6e3ee9 languageName: node linkType: hard +"pathval@npm:^1.1.1": + version: 1.1.1 + resolution: "pathval@npm:1.1.1" + checksum: 090e3147716647fb7fb5b4b8c8e5b55e5d0a6086d085b6cd23f3d3c01fcf0ff56fd3cc22f2f4a033bd2e46ed55d61ed8379e123b42afe7d531a2a5fc8bb556d6 + languageName: node + linkType: hard + "pause-stream@npm:~0.0.11": version: 0.0.11 resolution: "pause-stream@npm:0.0.11" @@ -27600,6 +28149,17 @@ __metadata: languageName: node linkType: hard +"pkg-types@npm:^1.2.1, pkg-types@npm:^1.3.1": + version: 1.3.1 + resolution: "pkg-types@npm:1.3.1" + dependencies: + confbox: ^0.1.8 + mlly: ^1.7.4 + pathe: ^2.0.1 + checksum: 4fa4edb2bb845646cdbd04c5c6bc43cdbc8f02ed4d1c28bfcafb6e65928aece789bcf1335e4cac5f65dfdc376e4bd7435bd509a35e9ec73ef2c076a1b88e289c + languageName: node + linkType: hard + "pkg-up@npm:^3.1.0": version: 3.1.0 resolution: "pkg-up@npm:3.1.0" @@ -28095,7 +28655,7 @@ __metadata: languageName: node linkType: hard -"postcss@npm:^8.1.0, postcss@npm:^8.4.33": +"postcss@npm:^8.1.0, postcss@npm:^8.4.33, postcss@npm:^8.4.43": version: 8.5.6 resolution: "postcss@npm:8.5.6" dependencies: @@ -30057,31 +30617,32 @@ __metadata: languageName: node linkType: hard -"rollup@npm:^4.27.3": - version: 4.50.2 - resolution: "rollup@npm:4.50.2" - dependencies: - "@rollup/rollup-android-arm-eabi": 4.50.2 - "@rollup/rollup-android-arm64": 4.50.2 - "@rollup/rollup-darwin-arm64": 4.50.2 - "@rollup/rollup-darwin-x64": 4.50.2 - "@rollup/rollup-freebsd-arm64": 4.50.2 - "@rollup/rollup-freebsd-x64": 4.50.2 - "@rollup/rollup-linux-arm-gnueabihf": 4.50.2 - "@rollup/rollup-linux-arm-musleabihf": 4.50.2 - "@rollup/rollup-linux-arm64-gnu": 4.50.2 - "@rollup/rollup-linux-arm64-musl": 4.50.2 - "@rollup/rollup-linux-loong64-gnu": 4.50.2 - "@rollup/rollup-linux-ppc64-gnu": 4.50.2 - "@rollup/rollup-linux-riscv64-gnu": 4.50.2 - "@rollup/rollup-linux-riscv64-musl": 4.50.2 - "@rollup/rollup-linux-s390x-gnu": 4.50.2 - "@rollup/rollup-linux-x64-gnu": 4.50.2 - "@rollup/rollup-linux-x64-musl": 4.50.2 - "@rollup/rollup-openharmony-arm64": 4.50.2 - "@rollup/rollup-win32-arm64-msvc": 4.50.2 - "@rollup/rollup-win32-ia32-msvc": 4.50.2 - "@rollup/rollup-win32-x64-msvc": 4.50.2 +"rollup@npm:^4.20.0, rollup@npm:^4.27.3": + version: 4.53.3 + resolution: "rollup@npm:4.53.3" + dependencies: + "@rollup/rollup-android-arm-eabi": 4.53.3 + "@rollup/rollup-android-arm64": 4.53.3 + "@rollup/rollup-darwin-arm64": 4.53.3 + "@rollup/rollup-darwin-x64": 4.53.3 + "@rollup/rollup-freebsd-arm64": 4.53.3 + "@rollup/rollup-freebsd-x64": 4.53.3 + "@rollup/rollup-linux-arm-gnueabihf": 4.53.3 + "@rollup/rollup-linux-arm-musleabihf": 4.53.3 + "@rollup/rollup-linux-arm64-gnu": 4.53.3 + "@rollup/rollup-linux-arm64-musl": 4.53.3 + "@rollup/rollup-linux-loong64-gnu": 4.53.3 + "@rollup/rollup-linux-ppc64-gnu": 4.53.3 + "@rollup/rollup-linux-riscv64-gnu": 4.53.3 + "@rollup/rollup-linux-riscv64-musl": 4.53.3 + "@rollup/rollup-linux-s390x-gnu": 4.53.3 + "@rollup/rollup-linux-x64-gnu": 4.53.3 + "@rollup/rollup-linux-x64-musl": 4.53.3 + "@rollup/rollup-openharmony-arm64": 4.53.3 + "@rollup/rollup-win32-arm64-msvc": 4.53.3 + "@rollup/rollup-win32-ia32-msvc": 4.53.3 + "@rollup/rollup-win32-x64-gnu": 4.53.3 + "@rollup/rollup-win32-x64-msvc": 4.53.3 "@types/estree": 1.0.8 fsevents: ~2.3.2 dependenciesMeta: @@ -30125,13 +30686,15 @@ __metadata: optional: true "@rollup/rollup-win32-ia32-msvc": optional: true + "@rollup/rollup-win32-x64-gnu": + optional: true "@rollup/rollup-win32-x64-msvc": optional: true fsevents: optional: true bin: rollup: dist/bin/rollup - checksum: fbdd9b470585fe1add3ec35edb760de289fe4413c9eb4ba5321fcf38638669d7f0d44ee54f9c55cd81404c69caa2c4b0598d2b4715e0b138cdace65bbe9a207a + checksum: 7c5ed8f30285c731e00007726c99c6ad1f07e398d09afad53c648f32017b22b9f5d60ac99c65d60ad5334e69ffeeaa835fff88d26f21c8f1237e3d936a664056 languageName: node linkType: hard @@ -30691,6 +31254,13 @@ __metadata: languageName: node linkType: hard +"siginfo@npm:^2.0.0": + version: 2.0.0 + resolution: "siginfo@npm:2.0.0" + checksum: 8aa5a98640ca09fe00d74416eca97551b3e42991614a3d1b824b115fc1401543650914f651ab1311518177e4d297e80b953f4cd4cd7ea1eabe824e8f2091de01 + languageName: node + linkType: hard + "signal-exit@npm:^3.0.2, signal-exit@npm:^3.0.3, signal-exit@npm:^3.0.7": version: 3.0.7 resolution: "signal-exit@npm:3.0.7" @@ -30698,7 +31268,7 @@ __metadata: languageName: node linkType: hard -"signal-exit@npm:^4.0.1": +"signal-exit@npm:^4.0.1, signal-exit@npm:^4.1.0": version: 4.1.0 resolution: "signal-exit@npm:4.1.0" checksum: 64c757b498cb8629ffa5f75485340594d2f8189e9b08700e69199069c8e3070fb3e255f7ab873c05dc0b3cec412aea7402e10a5990cb6a050bd33ba062a6c549 @@ -31076,6 +31646,13 @@ __metadata: languageName: node linkType: hard +"stackback@npm:0.0.2": + version: 0.0.2 + resolution: "stackback@npm:0.0.2" + checksum: 2d4dc4e64e2db796de4a3c856d5943daccdfa3dd092e452a1ce059c81e9a9c29e0b9badba91b43ef0d5ff5c04ee62feb3bcc559a804e16faf447bac2d883aa99 + languageName: node + linkType: hard + "stackframe@npm:^1.3.4": version: 1.3.4 resolution: "stackframe@npm:1.3.4" @@ -31134,6 +31711,13 @@ __metadata: languageName: node linkType: hard +"std-env@npm:^3.5.0": + version: 3.10.0 + resolution: "std-env@npm:3.10.0" + checksum: 51d641b36b0fae494a546fb8446d39a837957fbf902c765c62bd12af8e50682d141c4087ca032f1192fa90330c4f6ff23fd6c9795324efacd1684e814471e0e0 + languageName: node + linkType: hard + "statuses@npm:~2.0.1, statuses@npm:~2.0.2": version: 2.0.2 resolution: "statuses@npm:2.0.2" @@ -31440,6 +32024,13 @@ __metadata: languageName: node linkType: hard +"strip-final-newline@npm:^3.0.0": + version: 3.0.0 + resolution: "strip-final-newline@npm:3.0.0" + checksum: 23ee263adfa2070cd0f23d1ac14e2ed2f000c9b44229aec9c799f1367ec001478469560abefd00c5c99ee6f0b31c137d53ec6029c53e9f32a93804e18c201050 + languageName: node + linkType: hard + "strip-indent@npm:^3.0.0": version: 3.0.0 resolution: "strip-indent@npm:3.0.0" @@ -31470,6 +32061,15 @@ __metadata: languageName: node linkType: hard +"strip-literal@npm:^2.0.0": + version: 2.1.1 + resolution: "strip-literal@npm:2.1.1" + dependencies: + js-tokens: ^9.0.1 + checksum: 781f2018b2aa9e8e149882dfa35f4d284c244424e7b66cc62259796dbc4bc6da9d40f9206949ba12fa839f5f643d6c62a309f7eec4ff6e76ced15f0730f04831 + languageName: node + linkType: hard + "strnum@npm:^1.1.1": version: 1.1.2 resolution: "strnum@npm:1.1.2" @@ -32105,6 +32705,13 @@ __metadata: languageName: node linkType: hard +"tinybench@npm:^2.5.1": + version: 2.9.0 + resolution: "tinybench@npm:2.9.0" + checksum: 1ab00d7dfe0d1f127cbf00822bacd9024f7a50a3ecd1f354a8168e0b7d2b53a639a24414e707c27879d1adc0f5153141d51d76ebd7b4d37fe245e742e5d91fe8 + languageName: node + linkType: hard + "tinyglobby@npm:^0.2.12, tinyglobby@npm:^0.2.9": version: 0.2.15 resolution: "tinyglobby@npm:0.2.15" @@ -32115,6 +32722,20 @@ __metadata: languageName: node linkType: hard +"tinypool@npm:^0.8.3": + version: 0.8.4 + resolution: "tinypool@npm:0.8.4" + checksum: d40c40e062d5eeae85dadc39294dde6bc7b9a7a7cf0c972acbbe5a2b42491dfd4c48381c1e48bbe02aff4890e63de73d115b2e7de2ce4c81356aa5e654a43caf + languageName: node + linkType: hard + +"tinyspy@npm:^2.2.0": + version: 2.2.1 + resolution: "tinyspy@npm:2.2.1" + checksum: 170d6232e87f9044f537b50b406a38fbfd6f79a261cd12b92879947bd340939a833a678632ce4f5c4a6feab4477e9c21cd43faac3b90b68b77dd0536c4149736 + languageName: node + linkType: hard + "tldts-core@npm:^6.1.86": version: 6.1.86 resolution: "tldts-core@npm:6.1.86" @@ -32432,7 +33053,7 @@ __metadata: languageName: node linkType: hard -"ts-node@npm:^10.9.1": +"ts-node@npm:^10.9.1, ts-node@npm:^10.9.2": version: 10.9.2 resolution: "ts-node@npm:10.9.2" dependencies: @@ -32558,6 +33179,13 @@ __metadata: languageName: node linkType: hard +"type-detect@npm:^4.0.0, type-detect@npm:^4.1.0": + version: 4.1.0 + resolution: "type-detect@npm:4.1.0" + checksum: 3b32f873cd02bc7001b00a61502b7ddc4b49278aabe68d652f732e1b5d768c072de0bc734b427abf59d0520a5f19a2e07309ab921ef02018fa1cb4af155cdb37 + languageName: node + linkType: hard + "type-fest@npm:^0.13.1": version: 0.13.1 resolution: "type-fest@npm:0.13.1" @@ -32771,6 +33399,13 @@ __metadata: languageName: node linkType: hard +"ufo@npm:^1.6.1": + version: 1.6.1 + resolution: "ufo@npm:1.6.1" + checksum: 2c401dd45bd98ad00806e044aa8571aa2aa1762fffeae5e78c353192b257ef2c638159789f119e5d8d5e5200e34228cd1bbde871a8f7805de25daa8576fb1633 + languageName: node + linkType: hard + "uglify-js@npm:^3.1.4": version: 3.19.3 resolution: "uglify-js@npm:3.19.3" @@ -33450,6 +34085,114 @@ __metadata: languageName: node linkType: hard +"vite-node@npm:1.6.1": + version: 1.6.1 + resolution: "vite-node@npm:1.6.1" + dependencies: + cac: ^6.7.14 + debug: ^4.3.4 + pathe: ^1.1.1 + picocolors: ^1.0.0 + vite: ^5.0.0 + bin: + vite-node: vite-node.mjs + checksum: a42d2ee0110133c4c7cf19fafca74b3115d3b85b6234ed6057ad8de12ca9ece67655a0b5ba50942f253fb6c428b902f738aabdd62835b9142e8219725bbb895d + languageName: node + linkType: hard + +"vite@npm:^5.0.0": + version: 5.4.21 + resolution: "vite@npm:5.4.21" + dependencies: + esbuild: ^0.21.3 + fsevents: ~2.3.3 + postcss: ^8.4.43 + rollup: ^4.20.0 + peerDependencies: + "@types/node": ^18.0.0 || >=20.0.0 + less: "*" + lightningcss: ^1.21.0 + sass: "*" + sass-embedded: "*" + stylus: "*" + sugarss: "*" + terser: ^5.4.0 + dependenciesMeta: + fsevents: + optional: true + peerDependenciesMeta: + "@types/node": + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + bin: + vite: bin/vite.js + checksum: 7177fa03cff6a382f225290c9889a0d0e944d17eab705bcba89b58558a6f7adfa1f47e469b88f42a044a0eb40c12a1bf68b3cb42abb5295d04f9d7d4dd320837 + languageName: node + linkType: hard + +"vitest@npm:^1.0.0": + version: 1.6.1 + resolution: "vitest@npm:1.6.1" + dependencies: + "@vitest/expect": 1.6.1 + "@vitest/runner": 1.6.1 + "@vitest/snapshot": 1.6.1 + "@vitest/spy": 1.6.1 + "@vitest/utils": 1.6.1 + acorn-walk: ^8.3.2 + chai: ^4.3.10 + debug: ^4.3.4 + execa: ^8.0.1 + local-pkg: ^0.5.0 + magic-string: ^0.30.5 + pathe: ^1.1.1 + picocolors: ^1.0.0 + std-env: ^3.5.0 + strip-literal: ^2.0.0 + tinybench: ^2.5.1 + tinypool: ^0.8.3 + vite: ^5.0.0 + vite-node: 1.6.1 + why-is-node-running: ^2.2.2 + peerDependencies: + "@edge-runtime/vm": "*" + "@types/node": ^18.0.0 || >=20.0.0 + "@vitest/browser": 1.6.1 + "@vitest/ui": 1.6.1 + happy-dom: "*" + jsdom: "*" + peerDependenciesMeta: + "@edge-runtime/vm": + optional: true + "@types/node": + optional: true + "@vitest/browser": + optional: true + "@vitest/ui": + optional: true + happy-dom: + optional: true + jsdom: + optional: true + bin: + vitest: vitest.mjs + checksum: dd13cad6ba4375afe4449ce1e90cbbd0e22a771556d06e2b191c30f92021f02d423c5321df9cbdfec8f348924a42a97f3fa8fc0160e0697d3a04943db7697243 + languageName: node + linkType: hard + "vm-browserify@npm:^1.0.1": version: 1.1.2 resolution: "vm-browserify@npm:1.1.2" @@ -33820,6 +34563,18 @@ __metadata: languageName: node linkType: hard +"why-is-node-running@npm:^2.2.2": + version: 2.3.0 + resolution: "why-is-node-running@npm:2.3.0" + dependencies: + siginfo: ^2.0.0 + stackback: 0.0.2 + bin: + why-is-node-running: cli.js + checksum: 58ebbf406e243ace97083027f0df7ff4c2108baf2595bb29317718ef207cc7a8104e41b711ff65d6fa354f25daa8756b67f2f04931a4fd6ba9d13ae8197496fb + languageName: node + linkType: hard + "wide-align@npm:^1.1.5": version: 1.1.5 resolution: "wide-align@npm:1.1.5" @@ -34173,6 +34928,13 @@ __metadata: languageName: node linkType: hard +"yocto-queue@npm:^1.0.0": + version: 1.2.2 + resolution: "yocto-queue@npm:1.2.2" + checksum: 92dd9880c324dbc94ff4b677b7d350ba8d835619062b7102f577add7a59ab4d87f40edc5a03d77d369dfa9d11175b1b2ec4a06a6f8a5d8ce5d1306713f66ee41 + languageName: node + linkType: hard + "zen-observable@npm:^0.10.0": version: 0.10.0 resolution: "zen-observable@npm:0.10.0" From e4ed6e7a5dad70a899b11676cf1970a4d5c2fbaa Mon Sep 17 00:00:00 2001 From: Yi Cai Date: Fri, 5 Dec 2025 16:50:27 -0500 Subject: [PATCH 2/5] fixed bugs Signed-off-by: Yi Cai --- .../plugins/test/src/translations/ref.ts | 5 + .../packages/cli/TESTING-GUIDE.md | 209 ++++++++++++++++++ .../packages/cli/bin/translations-cli | 8 +- .../translations/packages/cli/package.json | 10 +- .../packages/cli/src/commands/generate.ts | 36 +-- .../cli/src/lib/i18n/generateFiles.ts | 1 + .../packages/cli/src/lib/i18n/validateFile.ts | 3 +- .../translations/packages/cli/test/README.md | 149 +++++++++++++ .../cli/test/compare-reference-files.sh | 89 ++++++++ .../packages/cli/test/generate.test.ts | 83 +++++++ .../packages/cli/test/integration-test.sh | 148 +++++++++++++ .../cli/test/manual-test-checklist.md | 197 +++++++++++++++++ .../packages/cli/test/quick-test.sh | 63 ++++++ .../packages/cli/test/real-repo-test.sh | 63 ++++++ .../packages/cli/test/test-helpers.ts | 142 ++++++++++++ .../packages/cli/vitest.config.ts | 29 +++ 16 files changed, 1211 insertions(+), 24 deletions(-) create mode 100644 workspaces/translations/packages/cli/.quick-test/plugins/test/src/translations/ref.ts create mode 100644 workspaces/translations/packages/cli/TESTING-GUIDE.md create mode 100644 workspaces/translations/packages/cli/test/README.md create mode 100755 workspaces/translations/packages/cli/test/compare-reference-files.sh create mode 100644 workspaces/translations/packages/cli/test/generate.test.ts create mode 100755 workspaces/translations/packages/cli/test/integration-test.sh create mode 100644 workspaces/translations/packages/cli/test/manual-test-checklist.md create mode 100755 workspaces/translations/packages/cli/test/quick-test.sh create mode 100755 workspaces/translations/packages/cli/test/real-repo-test.sh create mode 100644 workspaces/translations/packages/cli/test/test-helpers.ts create mode 100644 workspaces/translations/packages/cli/vitest.config.ts diff --git a/workspaces/translations/packages/cli/.quick-test/plugins/test/src/translations/ref.ts b/workspaces/translations/packages/cli/.quick-test/plugins/test/src/translations/ref.ts new file mode 100644 index 0000000000..eaa8c7b408 --- /dev/null +++ b/workspaces/translations/packages/cli/.quick-test/plugins/test/src/translations/ref.ts @@ -0,0 +1,5 @@ +import { createTranslationRef } from '@backstage/core-plugin-api/alpha'; +export const messages = createTranslationRef({ + id: 'test', + messages: { title: 'Test' }, +}); diff --git a/workspaces/translations/packages/cli/TESTING-GUIDE.md b/workspaces/translations/packages/cli/TESTING-GUIDE.md new file mode 100644 index 0000000000..c4e1b07037 --- /dev/null +++ b/workspaces/translations/packages/cli/TESTING-GUIDE.md @@ -0,0 +1,209 @@ +# Testing Guide for Translations CLI + +Complete guide for testing the translations CLI before release. + +## Quick Test Commands + +```bash +# Quick smoke test (fastest) +yarn test:quick + +# Full integration test +yarn test:integration + +# Unit tests (vitest) +yarn test + +# Manual testing checklist +# See: test/manual-test-checklist.md +``` + +## Testing Strategy + +### 1. Automated Tests + +#### Quick Test (Recommended First) + +```bash +yarn test:quick +``` + +- Builds the CLI +- Tests help command +- Tests generate command with sample files +- Verifies output structure +- Takes ~10 seconds + +#### Integration Test + +```bash +yarn test:integration +``` + +- Creates full test fixture +- Tests generate command +- Verifies English-only filtering +- Verifies non-English words are excluded +- Takes ~30 seconds + +#### Unit Tests + +```bash +yarn test +``` + +- Runs vitest test suite +- Tests individual functions +- Fast feedback during development + +### 2. Manual Testing + +Follow the comprehensive checklist: + +```bash +# View checklist +cat test/manual-test-checklist.md +``` + +Key areas to test: + +- โœ… All commands work +- โœ… Help text is correct +- โœ… Generate only includes English +- โœ… Non-English words excluded +- โœ… Error messages are helpful + +### 3. Real Repository Testing + +#### Test in community-plugins + +```bash +cd /Users/yicai/redhat/community-plugins + +# Build and link CLI first +cd /Users/yicai/redhat/rhdh-plugins/workspaces/translations/packages/cli +yarn build +yarn link # or use: node bin/translations-cli + +# Test generate +cd /Users/yicai/redhat/community-plugins +translations-cli i18n generate --source-dir . --output-dir i18n + +# Verify: +# 1. reference.json only contains English +# 2. No Italian/German/French words +# 3. All plugins included +# 4. Language files excluded +``` + +#### Test in rhdh-plugins + +```bash +cd /Users/yicai/redhat/rhdh-plugins/workspaces/translations +translations-cli i18n generate --source-dir . --output-dir i18n +``` + +## Pre-Release Checklist + +### Build & Lint + +- [ ] `yarn build` succeeds +- [ ] `yarn lint` passes (no errors) +- [ ] No TypeScript errors + +### Automated Tests + +- [ ] `yarn test:quick` passes +- [ ] `yarn test:integration` passes +- [ ] `yarn test` passes (if unit tests exist) + +### Manual Tests + +- [ ] All commands work (`--help` for each) +- [ ] Generate creates correct output +- [ ] Only English in reference.json +- [ ] Non-English words excluded +- [ ] Error handling works + +### Real Repository Tests + +- [ ] Tested in community-plugins +- [ ] Tested in rhdh-plugins (or similar) +- [ ] Output verified manually + +### Documentation + +- [ ] README is up to date +- [ ] TESTING.md is accurate +- [ ] All examples work + +## Common Issues & Solutions + +### Build Fails + +```bash +# Clean and rebuild +yarn clean +rm -rf dist node_modules +yarn install +yarn build +``` + +### Tests Fail + +```bash +# Ensure scripts are executable +chmod +x test/*.sh + +# Rebuild +yarn build +``` + +### Command Not Found + +```bash +# Use direct path +node bin/translations-cli i18n --help + +# Or link globally +yarn link +``` + +## Testing Workflow + +### Daily Development + +1. `yarn test:quick` - before committing +2. `yarn lint` - ensure code quality + +### Before PR + +1. `yarn test:integration` - full test +2. Manual testing of key features +3. Test in at least one real repo + +### Before Release + +1. Complete pre-release checklist +2. Test in 2+ real repositories +3. Verify all documentation +4. Check version numbers + +## Test Files Structure + +``` +test/ +โ”œโ”€โ”€ README.md # This guide +โ”œโ”€โ”€ test-helpers.ts # Test utilities +โ”œโ”€โ”€ generate.test.ts # Unit tests +โ”œโ”€โ”€ integration-test.sh # Full integration test +โ”œโ”€โ”€ quick-test.sh # Quick smoke test +โ””โ”€โ”€ manual-test-checklist.md # Manual testing guide +``` + +## Next Steps + +1. Run `yarn test:quick` to verify basic functionality +2. Review `test/manual-test-checklist.md` for comprehensive testing +3. Test in a real repository before release +4. Fix any issues found during testing diff --git a/workspaces/translations/packages/cli/bin/translations-cli b/workspaces/translations/packages/cli/bin/translations-cli index 34cce60b53..f59627785d 100755 --- a/workspaces/translations/packages/cli/bin/translations-cli +++ b/workspaces/translations/packages/cli/bin/translations-cli @@ -20,11 +20,15 @@ const path = require('path'); // Figure out whether we're running inside the backstage repo or as an installed dependency /* eslint-disable-next-line no-restricted-syntax */ const isLocal = require('fs').existsSync(path.resolve(__dirname, '../src')); +const hasDist = require('fs').existsSync(path.resolve(__dirname, '../dist/index.cjs.js')); -if (!isLocal) { +// Prefer built version if available, otherwise use source with transform +if (hasDist) { require('..'); -} else { +} else if (isLocal) { require('@backstage/cli/config/nodeTransform.cjs'); require('../src'); +} else { + require('..'); } diff --git a/workspaces/translations/packages/cli/package.json b/workspaces/translations/packages/cli/package.json index 438e3a265d..20bf0722da 100644 --- a/workspaces/translations/packages/cli/package.json +++ b/workspaces/translations/packages/cli/package.json @@ -20,13 +20,17 @@ "scripts": { "build": "backstage-cli package build", "lint": "backstage-cli package lint", - "test": "backstage-cli package test", + "test": "vitest run", + "test:watch": "vitest", + "test:quick": "./test/quick-test.sh", + "test:integration": "./test/integration-test.sh", + "test:real-repo": "./test/real-repo-test.sh", + "test:manual": "echo 'See test/manual-test-checklist.md for manual testing steps'", "clean": "backstage-cli package clean", "start": "nodemon --", "dev": "ts-node src/index.ts", "dev:help": "ts-node src/index.ts --help", - "test:local": "npm run build && node bin/translations-cli", - "test:watch": "vitest" + "test:local": "npm run build && node bin/translations-cli" }, "bin": "bin/translations-cli", "files": [ diff --git a/workspaces/translations/packages/cli/src/commands/generate.ts b/workspaces/translations/packages/cli/src/commands/generate.ts index 3ff8e626f9..a0063d899b 100644 --- a/workspaces/translations/packages/cli/src/commands/generate.ts +++ b/workspaces/translations/packages/cli/src/commands/generate.ts @@ -140,32 +140,32 @@ export async function generateCommand(opts: OptionValues): Promise { fileName !== 'en'; // Check if file contains createTranslationRef import (defines new translation keys) + // This is the primary source for English reference keys const hasCreateTranslationRef = content.includes('createTranslationRef') && (content.includes("from '@backstage/core-plugin-api/alpha'") || content.includes("from '@backstage/frontend-plugin-api'")); - // Check if file contains createTranslationMessages (overrides/extends existing keys) - // Only include if it's an English file (not a language file) - const hasCreateTranslationMessages = + // Also include English files with createTranslationMessages that have a ref + // These are English overrides/extensions of existing translations + // Only include -en.ts files to avoid non-English translations + const fullFileName = path.basename(filePath); + const isEnglishFile = + fullFileName.endsWith('-en.ts') || + fullFileName.endsWith('-en.tsx') || + fullFileName === 'en.ts' || + fullFileName === 'en.tsx' || + fileName.endsWith('-en') || + fileName === 'en'; + const hasCreateTranslationMessagesWithRef = + isEnglishFile && content.includes('createTranslationMessages') && + content.includes('ref:') && (content.includes("from '@backstage/core-plugin-api/alpha'") || - content.includes("from '@backstage/frontend-plugin-api'")) && - !isLanguageFile; + content.includes("from '@backstage/frontend-plugin-api'")); - // Check if file contains createTranslationResource (sets up translation resources) - // Only include if it's an English file (not a language file) - const hasCreateTranslationResource = - content.includes('createTranslationResource') && - (content.includes("from '@backstage/core-plugin-api/alpha'") || - content.includes("from '@backstage/frontend-plugin-api'")) && - !isLanguageFile; - - if ( - hasCreateTranslationRef || - hasCreateTranslationMessages || - hasCreateTranslationResource - ) { + // Include files that define new translation keys OR English overrides + if (hasCreateTranslationRef || hasCreateTranslationMessagesWithRef) { sourceFiles.push(filePath); } } catch { diff --git a/workspaces/translations/packages/cli/src/lib/i18n/generateFiles.ts b/workspaces/translations/packages/cli/src/lib/i18n/generateFiles.ts index b2c0dc6758..83d0650fda 100644 --- a/workspaces/translations/packages/cli/src/lib/i18n/generateFiles.ts +++ b/workspaces/translations/packages/cli/src/lib/i18n/generateFiles.ts @@ -89,6 +89,7 @@ async function generateJsonFile( if (isNestedStructure(keys)) { // New nested structure: { plugin: { en: { key: value } } } + // Keep keys as flat dot-notation strings (e.g., "menuItem.home": "Home") const normalizedData: NestedTranslationData = {}; for (const [pluginName, pluginData] of Object.entries(keys)) { diff --git a/workspaces/translations/packages/cli/src/lib/i18n/validateFile.ts b/workspaces/translations/packages/cli/src/lib/i18n/validateFile.ts index ceb4f8de44..df5d26ec86 100644 --- a/workspaces/translations/packages/cli/src/lib/i18n/validateFile.ts +++ b/workspaces/translations/packages/cli/src/lib/i18n/validateFile.ts @@ -102,6 +102,7 @@ async function validateJsonFile(filePath: string): Promise { if (isNested(data)) { // Nested structure: { plugin: { en: { key: value } } } + // Keys are flat dot-notation strings (e.g., "menuItem.home": "Home") for (const [pluginName, pluginData] of Object.entries(data)) { if (typeof pluginData !== 'object' || pluginData === null) { throw new Error(`Plugin "${pluginName}" must be an object`); @@ -116,7 +117,7 @@ async function validateJsonFile(filePath: string): Promise { throw new Error(`Plugin "${pluginName}".en must be an object`); } - // Validate that all values are strings + // Validate that all values are strings (keys are flat dot-notation) for (const [key, value] of Object.entries(enData)) { if (typeof value !== 'string') { throw new Error( diff --git a/workspaces/translations/packages/cli/test/README.md b/workspaces/translations/packages/cli/test/README.md new file mode 100644 index 0000000000..85fe5b3966 --- /dev/null +++ b/workspaces/translations/packages/cli/test/README.md @@ -0,0 +1,149 @@ +# Testing Guide for Translations CLI + +This directory contains testing utilities and guides for the translations CLI. + +## Quick Start + +### Run Quick Tests (Fast) + +```bash +yarn test:quick +``` + +Tests basic functionality in under 10 seconds. + +### Run Integration Tests (Comprehensive) + +```bash +yarn test:integration +``` + +Tests full workflow with real file structures. + +### Run Unit Tests (If Available) + +```bash +yarn test +``` + +Runs vitest unit tests. + +### Manual Testing + +Follow the checklist in `manual-test-checklist.md`: + +```bash +yarn test:manual +# Then follow the checklist +``` + +## Test Files + +- `test-helpers.ts` - Utility functions for creating test fixtures +- `generate.test.ts` - Unit tests for generate command +- `integration-test.sh` - Full integration test script +- `quick-test.sh` - Quick smoke tests +- `manual-test-checklist.md` - Comprehensive manual testing guide + +## Testing Workflow + +### Before Every Commit + +```bash +# Quick smoke test +yarn test:quick +``` + +### Before PR + +```bash +# Full integration test +yarn test:integration + +# Manual testing (follow checklist) +# See test/manual-test-checklist.md +``` + +### Before Release + +1. Run all automated tests +2. Complete manual testing checklist +3. Test in real repositories: + - community-plugins + - rhdh-plugins + - Any other target repos + +## Testing in Real Repositories + +### Test in community-plugins + +```bash +cd /Users/yicai/redhat/community-plugins +translations-cli i18n generate --source-dir . --output-dir i18n + +# Verify: +# - reference.json only contains English +# - No non-English words (Italian, German, etc.) +# - All plugins are included +``` + +### Test in rhdh-plugins + +```bash +cd /Users/yicai/redhat/rhdh-plugins/workspaces/translations +translations-cli i18n generate --source-dir . --output-dir i18n + +# Verify output +``` + +## Common Test Scenarios + +### 1. Generate Command + +- โœ… Creates reference.json +- โœ… Only includes English keys +- โœ… Excludes language files (de.ts, es.ts, etc.) +- โœ… Excludes createTranslationMessages files +- โœ… Handles nested keys correctly + +### 2. Filtering + +- โœ… Only includes createTranslationRef files +- โœ… Excludes createTranslationMessages (may contain non-English) +- โœ… Excludes createTranslationResource +- โœ… No non-English words in output + +### 3. Error Handling + +- โœ… Invalid commands show helpful errors +- โœ… Missing files show helpful errors +- โœ… Invalid config shows helpful errors + +## Troubleshooting Tests + +### Tests Fail to Run + +```bash +# Ensure dependencies are installed +yarn install + +# Rebuild +yarn build +``` + +### Integration Test Fails + +```bash +# Check if bin file is executable +chmod +x bin/translations-cli + +# Check if script is executable +chmod +x test/integration-test.sh +``` + +### Permission Errors + +```bash +# Make scripts executable +chmod +x test/*.sh +``` diff --git a/workspaces/translations/packages/cli/test/compare-reference-files.sh b/workspaces/translations/packages/cli/test/compare-reference-files.sh new file mode 100755 index 0000000000..f361712f6f --- /dev/null +++ b/workspaces/translations/packages/cli/test/compare-reference-files.sh @@ -0,0 +1,89 @@ +#!/bin/bash +# Compare reference.json files before and after regeneration +# Usage: ./test/compare-reference-files.sh + +set -e + +if [ -z "$1" ]; then + echo "Usage: $0 " + echo "Example: $0 /Users/yicai/redhat/rhdh-plugins" + exit 1 +fi + +REPO_PATH="$1" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CLI_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" + +# Find reference.json location +REF_FILE="" +if [ -f "$REPO_PATH/i18n/reference.json" ]; then + REF_FILE="$REPO_PATH/i18n/reference.json" +elif [ -f "$REPO_PATH/workspaces/i18n/reference.json" ]; then + REF_FILE="$REPO_PATH/workspaces/i18n/reference.json" +else + echo "Error: reference.json not found in $REPO_PATH" + exit 1 +fi + +BACKUP_FILE="${REF_FILE}.backup" + +echo "๐Ÿ” Comparing reference.json files..." +echo "Repository: $REPO_PATH" +echo "File: $REF_FILE" +echo "" + +# Create backup if it doesn't exist +if [ ! -f "$BACKUP_FILE" ]; then + echo "Creating backup..." + cp "$REF_FILE" "$BACKUP_FILE" +fi + +# Generate new file +echo "Generating new reference.json..." +cd "$REPO_PATH" +if [ -f "workspaces/i18n/reference.json" ]; then + OUTPUT_DIR="workspaces/i18n" +else + OUTPUT_DIR="i18n" +fi + +node "$CLI_DIR/bin/translations-cli" i18n generate --source-dir . --output-dir "$OUTPUT_DIR" > /dev/null 2>&1 + +# Compare +echo "" +if cmp -s "$BACKUP_FILE" "$REF_FILE"; then + echo "โœ… Files are IDENTICAL - no upload needed" + exit 0 +else + echo "โš ๏ธ Files DIFFER - upload needed" + echo "" + echo "Summary:" + if command -v jq &> /dev/null; then + BACKUP_PLUGINS=$(jq 'keys | length' "$BACKUP_FILE") + NEW_PLUGINS=$(jq 'keys | length' "$REF_FILE") + BACKUP_KEYS=$(jq '[.[] | .en | keys | length] | add' "$BACKUP_FILE") + NEW_KEYS=$(jq '[.[] | .en | keys | length] | add' "$REF_FILE") + + echo " Plugins: $BACKUP_PLUGINS โ†’ $NEW_PLUGINS" + echo " Keys: $BACKUP_KEYS โ†’ $NEW_KEYS" + + if [ "$NEW_KEYS" -gt "$BACKUP_KEYS" ]; then + DIFF=$((NEW_KEYS - BACKUP_KEYS)) + echo " Added: +$DIFF keys" + elif [ "$NEW_KEYS" -lt "$BACKUP_KEYS" ]; then + DIFF=$((BACKUP_KEYS - NEW_KEYS)) + echo " Removed: -$DIFF keys" + fi + fi + + echo "" + echo "MD5 hashes:" + md5 "$BACKUP_FILE" "$REF_FILE" 2>/dev/null || md5sum "$BACKUP_FILE" "$REF_FILE" + + echo "" + echo "To see detailed differences:" + echo " diff -u $BACKUP_FILE $REF_FILE" + + exit 1 +fi + diff --git a/workspaces/translations/packages/cli/test/generate.test.ts b/workspaces/translations/packages/cli/test/generate.test.ts new file mode 100644 index 0000000000..3cf10fd20f --- /dev/null +++ b/workspaces/translations/packages/cli/test/generate.test.ts @@ -0,0 +1,83 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import fs from 'fs-extra'; +import path from 'path'; +import { createTestFixture, assertFileContains, runCLI } from './test-helpers'; + +describe('generate command', () => { + let fixture: Awaited>; + + beforeAll(async () => { + fixture = await createTestFixture(); + }); + + afterAll(async () => { + await fixture.cleanup(); + }); + + it('should generate reference.json file', async () => { + const outputDir = path.join(fixture.path, 'i18n'); + const outputFile = path.join(outputDir, 'reference.json'); + + const result = runCLI( + `i18n generate --source-dir ${fixture.path} --output-dir ${outputDir}`, + ); + + expect(result.exitCode).toBe(0); + expect(await fs.pathExists(outputFile)).toBe(true); + }); + + it('should only include English reference keys (exclude language files)', async () => { + const outputDir = path.join(fixture.path, 'i18n'); + const outputFile = path.join(outputDir, 'reference.json'); + + runCLI( + `i18n generate --source-dir ${fixture.path} --output-dir ${outputDir}`, + ); + + const content = await fs.readFile(outputFile, 'utf-8'); + const data = JSON.parse(content); + + // Should have test-plugin + expect(data['test-plugin']).toBeDefined(); + expect(data['test-plugin'].en).toBeDefined(); + + // Should have English keys + expect(data['test-plugin'].en.title).toBe('Test Plugin'); + expect(data['test-plugin'].en.description).toBe('This is a test plugin'); + + // Should NOT have German translations + expect(data['test-plugin'].en.title).not.toContain('German'); + expect(data['test-plugin'].en.description).not.toContain('Test-Plugin'); + }); + + it('should exclude non-English words from reference file', async () => { + const outputDir = path.join(fixture.path, 'i18n'); + const outputFile = path.join(outputDir, 'reference.json'); + + runCLI( + `i18n generate --source-dir ${fixture.path} --output-dir ${outputDir}`, + ); + + const content = await fs.readFile(outputFile, 'utf-8'); + + // Should not contain German words + expect(content).not.toContain('Test-Plugin'); + expect(content).not.toContain('Dies ist'); + }); +}); diff --git a/workspaces/translations/packages/cli/test/integration-test.sh b/workspaces/translations/packages/cli/test/integration-test.sh new file mode 100755 index 0000000000..b2b2f059ba --- /dev/null +++ b/workspaces/translations/packages/cli/test/integration-test.sh @@ -0,0 +1,148 @@ +#!/bin/bash +# Integration test script for translations-cli +# This script tests the CLI in a real-world scenario + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CLI_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +TEST_DIR="$CLI_DIR/.integration-test" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +echo_info() { + echo -e "${GREEN}โœ“${NC} $1" +} + +echo_warn() { + echo -e "${YELLOW}โš ${NC} $1" +} + +echo_error() { + echo -e "${RED}โœ—${NC} $1" +} + +cleanup() { + if [ -d "$TEST_DIR" ]; then + echo_info "Cleaning up test directory..." + rm -rf "$TEST_DIR" + fi +} + +trap cleanup EXIT + +# Build CLI +echo_info "Building CLI..." +cd "$CLI_DIR" +yarn build + +# Create test directory structure +echo_info "Creating test fixture..." +mkdir -p "$TEST_DIR/plugins/test-plugin/src/translations" +mkdir -p "$TEST_DIR/i18n" + +# Create ref.ts (English reference) +cat > "$TEST_DIR/plugins/test-plugin/src/translations/ref.ts" << 'EOF' +import { createTranslationRef } from '@backstage/core-plugin-api/alpha'; + +export const testPluginMessages = createTranslationRef({ + id: 'test-plugin', + messages: { + title: 'Test Plugin', + description: 'This is a test plugin', + button: { + save: 'Save', + cancel: 'Cancel', + }, + }, +}); +EOF + +# Create de.ts (German - should be excluded) +cat > "$TEST_DIR/plugins/test-plugin/src/translations/de.ts" << 'EOF' +import { createTranslationMessages } from '@backstage/core-plugin-api/alpha'; +import { testPluginMessages } from './ref'; + +export default createTranslationMessages({ + ref: testPluginMessages, + messages: { + title: 'Test Plugin (German)', + description: 'Dies ist ein Test-Plugin', + }, +}); +EOF + +# Create it.ts (Italian - should be excluded) +cat > "$TEST_DIR/plugins/test-plugin/src/translations/it.ts" << 'EOF' +import { createTranslationMessages } from '@backstage/core-plugin-api/alpha'; +import { testPluginMessages } from './ref'; + +export default createTranslationMessages({ + ref: testPluginMessages, + messages: { + title: 'Plugin di Test', + description: 'Questo รจ un plugin di test', + }, +}); +EOF + +# Test generate command +echo_info "Testing generate command..." +cd "$TEST_DIR" +node "$CLI_DIR/bin/translations-cli" i18n generate \ + --source-dir . \ + --output-dir i18n + +# Verify output file exists +if [ ! -f "$TEST_DIR/i18n/reference.json" ]; then + echo_error "reference.json was not created!" + exit 1 +fi +echo_info "reference.json created" + +# Verify structure +if ! grep -q '"test-plugin"' "$TEST_DIR/i18n/reference.json"; then + echo_error "test-plugin not found in reference.json!" + exit 1 +fi +echo_info "test-plugin found in reference.json" + +# Verify English keys are present +if ! grep -q '"title": "Test Plugin"' "$TEST_DIR/i18n/reference.json"; then + echo_error "English title not found!" + exit 1 +fi +echo_info "English keys found" + +# Verify non-English words are NOT present +if grep -q "Dies ist" "$TEST_DIR/i18n/reference.json"; then + echo_error "German text found in reference.json!" + exit 1 +fi +if grep -q "Plugin di Test" "$TEST_DIR/i18n/reference.json"; then + echo_error "Italian text found in reference.json!" + exit 1 +fi +echo_info "Non-English words correctly excluded" + +# Test help command +echo_info "Testing help command..." +node "$CLI_DIR/bin/translations-cli" i18n --help > /dev/null +echo_info "Help command works" + +# Test init command +echo_info "Testing init command..." +cd "$TEST_DIR" +node "$CLI_DIR/bin/translations-cli" i18n init +if [ ! -f "$TEST_DIR/.i18n.config.json" ]; then + echo_error ".i18n.config.json was not created!" + exit 1 +fi +echo_info "init command works" + +echo_info "All integration tests passed! โœ“" + diff --git a/workspaces/translations/packages/cli/test/manual-test-checklist.md b/workspaces/translations/packages/cli/test/manual-test-checklist.md new file mode 100644 index 0000000000..9632a446cc --- /dev/null +++ b/workspaces/translations/packages/cli/test/manual-test-checklist.md @@ -0,0 +1,197 @@ +# Manual Testing Checklist + +Use this checklist to manually test the CLI before release. + +## Prerequisites + +```bash +# Build the CLI +cd workspaces/translations/packages/cli +yarn build + +# Link globally (optional, for easier testing) +yarn link +``` + +## 1. Basic Command Tests + +### Help Commands + +- [ ] `translations-cli i18n --help` shows all available commands +- [ ] `translations-cli i18n generate --help` shows generate command options +- [ ] `translations-cli i18n upload --help` shows upload command options +- [ ] `translations-cli i18n download --help` shows download command options + +### Init Command + +- [ ] `translations-cli i18n init` creates `.i18n.config.json` +- [ ] `translations-cli i18n init` creates `.i18n.auth.json` (if not exists) +- [ ] Config files have correct structure + +## 2. Generate Command Tests + +### Basic Generation + +- [ ] `translations-cli i18n generate` creates `i18n/reference.json` +- [ ] Generated file has correct structure: `{ "plugin": { "en": { "key": "value" } } }` +- [ ] Only English reference keys are included +- [ ] Language files (de.ts, es.ts, fr.ts, etc.) are excluded + +### Filtering Tests + +- [ ] Files with `createTranslationRef` are included +- [ ] Files with `createTranslationMessages` are excluded (they may contain non-English) +- [ ] Files with `createTranslationResource` are excluded +- [ ] Non-English words (Italian, German, etc.) are NOT in reference.json + +### Options Tests + +- [ ] `--source-dir` option works +- [ ] `--output-dir` option works +- [ ] `--include-pattern` option works +- [ ] `--exclude-pattern` option works +- [ ] `--merge-existing` option works + +### Test in Real Repo + +```bash +cd /path/to/community-plugins +translations-cli i18n generate --source-dir . --output-dir i18n +# Check that reference.json only contains English +``` + +## 3. Upload Command Tests + +### Basic Upload + +- [ ] `translations-cli i18n upload --source-file i18n/reference.json --dry-run` works +- [ ] Dry-run shows what would be uploaded without actually uploading +- [ ] Actual upload works (if TMS configured) + +### Cache Tests + +- [ ] First upload creates cache +- [ ] Second upload (unchanged file) is skipped +- [ ] `--force` flag bypasses cache +- [ ] Cache file is created in `.i18n-cache/` + +### Options Tests + +- [ ] `--tms-url` option works +- [ ] `--tms-token` option works +- [ ] `--project-id` option works +- [ ] `--target-languages` option works +- [ ] `--upload-filename` option works + +## 4. Download Command Tests + +### Basic Download + +- [ ] `translations-cli i18n download --dry-run` works +- [ ] Dry-run shows what would be downloaded +- [ ] Actual download works (if TMS configured) + +### Options Tests + +- [ ] `--output-dir` option works +- [ ] `--target-languages` option works +- [ ] `--format` option works (json, po) + +## 5. Sync Command Tests + +- [ ] `translations-cli i18n sync --dry-run` shows all steps +- [ ] Sync performs: generate โ†’ upload โ†’ download โ†’ deploy +- [ ] Each step can be skipped with flags + +## 6. Deploy Command Tests + +- [ ] `translations-cli i18n deploy --dry-run` works +- [ ] Deploy copies files to correct locations +- [ ] `--format` option works + +## 7. Status Command Tests + +- [ ] `translations-cli i18n status` shows translation status +- [ ] Shows missing translations +- [ ] Shows completion percentages + +## 8. Clean Command Tests + +- [ ] `translations-cli i18n clean` removes cache files +- [ ] `--force` flag works +- [ ] Cache directory is cleaned + +## 9. Error Handling Tests + +- [ ] Invalid command shows helpful error +- [ ] Missing config file shows helpful error +- [ ] Invalid file path shows helpful error +- [ ] Network errors show helpful messages +- [ ] Authentication errors show helpful messages + +## 10. Integration Tests + +### Full Workflow Test + +```bash +# In a test repository +cd /path/to/test-repo + +# 1. Initialize +translations-cli i18n init + +# 2. Generate +translations-cli i18n generate + +# 3. Upload (dry-run) +translations-cli i18n upload --source-file i18n/reference.json --dry-run + +# 4. Download (dry-run) +translations-cli i18n download --dry-run + +# 5. Deploy (dry-run) +translations-cli i18n deploy --dry-run +``` + +### Real Repository Test + +```bash +# Test in community-plugins +cd /Users/yicai/redhat/community-plugins +translations-cli i18n generate --source-dir . --output-dir i18n + +# Verify: +# - reference.json only contains English +# - No Italian/German/French words in reference.json +# - All plugins are included +# - Language files are excluded +``` + +## 11. Edge Cases + +- [ ] Empty source directory +- [ ] No translation files found +- [ ] Invalid JSON in config +- [ ] Missing dependencies +- [ ] Large files (performance) +- [ ] Special characters in keys/values +- [ ] Unicode normalization + +## 12. Performance Tests + +- [ ] Generate on large codebase (< 30 seconds) +- [ ] Upload large file (< 60 seconds) +- [ ] Download large file (< 60 seconds) + +## Pre-Release Checklist + +Before releasing, ensure: + +- [ ] All manual tests pass +- [ ] Automated tests pass: `yarn test` +- [ ] Linting passes: `yarn lint` +- [ ] Build succeeds: `yarn build` +- [ ] Documentation is up to date +- [ ] Version number is correct +- [ ] CHANGELOG is updated +- [ ] Tested in at least 2 different repositories diff --git a/workspaces/translations/packages/cli/test/quick-test.sh b/workspaces/translations/packages/cli/test/quick-test.sh new file mode 100755 index 0000000000..f140be69c9 --- /dev/null +++ b/workspaces/translations/packages/cli/test/quick-test.sh @@ -0,0 +1,63 @@ +#!/bin/bash +# Quick test script - tests basic CLI functionality + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CLI_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" + +cd "$CLI_DIR" + +echo "๐Ÿ”จ Building CLI..." +yarn build + +echo "" +echo "๐Ÿงช Testing help command..." +if [ -f "dist/index.cjs.js" ]; then + node bin/translations-cli i18n --help > /dev/null && echo "โœ“ Help command works" +else + echo "โš  Build output not found, skipping help test" +fi + +echo "" +echo "๐Ÿงช Testing generate command (dry run)..." +# Create a minimal test structure +TEST_DIR="$CLI_DIR/.quick-test" +mkdir -p "$TEST_DIR/plugins/test/src/translations" + +cat > "$TEST_DIR/plugins/test/src/translations/ref.ts" << 'EOF' +import { createTranslationRef } from '@backstage/core-plugin-api/alpha'; +export const messages = createTranslationRef({ + id: 'test', + messages: { title: 'Test' }, +}); +EOF + +cd "$TEST_DIR" +if [ -f "$CLI_DIR/dist/index.cjs.js" ]; then + node "$CLI_DIR/bin/translations-cli" i18n generate --source-dir . --output-dir i18n > /dev/null +else + echo "โš  Build output not found, skipping generate test" + exit 0 +fi + +if [ -f "$TEST_DIR/i18n/reference.json" ]; then + echo "โœ“ Generate command works" + if grep -q '"test"' "$TEST_DIR/i18n/reference.json"; then + echo "โœ“ Generated file contains expected data" + else + echo "โœ— Generated file missing expected data" + exit 1 + fi +else + echo "โœ— Generate command failed - no output file" + exit 1 +fi + +# Cleanup +cd "$CLI_DIR" +rm -rf "$TEST_DIR" + +echo "" +echo "โœ… All quick tests passed!" + diff --git a/workspaces/translations/packages/cli/test/real-repo-test.sh b/workspaces/translations/packages/cli/test/real-repo-test.sh new file mode 100755 index 0000000000..320fd5a4f4 --- /dev/null +++ b/workspaces/translations/packages/cli/test/real-repo-test.sh @@ -0,0 +1,63 @@ +#!/bin/bash +# Test the CLI in a real repository +# Usage: ./test/real-repo-test.sh + +set -e + +if [ -z "$1" ]; then + echo "Usage: $0 " + echo "Example: $0 /Users/yicai/redhat/community-plugins" + exit 1 +fi + +REPO_PATH="$1" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CLI_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" + +if [ ! -d "$REPO_PATH" ]; then + echo "Error: Repository path does not exist: $REPO_PATH" + exit 1 +fi + +echo "๐Ÿ”จ Building CLI..." +cd "$CLI_DIR" +yarn build + +echo "" +echo "๐Ÿงช Testing in repository: $REPO_PATH" +cd "$REPO_PATH" + +# Test generate +echo "Running generate command..." +node "$CLI_DIR/bin/translations-cli" i18n generate --source-dir . --output-dir i18n + +# Check output +if [ -f "i18n/reference.json" ]; then + echo "โœ“ reference.json created" + + # Check for non-English words + if grep -qi "eventi\|panoramica\|servizi\|richieste\|macchina\|caricamento\|riprova\|chiudi" i18n/reference.json; then + echo "โš  WARNING: Non-English words found in reference.json!" + echo "This should only contain English translations." + else + echo "โœ“ No non-English words detected" + fi + + # Show summary + echo "" + echo "๐Ÿ“Š Summary:" + PLUGIN_COUNT=$(jq 'keys | length' i18n/reference.json 2>/dev/null || echo "0") + echo " Plugins: $PLUGIN_COUNT" + + if command -v jq &> /dev/null; then + TOTAL_KEYS=$(jq '[.[] | .en | keys | length] | add' i18n/reference.json 2>/dev/null || echo "0") + echo " Total keys: $TOTAL_KEYS" + fi +else + echo "โœ— reference.json was not created" + exit 1 +fi + +echo "" +echo "โœ… Test completed successfully!" + diff --git a/workspaces/translations/packages/cli/test/test-helpers.ts b/workspaces/translations/packages/cli/test/test-helpers.ts new file mode 100644 index 0000000000..f370d5bb2a --- /dev/null +++ b/workspaces/translations/packages/cli/test/test-helpers.ts @@ -0,0 +1,142 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import fs from 'fs-extra'; +import path from 'path'; +import { execSync } from 'child_process'; + +export interface TestFixture { + path: string; + cleanup: () => Promise; +} + +/** + * Create a temporary test directory with sample translation files + */ +export async function createTestFixture(): Promise { + const testDir = path.join(process.cwd(), '.test-temp'); + await fs.ensureDir(testDir); + + // Create a sample plugin structure + const pluginDir = path.join( + testDir, + 'plugins', + 'test-plugin', + 'src', + 'translations', + ); + await fs.ensureDir(pluginDir); + + // Create a ref.ts file with createTranslationRef + const refFile = path.join(pluginDir, 'ref.ts'); + await fs.writeFile( + refFile, + `import { createTranslationRef } from '@backstage/core-plugin-api/alpha'; + +export const testPluginMessages = createTranslationRef({ + id: 'test-plugin', + messages: { + title: 'Test Plugin', + description: 'This is a test plugin', + button: { + save: 'Save', + cancel: 'Cancel', + }, + }, +}); +`, + ); + + // Create a de.ts file (should be excluded) + const deFile = path.join(pluginDir, 'de.ts'); + await fs.writeFile( + deFile, + `import { createTranslationMessages } from '@backstage/core-plugin-api/alpha'; +import { testPluginMessages } from './ref'; + +export default createTranslationMessages({ + ref: testPluginMessages, + messages: { + title: 'Test Plugin (German)', + description: 'Dies ist ein Test-Plugin', + }, +}); +`, + ); + + return { + path: testDir, + cleanup: async () => { + await fs.remove(testDir); + }, + }; +} + +/** + * Run CLI command and return output + */ +export function runCLI( + command: string, + cwd?: string, +): { + stdout: string; + stderr: string; + exitCode: number; +} { + try { + const binPath = path.join(process.cwd(), 'bin', 'translations-cli'); + const fullCommand = `${binPath} ${command}`; + const stdout = execSync(fullCommand, { + cwd: cwd || process.cwd(), + encoding: 'utf-8', + stdio: 'pipe', + }); + return { stdout, stderr: '', exitCode: 0 }; + } catch (error: any) { + return { + stdout: error.stdout?.toString() || '', + stderr: error.stderr?.toString() || '', + exitCode: error.status || 1, + }; + } +} + +/** + * Check if file exists and has expected content + */ +export async function assertFileContains( + filePath: string, + expectedContent: string | RegExp, +): Promise { + if (!(await fs.pathExists(filePath))) { + throw new Error(`File does not exist: ${filePath}`); + } + + const content = await fs.readFile(filePath, 'utf-8'); + if (typeof expectedContent === 'string') { + if (!content.includes(expectedContent)) { + throw new Error( + `File ${filePath} does not contain expected content: ${expectedContent}`, + ); + } + } else { + if (!expectedContent.test(content)) { + throw new Error( + `File ${filePath} does not match expected pattern: ${expectedContent}`, + ); + } + } +} diff --git a/workspaces/translations/packages/cli/vitest.config.ts b/workspaces/translations/packages/cli/vitest.config.ts new file mode 100644 index 0000000000..0b09a9606b --- /dev/null +++ b/workspaces/translations/packages/cli/vitest.config.ts @@ -0,0 +1,29 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['test/**/*.test.ts', 'src/**/*.test.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + exclude: ['node_modules/', 'dist/', 'test/'], + }, + }, +}); From be94d5fb2b76063ae6ef70143afee31f99f4f997 Mon Sep 17 00:00:00 2001 From: Yi Cai Date: Fri, 12 Dec 2025 15:06:36 -0500 Subject: [PATCH 3/5] feat(translations-cli): add download and deploy commands with multi-repo support - Add download command to fetch completed translations from Memsource - Support downloading all completed jobs or specific job IDs - Support filtering by languages - Auto-detects repo type and filters files accordingly - Add deploy command to deploy translations to TypeScript files - Universal deployment script supporting rhdh-plugins, community-plugins, and rhdh - Auto-detects repository structure and plugin locations - Handles different file naming conventions ({lang}.ts vs {plugin}-{lang}.ts) - Correctly handles import paths (./ref, ./translations, external @backstage packages) - Updates existing files and creates new translation files - Automatically updates index.ts files to register translations - Add comprehensive documentation - Complete workflow guide for download and deployment - Multi-repo deployment documentation - Step-by-step instructions for all three repositories - Update yarn.lock with latest dependencies --- .../cli/docs/download-deploy-usage.md | 414 +++++++++++ .../cli/docs/multi-repo-deployment.md | 163 +++++ .../cli/scripts/deploy-translations.ts | 691 ++++++++++++++++++ .../packages/cli/src/commands/deploy.ts | 141 ++-- .../packages/cli/src/commands/download.ts | 374 ++++++---- .../packages/cli/src/commands/index.ts | 33 +- workspaces/translations/yarn.lock | 14 +- 7 files changed, 1586 insertions(+), 244 deletions(-) create mode 100644 workspaces/translations/packages/cli/docs/download-deploy-usage.md create mode 100644 workspaces/translations/packages/cli/docs/multi-repo-deployment.md create mode 100644 workspaces/translations/packages/cli/scripts/deploy-translations.ts diff --git a/workspaces/translations/packages/cli/docs/download-deploy-usage.md b/workspaces/translations/packages/cli/docs/download-deploy-usage.md new file mode 100644 index 0000000000..8f4219dc60 --- /dev/null +++ b/workspaces/translations/packages/cli/docs/download-deploy-usage.md @@ -0,0 +1,414 @@ +# Download and Deploy Translations + +This guide explains how to use the automated download and deploy commands for translations. + +## Prerequisites + +1. **Memsource CLI setup**: Ensure you have `memsource` CLI installed and `~/.memsourcerc` is sourced: + + ```bash + source ~/.memsourcerc + ``` + +2. **Project configuration**: Ensure `.i18n.config.json` exists in your project root with: + + ```json + { + "tms": { + "url": "https://cloud.memsource.com/web", + "projectId": "your-project-id" + } + } + ``` + +3. **tsx installed**: For the deploy command, ensure `tsx` is available: + ```bash + npm install -g tsx + # or + yarn add -D tsx + ``` + +## Download Translations + +Download completed translation jobs from Memsource: + +### Download all completed jobs: + +```bash +translations-cli i18n download +``` + +### Download specific languages: + +```bash +translations-cli i18n download --languages "it,ja,fr" +``` + +### Download specific job IDs: + +```bash +translations-cli i18n download --job-ids "13,14,16,17,19,20" +``` + +### Custom output directory: + +```bash +translations-cli i18n download --output-dir "custom/downloads" +``` + +**Options:** + +- `--project-id `: Memsource project ID (can be set in `.i18n.config.json`) +- `--output-dir `: Output directory (default: `i18n/downloads`) +- `--languages `: Comma-separated list of languages (e.g., "it,ja,fr") +- `--job-ids `: Comma-separated list of specific job IDs to download + +**Output:** + +- Downloaded files are saved to the output directory +- Files are named: `{filename}-{lang}-C.json` (e.g., `rhdh-plugins-reference-2025-12-05-it-C.json`) + +## Deploy Translations + +Deploy downloaded translations to TypeScript translation files: + +### Deploy from default location: + +```bash +translations-cli i18n deploy +``` + +### Deploy from custom location: + +```bash +translations-cli i18n deploy --source-dir "custom/downloads" +``` + +**Options:** + +- `--source-dir `: Source directory containing downloaded translations (default: `i18n/downloads`) + +**What it does:** + +1. Reads JSON files from the source directory +2. Finds corresponding plugin translation directories +3. Updates existing `it.ts` files with new translations +4. Creates new `ja.ts` files for plugins that don't have them +5. Updates `index.ts` files to register Japanese translations + +**Output:** + +- Updated/created files in `workspaces/*/plugins/*/src/translations/` +- Files maintain TypeScript format with proper imports +- All translations are registered in `index.ts` files + +## Complete Workflow + +This section provides a comprehensive step-by-step guide for the entire translation workflow across all three repositories (`rhdh-plugins`, `community-plugins`, and `rhdh`). + +### Prerequisites (One-Time Setup) + +Before starting, ensure you have completed the initial setup: + +1. **Memsource CLI installed and configured**: + + ```bash + pip install memsource-cli-client + # Configure ~/.memsourcerc with your credentials + source ~/.memsourcerc + ``` + +2. **Project configuration files**: + + - Each repo should have `.i18n.config.json` in its root directory + - Contains TMS URL, Project ID, and target languages + +3. **tsx installed** (for deploy command): + ```bash + npm install -g tsx + # or + yarn add -D tsx + ``` + +### Step 1: Generate Reference Files + +Generate the reference translation files for each repository. These files contain all English strings that need translation. + +#### For rhdh-plugins: + +```bash +cd /path/to/rhdh-plugins +translations-cli i18n generate +``` + +**Output**: `workspaces/i18n/reference.json` + +#### For community-plugins: + +```bash +cd /path/to/community-plugins +translations-cli i18n generate +``` + +**Output**: `i18n/reference.json` + +#### For rhdh: + +```bash +cd /path/to/rhdh +translations-cli i18n generate +``` + +**Output**: `i18n/reference.json` + +**What it does:** + +- Scans all TypeScript/JavaScript source files +- Extracts translation keys from `createTranslationRef` and `createTranslationMessages` calls +- Generates a flat JSON file with all English reference strings +- File format: `{ "pluginName": { "en": { "key": "English value" } } }` + +### Step 2: Upload Reference Files to Memsource + +Upload the generated reference files to your TMS project for translation. + +#### For rhdh-plugins: + +```bash +cd /path/to/rhdh-plugins +translations-cli i18n upload +``` + +#### For community-plugins: + +```bash +cd /path/to/community-plugins +translations-cli i18n upload +``` + +#### For rhdh: + +```bash +cd /path/to/rhdh +translations-cli i18n upload +``` + +**What it does:** + +- Reads `i18n/reference.json` (or `workspaces/i18n/reference.json` for rhdh-plugins) +- Uploads to Memsource project specified in `.i18n.config.json` +- Creates translation jobs for each target language (e.g., `it`, `ja`, `fr`) +- Uses caching to avoid re-uploading unchanged files (use `--force` to bypass) + +**Output:** + +- Success message with job IDs +- Files are now available in Memsource UI for translation + +### Step 3: Wait for Translations to Complete + +1. **Monitor progress in Memsource UI**: + + - Visit your Memsource project: `https://cloud.memsource.com/web/project2/show/{projectId}` + - Check job status for each language + - Wait for jobs to be marked as "Completed" + +2. **Note the job IDs**: + - Job IDs are displayed in the Memsource UI + - You'll need these for downloading specific jobs + - Example: Jobs 13, 14, 16, 17, 19, 20 + +### Step 4: Download Completed Translations + +Download the translated files from Memsource. You can download from any repository - the files are named with repo prefixes, so they won't conflict. + +#### Option A: Download all completed jobs + +```bash +cd /path/to/rhdh-plugins # Can be any repo +source ~/.memsourcerc +translations-cli i18n download +``` + +#### Option B: Download specific job IDs + +```bash +cd /path/to/rhdh-plugins +source ~/.memsourcerc +translations-cli i18n download --job-ids "13,14,16,17,19,20" +``` + +#### Option C: Download specific languages + +```bash +cd /path/to/rhdh-plugins +source ~/.memsourcerc +translations-cli i18n download --languages "it,ja,fr" +``` + +#### Option D: Download to shared location (recommended for multi-repo) + +```bash +mkdir -p ~/translations/downloads +cd /path/to/rhdh-plugins +source ~/.memsourcerc +translations-cli i18n download --output-dir ~/translations/downloads +``` + +**What it does:** + +- Lists all completed jobs from Memsource project +- Downloads JSON files for each language +- Filters by job IDs or languages if specified +- Saves files with naming pattern: `{repo-name}-reference-{date}-{lang}-C.json` + +**Output files:** + +- `rhdh-plugins-reference-2025-12-05-it-C.json` +- `rhdh-plugins-reference-2025-12-05-ja-C.json` +- `community-plugins-reference-2025-12-05-it-C.json` +- `rhdh-reference-2025-12-05-it-C.json` +- etc. + +**Default location**: `i18n/downloads/` in the current repo + +### Step 5: Deploy Translations to Application + +Deploy the downloaded translations back to your application's TypeScript translation files. The deploy command automatically detects which repo you're in and processes only the relevant files. + +#### For rhdh-plugins: + +```bash +cd /path/to/rhdh-plugins +translations-cli i18n deploy +# Or from shared location: +translations-cli i18n deploy --source-dir ~/translations/downloads +``` + +#### For community-plugins: + +```bash +cd /path/to/community-plugins +translations-cli i18n deploy +# Or from shared location: +translations-cli i18n deploy --source-dir ~/translations/downloads +``` + +#### For rhdh: + +```bash +cd /path/to/rhdh +translations-cli i18n deploy +# Or from shared location: +translations-cli i18n deploy --source-dir ~/translations/downloads +``` + +**What it does:** + +1. **Detects repository type** automatically (rhdh-plugins, community-plugins, or rhdh) +2. **Finds downloaded files** matching the current repo (filters by repo name in filename) +3. **Locates plugin translation directories**: + - `rhdh-plugins`: `workspaces/*/plugins/*/src/translations/` + - `community-plugins`: `workspaces/*/plugins/*/src/translations/` + - `rhdh`: `packages/app/src/translations/{plugin}/` or flat structure +4. **Updates existing files** (e.g., `it.ts`) with new translations +5. **Creates new files** (e.g., `ja.ts`) for plugins that don't have them +6. **Updates `index.ts`** files to register new translations +7. **Handles import paths** correctly: + - Local imports: `./ref` or `./translations` + - External imports: `@backstage/plugin-*/alpha` (for rhdh repo) + +**Output:** + +- Updated/created TypeScript files in plugin translation directories +- Files maintain proper TypeScript format with correct imports +- All translations registered in `index.ts` files + +### Step 6: Verify Deployment + +After deployment, verify that translations are correctly integrated: + +1. **Check file syntax**: + + ```bash + # In each repo, check for TypeScript errors + yarn tsc --noEmit + ``` + +2. **Verify imports**: + + - Ensure all import paths are correct + - Check that `./ref` or external package imports exist + +3. **Test in application**: + - Build and run the application + - Switch language settings + - Verify translations appear correctly + +## Complete Workflow Example (All 3 Repos) + +Here's a complete example workflow for all three repositories: + +```bash +# 1. Setup (one-time) +source ~/.memsourcerc + +# 2. Generate reference files for all repos +cd /path/to/rhdh-plugins && translations-cli i18n generate +cd /path/to/community-plugins && translations-cli i18n generate +cd /path/to/rhdh && translations-cli i18n generate + +# 3. Upload all reference files +cd /path/to/rhdh-plugins && translations-cli i18n upload +cd /path/to/community-plugins && translations-cli i18n upload +cd /path/to/rhdh && translations-cli i18n upload + +# 4. Wait for translations in Memsource UI... + +# 5. Download all translations to shared location +mkdir -p ~/translations/downloads +cd /path/to/rhdh-plugins +translations-cli i18n download --output-dir ~/translations/downloads + +# 6. Deploy to each repo +cd /path/to/rhdh-plugins +translations-cli i18n deploy --source-dir ~/translations/downloads + +cd /path/to/community-plugins +translations-cli i18n deploy --source-dir ~/translations/downloads + +cd /path/to/rhdh +translations-cli i18n deploy --source-dir ~/translations/downloads + +# 7. Verify +cd /path/to/rhdh-plugins && yarn tsc --noEmit +cd /path/to/community-plugins && yarn tsc --noEmit +cd /path/to/rhdh && yarn tsc --noEmit +``` + +## Troubleshooting + +### "memsource CLI not found" + +- Ensure `memsource` CLI is installed +- Check that `~/.memsourcerc` is sourced: `source ~/.memsourcerc` + +### "MEMSOURCE_TOKEN not found" + +- Source `~/.memsourcerc`: `source ~/.memsourcerc` +- Verify token: `echo $MEMSOURCE_TOKEN` + +### "tsx not found" (deploy command) + +- Install tsx: `npm install -g tsx` or `yarn add -D tsx` + +### "No translation files found" + +- Ensure you've run the download command first +- Check that files exist in the source directory +- Verify file names end with `.json` + +### "Plugin not found" during deploy + +- Ensure plugin translation directories exist +- Check that plugin names match between downloaded files and workspace structure diff --git a/workspaces/translations/packages/cli/docs/multi-repo-deployment.md b/workspaces/translations/packages/cli/docs/multi-repo-deployment.md new file mode 100644 index 0000000000..a8d0c6b3c5 --- /dev/null +++ b/workspaces/translations/packages/cli/docs/multi-repo-deployment.md @@ -0,0 +1,163 @@ +# Multi-Repository Translation Deployment + +The CLI commands are **universal** and work for all three repositories: `rhdh-plugins`, `community-plugins`, and `rhdh`. + +## How It Works + +The deployment script automatically: + +1. **Detects the repository type** based on directory structure +2. **Finds downloaded translation files** matching the current repo (no hardcoded dates) +3. **Locates plugin translation directories** using repo-specific patterns +4. **Handles different file naming conventions** per repo + +## Repository Structures Supported + +### 1. rhdh-plugins + +- **Structure**: `workspaces/*/plugins/*/src/translations/` +- **Files**: `{lang}.ts` (e.g., `it.ts`, `ja.ts`) +- **Example**: `workspaces/adoption-insights/plugins/adoption-insights/src/translations/it.ts` + +### 2. community-plugins + +- **Structure**: `workspaces/*/plugins/*/src/translations/` +- **Files**: `{lang}.ts` (e.g., `it.ts`, `ja.ts`) +- **Example**: `workspaces/rbac/plugins/rbac/src/translations/it.ts` + +### 3. rhdh + +- **Structure**: `packages/app/src/translations/{plugin}/` or flat `packages/app/src/translations/` +- **Files**: `{lang}.ts` or `{plugin}-{lang}.ts` (e.g., `it.ts` or `user-settings-it.ts`) +- **Example**: `packages/app/src/translations/user-settings/user-settings-it.ts` + +## Usage + +### Step 1: Download translations (same for all repos) + +From any of the three repositories: + +```bash +# Download all completed jobs +translations-cli i18n download + +# Or download specific job IDs +translations-cli i18n download --job-ids "13,14,16,17,19,20" + +# Or download specific languages +translations-cli i18n download --languages "it,ja" +``` + +**Note**: Downloaded files are named with repo prefix: + +- `rhdh-plugins-reference-*-{lang}-C.json` +- `community-plugins-reference-*-{lang}-C.json` +- `rhdh-reference-*-{lang}-C.json` + +### Step 2: Deploy translations (same command, different repos) + +The deploy command automatically detects which repo you're in and processes only the relevant files: + +#### For rhdh-plugins: + +```bash +cd /path/to/rhdh-plugins +translations-cli i18n deploy +``` + +#### For community-plugins: + +```bash +cd /path/to/community-plugins +translations-cli i18n deploy +``` + +#### For rhdh: + +```bash +cd /path/to/rhdh +translations-cli i18n deploy +``` + +## Complete Workflow for All Repos + +### Option A: Deploy to each repo separately + +```bash +# 1. Download all translations (from any repo) +cd /path/to/rhdh-plugins +translations-cli i18n download + +# 2. Deploy to rhdh-plugins +translations-cli i18n deploy + +# 3. Deploy to community-plugins +cd /path/to/community-plugins +translations-cli i18n deploy --source-dir /path/to/rhdh-plugins/i18n/downloads + +# 4. Deploy to rhdh +cd /path/to/rhdh +translations-cli i18n deploy --source-dir /path/to/rhdh-plugins/i18n/downloads +``` + +### Option B: Use shared download directory + +```bash +# 1. Download to a shared location +mkdir -p ~/translations/downloads +cd /path/to/rhdh-plugins +translations-cli i18n download --output-dir ~/translations/downloads + +# 2. Deploy from shared location to each repo +cd /path/to/rhdh-plugins +translations-cli i18n deploy --source-dir ~/translations/downloads + +cd /path/to/community-plugins +translations-cli i18n deploy --source-dir ~/translations/downloads + +cd /path/to/rhdh +translations-cli i18n deploy --source-dir ~/translations/downloads +``` + +## Auto-Detection Features + +### Repository Detection + +The script detects the repo type by checking: + +- `workspaces/` directory โ†’ `rhdh-plugins` or `community-plugins` +- `packages/app/` directory โ†’ `rhdh` + +### File Detection + +The script automatically finds downloaded files matching: + +- Pattern: `{repo-name}-reference-*-{lang}-C.json` +- No hardcoded dates - works with any download date +- Only processes files matching the current repo + +### Plugin Location + +The script searches for plugins using repo-specific patterns: + +- **rhdh-plugins/community-plugins**: `workspaces/*/plugins/{plugin}/src/translations/` +- **rhdh**: `packages/app/src/translations/{plugin}/` or flat structure + +## Troubleshooting + +### "Could not detect repository type" + +- Ensure you're running the command from the repository root +- Check that `workspaces/` or `packages/` directory exists + +### "No translation files found for {repo}" + +- Verify downloaded files exist in the source directory +- Check that file names match pattern: `{repo}-reference-*-{lang}-C.json` +- Ensure you've run the download command first + +### "Plugin not found" warnings + +- Some plugins might not exist in all repos +- This is normal - the script skips missing plugins +- Check that plugin names match between downloaded files and repo structure diff --git a/workspaces/translations/packages/cli/scripts/deploy-translations.ts b/workspaces/translations/packages/cli/scripts/deploy-translations.ts new file mode 100644 index 0000000000..d92b683b24 --- /dev/null +++ b/workspaces/translations/packages/cli/scripts/deploy-translations.ts @@ -0,0 +1,691 @@ +#!/usr/bin/env node +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import fs from 'fs-extra'; +import path from 'path'; +import chalk from 'chalk'; + +interface TranslationData { + [pluginName: string]: { + en: { + [key: string]: string; + }; + }; +} + +interface PluginInfo { + name: string; + translationDir: string; + refImportName: string; + variableName: string; +} + +/** + * Find the correct import path for translation ref + */ +function findRefImportPath(translationDir: string): string { + // Check common patterns in order of preference + if (fs.existsSync(path.join(translationDir, 'ref.ts'))) { + return './ref'; + } + if (fs.existsSync(path.join(translationDir, 'translations.ts'))) { + return './translations'; + } + // Default fallback + return './ref'; +} + +/** + * Extract ref import name, import path, and variable name from existing translation file + */ +function extractRefInfo(filePath: string): { + refImportName: string; + refImportPath: string; + variableName: string; +} | null { + try { + const content = fs.readFileSync(filePath, 'utf-8'); + + // Extract ref import: import { xxxTranslationRef } from './ref' or from '@backstage/...' + // Match both local and external imports + const refImportMatch = content.match( + /import\s*{\s*([a-zA-Z0-9_]+TranslationRef)\s*}\s*from\s*['"]([^'"]+)['"]/, + ); + if (!refImportMatch) { + return null; + } + const refImportName = refImportMatch[1]; + let refImportPath = refImportMatch[2]; + + // If it's a local import (starts with ./), verify the file exists + // If not, try to find the correct path + if (refImportPath.startsWith('./')) { + const translationDir = path.dirname(filePath); + const expectedFile = path.join( + translationDir, + `${refImportPath.replace('./', '')}.ts`, + ); + if (!fs.existsSync(expectedFile)) { + // Try to find the correct import path + refImportPath = findRefImportPath(translationDir); + } + } + + // Extract variable name: const xxxTranslationIt = ... or const de = ... + // Try full pattern first + let variableMatch = content.match( + /const\s+([a-zA-Z0-9_]+Translation(?:It|Ja|De|Fr|Es))\s*=/, + ); + + // If not found, try simple pattern (like const de = ...) + if (!variableMatch) { + variableMatch = content.match( + /const\s+([a-z]+)\s*=\s*createTranslationMessages/, + ); + } + + if (!variableMatch) { + return null; + } + const variableName = variableMatch[1]; + + return { refImportName, refImportPath, variableName }; + } catch { + return null; + } +} + +/** + * Map plugin names to their Backstage package imports (for rhdh repo) + */ +function getPluginPackageImport(pluginName: string): string | null { + const pluginPackageMap: Record = { + search: '@backstage/plugin-search/alpha', + 'user-settings': '@backstage/plugin-user-settings/alpha', + scaffolder: '@backstage/plugin-scaffolder/alpha', + 'core-components': '@backstage/core-components/alpha', + 'catalog-import': '@backstage/plugin-catalog-import/alpha', + catalog: '@backstage/plugin-catalog-react/alpha', + }; + + return pluginPackageMap[pluginName] || null; +} + +/** + * Infer ref import name, import path, and variable name from plugin name + */ +function inferRefInfo( + pluginName: string, + lang: string, + repoType: string, + translationDir?: string, +): { + refImportName: string; + refImportPath: string; + variableName: string; +} { + // Convert plugin name to camelCase + const camelCase = pluginName + .split('-') + .map((word, i) => + i === 0 ? word : word.charAt(0).toUpperCase() + word.slice(1), + ) + .join(''); + + const refImportName = `${camelCase}TranslationRef`; + const langCapitalized = lang.charAt(0).toUpperCase() + lang.slice(1); + const variableName = `${camelCase}Translation${langCapitalized}`; + + // Determine import path + let refImportPath = './ref'; + + // For rhdh repo, try to use external package imports + if (repoType === 'rhdh') { + const packageImport = getPluginPackageImport(pluginName); + if (packageImport) { + refImportPath = packageImport; + } + } else if (translationDir) { + // For other repos, check what file exists + refImportPath = findRefImportPath(translationDir); + } + + return { refImportName, refImportPath, variableName }; +} + +/** + * Detect repository type based on structure + */ +function detectRepoType( + repoRoot: string, +): 'rhdh-plugins' | 'community-plugins' | 'rhdh' | 'unknown' { + const workspacesDir = path.join(repoRoot, 'workspaces'); + const packagesDir = path.join(repoRoot, 'packages'); + + if (fs.existsSync(workspacesDir)) { + // Check if it's rhdh-plugins or community-plugins + // Both have workspaces, but we can check the repo name or other indicators + const repoName = path.basename(repoRoot); + if (repoName === 'community-plugins') { + return 'community-plugins'; + } + // Default to rhdh-plugins if workspaces exist + return 'rhdh-plugins'; + } + + if (fs.existsSync(packagesDir)) { + // Check if it's rhdh repo (has packages/app structure) + const appDir = path.join(packagesDir, 'app'); + if (fs.existsSync(appDir)) { + return 'rhdh'; + } + } + + return 'unknown'; +} + +/** + * Find plugin translation directory (supports multiple repo structures) + */ +function findPluginTranslationDir( + pluginName: string, + repoRoot: string, +): string | null { + const repoType = detectRepoType(repoRoot); + + // Structure 1: workspaces/*/plugins/*/src/translations (rhdh-plugins, community-plugins) + if (repoType === 'rhdh-plugins' || repoType === 'community-plugins') { + const workspacesDir = path.join(repoRoot, 'workspaces'); + + if (fs.existsSync(workspacesDir)) { + const workspaceDirs = fs.readdirSync(workspacesDir); + + for (const workspace of workspaceDirs) { + const pluginsDir = path.join( + workspacesDir, + workspace, + 'plugins', + pluginName, + 'src', + 'translations', + ); + + if (fs.existsSync(pluginsDir)) { + return pluginsDir; + } + } + } + } + + // Structure 2: packages/app/src/translations/{plugin}/ (rhdh) + if (repoType === 'rhdh') { + // Try: packages/app/src/translations/{plugin}/ + const pluginDir = path.join( + repoRoot, + 'packages', + 'app', + 'src', + 'translations', + pluginName, + ); + + if (fs.existsSync(pluginDir)) { + return pluginDir; + } + + // Try: packages/app/src/translations/ (flat structure with {plugin}-{lang}.ts files) + const translationsDir = path.join( + repoRoot, + 'packages', + 'app', + 'src', + 'translations', + ); + + if (fs.existsSync(translationsDir)) { + // Check if there are files like {plugin}-{lang}.ts + const files = fs.readdirSync(translationsDir); + const hasPluginFiles = files.some( + f => f.startsWith(`${pluginName}-`) && f.endsWith('.ts'), + ); + + if (hasPluginFiles) { + return translationsDir; + } + } + } + + return null; +} + +/** + * Generate TypeScript translation file content + */ +function generateTranslationFile( + pluginName: string, + lang: string, + messages: { [key: string]: string }, + refImportName: string, + refImportPath: string, + variableName: string, +): string { + let langName: string; + if (lang === 'it') { + langName = 'Italian'; + } else if (lang === 'ja') { + langName = 'Japanese'; + } else { + langName = lang; + } + + const messagesContent = Object.entries(messages) + .map(([key, value]) => { + // Escape single quotes and backslashes in values + const escapedValue = value + .replace(/\\/g, '\\\\') + .replace(/'/g, "\\'") + .replace(/\n/g, '\\n'); + return ` '${key}': '${escapedValue}',`; + }) + .join('\n'); + + return `/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createTranslationMessages } from '@backstage/core-plugin-api/alpha'; +import { ${refImportName} } from '${refImportPath}'; + +/** + * ${langName} translation for ${pluginName}. + * @public + */ +const ${variableName} = createTranslationMessages({ + ref: ${refImportName}, + messages: { +${messagesContent} + }, +}); + +export default ${variableName}; +`; +} + +/** + * Auto-detect downloaded translation files + */ +function detectDownloadedFiles( + downloadDir: string, + repoType: string, +): Record { + const files: Record = {}; + + if (!fs.existsSync(downloadDir)) { + return files; + } + + // List all JSON files in download directory + const allFiles = fs.readdirSync(downloadDir).filter(f => f.endsWith('.json')); + + // Pattern: {repo}-reference-{date}-{lang}-C.json + // Examples: + // - rhdh-plugins-reference-2025-12-05-it-C.json + // - community-plugins-reference-2025-12-05-ja-C.json + // - rhdh-reference-2025-12-05-it-C.json + + for (const file of allFiles) { + // Match pattern: {repo}-reference-*-{lang}-C.json + const match = file.match(/^(.+)-reference-.+-(it|ja|fr|de|es)-C\.json$/); + if (match) { + const fileRepo = match[1]; + const lang = match[2]; + + // Only include files that match the current repo + if ( + (repoType === 'rhdh-plugins' && fileRepo === 'rhdh-plugins') || + (repoType === 'community-plugins' && + fileRepo === 'community-plugins') || + (repoType === 'rhdh' && fileRepo === 'rhdh') + ) { + files[lang] = file; + } + } + } + + return files; +} + +/** + * Deploy translations from downloaded JSON files + */ +async function deployTranslations( + downloadDir: string, + repoRoot: string, +): Promise { + console.log(chalk.blue('๐Ÿš€ Deploying translations...\n')); + + // Detect repository type + const repoType = detectRepoType(repoRoot); + console.log(chalk.cyan(`๐Ÿ“ฆ Detected repository: ${repoType}\n`)); + + if (repoType === 'unknown') { + throw new Error( + 'Could not detect repository type. Expected: rhdh-plugins, community-plugins, or rhdh', + ); + } + + // Auto-detect downloaded files for this repo + const repoFiles = detectDownloadedFiles(downloadDir, repoType); + + if (Object.keys(repoFiles).length === 0) { + console.warn( + chalk.yellow( + `โš ๏ธ No translation files found for ${repoType} in ${downloadDir}`, + ), + ); + console.warn( + chalk.gray( + ` Expected files like: ${repoType}-reference-*-{lang}-C.json`, + ), + ); + return; + } + + console.log( + chalk.cyan( + `๐Ÿ“ Found ${ + Object.keys(repoFiles).length + } translation file(s) for ${repoType}`, + ), + ); + + let totalUpdated = 0; + let totalCreated = 0; + + for (const [lang, filename] of Object.entries(repoFiles)) { + const filepath = path.join(downloadDir, filename); + + if (!fs.existsSync(filepath)) { + console.warn(chalk.yellow(` โš ๏ธ File not found: ${filename}`)); + continue; + } + + const data: TranslationData = JSON.parse( + fs.readFileSync(filepath, 'utf-8'), + ); + + console.log(chalk.cyan(`\n ๐ŸŒ Language: ${lang.toUpperCase()}`)); + + for (const [pluginName, pluginData] of Object.entries(data)) { + const translations = pluginData.en || {}; + + if (Object.keys(translations).length === 0) { + continue; + } + + // Find plugin translation directory + const translationDir = findPluginTranslationDir(pluginName, repoRoot); + + if (!translationDir) { + console.warn( + chalk.yellow(` โš ๏ธ Plugin "${pluginName}" not found, skipping...`), + ); + continue; + } + + // For rhdh repo, files might be named {plugin}-{lang}.ts instead of {lang}.ts + let targetFile: string; + if (repoType === 'rhdh') { + // Check if files use {plugin}-{lang}.ts format + const pluginLangFile = path.join( + translationDir, + `${pluginName}-${lang}.ts`, + ); + const langFile = path.join(translationDir, `${lang}.ts`); + + // Prefer existing file format, or default to {lang}.ts + if (fs.existsSync(pluginLangFile)) { + targetFile = pluginLangFile; + } else if (fs.existsSync(langFile)) { + targetFile = langFile; + } else { + // Default to {lang}.ts for new files + targetFile = langFile; + } + } else { + targetFile = path.join(translationDir, `${lang}.ts`); + } + + const exists = fs.existsSync(targetFile); + + // Get ref info from existing file or infer + // Strategy: Always check other existing files first to get correct import path, + // then fall back to existing file or inference + let refInfo: { + refImportName: string; + refImportPath: string; + variableName: string; + }; + + // First, try to get from another language file in same directory (prioritize these) + // For rhdh, check both naming conventions: {plugin}-{lang}.ts and {lang}.ts + const otherLangFiles = ['it', 'ja', 'de', 'fr', 'es', 'en'] + .filter(l => l !== lang) + .flatMap(l => { + if (repoType === 'rhdh') { + // Check both naming patterns + const pluginLangFile = path.join( + translationDir, + `${pluginName}-${l}.ts`, + ); + const langFile = path.join(translationDir, `${l}.ts`); + const files = []; + if (fs.existsSync(pluginLangFile)) files.push(pluginLangFile); + if (fs.existsSync(langFile)) files.push(langFile); + return files; + } + const langFile = path.join(translationDir, `${l}.ts`); + return fs.existsSync(langFile) ? [langFile] : []; + }); + + // Try to extract from other language files first (they likely have correct imports) + let foundRefInfo = false; + for (const otherFile of otherLangFiles) { + const otherRefInfo = extractRefInfo(otherFile); + if (otherRefInfo) { + // Verify the import path is valid (file exists) + if (otherRefInfo.refImportPath.startsWith('./')) { + const expectedFile = path.join( + translationDir, + `${otherRefInfo.refImportPath.replace('./', '')}.ts`, + ); + if (!fs.existsSync(expectedFile)) { + // Import path is invalid, try to find correct one + otherRefInfo.refImportPath = findRefImportPath(translationDir); + } + } + + // Use this ref info (prioritize external package imports for rhdh) + if ( + repoType === 'rhdh' && + !otherRefInfo.refImportPath.startsWith('./') + ) { + // Found a file with external package import - use it + const langCapitalized = + lang.charAt(0).toUpperCase() + lang.slice(1); + // For variable name, try to match pattern or use simple lang code + let variableName = otherRefInfo.variableName; + if (variableName.match(/Translation(It|Ja|De|Fr|Es)$/)) { + variableName = variableName.replace( + /Translation(It|Ja|De|Fr|Es)$/, + `Translation${langCapitalized}`, + ); + } else { + // Simple pattern like "const de = ..." + variableName = lang; + } + refInfo = { + refImportName: otherRefInfo.refImportName, + refImportPath: otherRefInfo.refImportPath, + variableName, + }; + foundRefInfo = true; + break; + } else if ( + repoType !== 'rhdh' || + otherRefInfo.refImportPath.startsWith('./') + ) { + // For non-rhdh repos, or local imports, use it + const langCapitalized = + lang.charAt(0).toUpperCase() + lang.slice(1); + let variableName = otherRefInfo.variableName; + if (variableName.match(/Translation(It|Ja|De|Fr|Es)$/)) { + variableName = variableName.replace( + /Translation(It|Ja|De|Fr|Es)$/, + `Translation${langCapitalized}`, + ); + } else { + variableName = lang; + } + refInfo = { + refImportName: otherRefInfo.refImportName, + refImportPath: otherRefInfo.refImportPath, + variableName, + }; + foundRefInfo = true; + break; + } + } + } + + if (!foundRefInfo) { + // If no good import found in other files, try existing file or infer + if (exists) { + const existingRefInfo = extractRefInfo(targetFile); + if (existingRefInfo) { + refInfo = existingRefInfo; + foundRefInfo = true; + } + } + + if (!foundRefInfo) { + // Try any other language file (even with ./ref or ./translations) + const anyOtherFile = otherLangFiles.find(f => fs.existsSync(f)); + if (anyOtherFile) { + const otherRefInfo = extractRefInfo(anyOtherFile); + if (otherRefInfo) { + // Verify and fix import path if needed + if (otherRefInfo.refImportPath.startsWith('./')) { + const expectedFile = path.join( + translationDir, + `${otherRefInfo.refImportPath.replace('./', '')}.ts`, + ); + if (!fs.existsSync(expectedFile)) { + otherRefInfo.refImportPath = + findRefImportPath(translationDir); + } + } + + const langCapitalized = + lang.charAt(0).toUpperCase() + lang.slice(1); + let variableName = otherRefInfo.variableName; + if (variableName.match(/Translation(It|Ja|De|Fr|Es)$/)) { + variableName = variableName.replace( + /Translation(It|Ja|De|Fr|Es)$/, + `Translation${langCapitalized}`, + ); + } else { + variableName = lang; + } + refInfo = { + refImportName: otherRefInfo.refImportName, + refImportPath: otherRefInfo.refImportPath, + variableName, + }; + foundRefInfo = true; + } + } + } + + if (!foundRefInfo) { + // Last resort: infer from plugin name + refInfo = inferRefInfo(pluginName, lang, repoType, translationDir); + } + } + + // Generate file content + const content = generateTranslationFile( + pluginName, + lang, + translations, + refInfo.refImportName, + refInfo.refImportPath, + refInfo.variableName, + ); + + // Write file + fs.writeFileSync(targetFile, content, 'utf-8'); + + const relativePath = path.relative(repoRoot, targetFile); + if (exists) { + console.log( + chalk.green( + ` โœ… Updated: ${relativePath} (${ + Object.keys(translations).length + } keys)`, + ), + ); + totalUpdated++; + } else { + console.log( + chalk.green( + ` โœจ Created: ${relativePath} (${ + Object.keys(translations).length + } keys)`, + ), + ); + totalCreated++; + } + } + } + + console.log(chalk.blue(`\n\n๐Ÿ“Š Summary:`)); + console.log(chalk.green(` โœ… Updated: ${totalUpdated} files`)); + console.log(chalk.green(` โœจ Created: ${totalCreated} files`)); + console.log(chalk.blue(`\n๐ŸŽ‰ Deployment complete!`)); +} + +// Main execution +const downloadDir = process.argv[2] || 'workspaces/i18n/downloads'; +const repoRoot = process.cwd(); + +deployTranslations(downloadDir, repoRoot).catch(error => { + console.error(chalk.red('โŒ Error:'), error); + process.exit(1); +}); diff --git a/workspaces/translations/packages/cli/src/commands/deploy.ts b/workspaces/translations/packages/cli/src/commands/deploy.ts index 81e462edb4..7ee82d90d3 100644 --- a/workspaces/translations/packages/cli/src/commands/deploy.ts +++ b/workspaces/translations/packages/cli/src/commands/deploy.ts @@ -15,15 +15,18 @@ */ import path from 'path'; +import { fileURLToPath } from 'url'; import { OptionValues } from 'commander'; import chalk from 'chalk'; import fs from 'fs-extra'; +import { execSync } from 'child_process'; -import { loadTranslationFile } from '../lib/i18n/loadFile'; -import { validateTranslationData } from '../lib/i18n/validateData'; -import { deployTranslationFiles } from '../lib/i18n/deployFiles'; -import { loadI18nConfig, mergeConfigWithOptions } from '../lib/i18n/config'; +// Get __dirname equivalent in ES modules +// eslint-disable-next-line no-restricted-syntax +const __filename = fileURLToPath(import.meta.url); +// eslint-disable-next-line no-restricted-syntax +const __dirname = path.dirname(__filename); interface DeployResult { language: string; @@ -171,6 +174,60 @@ function displaySummary( } } +/** + * Deploy translations using the TypeScript deployment script + */ +async function deployWithTypeScriptScript( + sourceDir: string, + repoRoot: string, +): Promise { + // Find the deployment script + // Try multiple possible locations + const possibleScriptPaths = [ + // From built location (dist/commands -> dist -> scripts) + // eslint-disable-next-line no-restricted-syntax + path.resolve(__dirname, '../../scripts/deploy-translations.ts'), + // From source location (src/commands -> src -> scripts) + // eslint-disable-next-line no-restricted-syntax + path.resolve(__dirname, '../../../scripts/deploy-translations.ts'), + // From repo root + path.resolve( + repoRoot, + 'workspaces/translations/packages/cli/scripts/deploy-translations.ts', + ), + ]; + + let scriptPath: string | null = null; + for (const possiblePath of possibleScriptPaths) { + if (await fs.pathExists(possiblePath)) { + scriptPath = possiblePath; + break; + } + } + + if (!scriptPath) { + throw new Error( + `Deployment script not found. Tried: ${possibleScriptPaths.join(', ')}`, + ); + } + + // Use tsx to run the TypeScript script + try { + execSync('which tsx', { stdio: 'pipe' }); + } catch { + throw new Error( + 'tsx not found. Please install it: npm install -g tsx, or yarn add -D tsx', + ); + } + + // Run the script with tsx + execSync(`tsx ${scriptPath} ${sourceDir}`, { + stdio: 'inherit', + cwd: repoRoot, + env: { ...process.env }, + }); +} + export async function deployCommand(opts: OptionValues): Promise { console.log( chalk.blue( @@ -178,83 +235,47 @@ export async function deployCommand(opts: OptionValues): Promise { ), ); - const config = await loadI18nConfig(); - const mergedOpts = await mergeConfigWithOptions(config, opts); - - const { - sourceDir = 'i18n', - targetDir = 'src/locales', - languages, - format = 'json', - backup = true, - validate = true, - } = mergedOpts as { + const { sourceDir = 'i18n/downloads' } = opts as { sourceDir?: string; - targetDir?: string; - languages?: string; - format?: string; - backup?: boolean; - validate?: boolean; }; try { - const sourceDirStr = String(sourceDir || 'i18n'); - const targetDirStr = String(targetDir || 'src/locales'); - const formatStr = String(format || 'json'); - const languagesStr = - languages && typeof languages === 'string' ? languages : undefined; + const sourceDirStr = String(sourceDir || 'i18n/downloads'); + const repoRoot = process.cwd(); if (!(await fs.pathExists(sourceDirStr))) { throw new Error(`Source directory not found: ${sourceDirStr}`); } - await fs.ensureDir(targetDirStr); - - const filesToProcess = await findTranslationFiles( - sourceDirStr, - formatStr, - languagesStr, - ); + // Check if there are any JSON files in the source directory + const files = await fs.readdir(sourceDirStr); + const jsonFiles = files.filter(f => f.endsWith('.json')); - if (filesToProcess.length === 0) { + if (jsonFiles.length === 0) { console.log( - chalk.yellow(`โš ๏ธ No translation files found in ${sourceDirStr}`), + chalk.yellow(`โš ๏ธ No translation JSON files found in ${sourceDirStr}`), ); + console.log( + chalk.gray( + ' Make sure you have downloaded translations first using:', + ), + ); + console.log(chalk.gray(' translations-cli i18n download')); return; } console.log( chalk.yellow( - `๐Ÿ“ Found ${filesToProcess.length} translation files to deploy`, + `๐Ÿ“ Found ${jsonFiles.length} translation file(s) to deploy`, ), ); - if (backup) { - await createBackup(targetDirStr, formatStr); - } - - const deployResults: DeployResult[] = []; - - for (const fileName of filesToProcess) { - try { - const result = await processTranslationFile( - fileName, - sourceDirStr, - targetDirStr, - formatStr, - Boolean(validate), - ); - deployResults.push(result); - } catch (error) { - const language = fileName.replace(`.${formatStr}`, ''); - console.error(chalk.red(`โŒ Error processing ${language}:`), error); - throw error; - } - } + // Deploy using TypeScript script + await deployWithTypeScriptScript(sourceDirStr, repoRoot); - displaySummary(deployResults, targetDirStr, Boolean(backup)); - } catch (error) { - console.error(chalk.red('โŒ Error deploying translations:'), error); + console.log(chalk.green(`โœ… Deployment completed successfully!`)); + } catch (error: any) { + console.error(chalk.red('โŒ Error deploying translations:'), error.message); throw error; } } diff --git a/workspaces/translations/packages/cli/src/commands/download.ts b/workspaces/translations/packages/cli/src/commands/download.ts index 70ad019b6c..ade6cd67ed 100644 --- a/workspaces/translations/packages/cli/src/commands/download.ts +++ b/workspaces/translations/packages/cli/src/commands/download.ts @@ -19,194 +19,260 @@ import path from 'path'; import { OptionValues } from 'commander'; import chalk from 'chalk'; import fs from 'fs-extra'; +import { execSync } from 'child_process'; -import { TMSClient } from '../lib/i18n/tmsClient'; -import { saveTranslationFile } from '../lib/i18n/saveFile'; import { loadI18nConfig, mergeConfigWithOptions } from '../lib/i18n/config'; +/** + * Download translations using Memsource CLI + */ +async function downloadWithMemsourceCLI( + projectId: string, + outputDir: string, + jobIds?: string[], + languages?: string[], +): Promise> { + // Check if memsource CLI is available + try { + execSync('which memsource', { stdio: 'pipe' }); + } catch { + throw new Error( + 'memsource CLI not found. Please ensure memsource-cli is installed and ~/.memsourcerc is sourced.', + ); + } + + // Check if MEMSOURCE_TOKEN is available + if (!process.env.MEMSOURCE_TOKEN) { + throw new Error( + 'MEMSOURCE_TOKEN not found. Please source ~/.memsourcerc first: source ~/.memsourcerc', + ); + } + + // Ensure output directory exists + await fs.ensureDir(outputDir); + + const downloadResults: Array<{ + jobId: string; + filename: string; + lang: string; + }> = []; + + // If job IDs are provided, download those specific jobs + if (jobIds && jobIds.length > 0) { + console.log( + chalk.yellow(`๐Ÿ“ฅ Downloading ${jobIds.length} specific job(s)...`), + ); + + for (const jobId of jobIds) { + try { + const cmd = [ + 'memsource', + 'job', + 'download', + '--project-id', + projectId, + '--job-id', + jobId, + '--type', + 'target', + '--output-dir', + outputDir, + ]; + + execSync(cmd.join(' '), { + stdio: 'pipe', + env: { ...process.env }, + }); + + // Get job info to determine filename and language + const jobInfoCmd = [ + 'memsource', + 'job', + 'list', + '--project-id', + projectId, + '--format', + 'json', + ]; + const jobListOutput = execSync(jobInfoCmd.join(' '), { + encoding: 'utf-8', + env: { ...process.env }, + }); + const jobs = JSON.parse(jobListOutput); + const jobArray = Array.isArray(jobs) ? jobs : [jobs]; + const job = jobArray.find((j: any) => j.uid === jobId); + + if (job) { + downloadResults.push({ + jobId, + filename: job.filename, + lang: job.target_lang, + }); + console.log( + chalk.green( + `โœ… Downloaded job ${jobId}: ${job.filename} (${job.target_lang})`, + ), + ); + } + } catch (error: any) { + console.warn( + chalk.yellow( + `โš ๏ธ Warning: Could not download job ${jobId}: ${error.message}`, + ), + ); + } + } + } else { + // List all completed jobs and download them + console.log(chalk.yellow('๐Ÿ“‹ Listing available jobs...')); + + try { + const listCmd = [ + 'memsource', + 'job', + 'list', + '--project-id', + projectId, + '--format', + 'json', + ]; + const listOutput = execSync(listCmd.join(' '), { + encoding: 'utf-8', + env: { ...process.env }, + }); + const jobs = JSON.parse(listOutput); + const jobArray = Array.isArray(jobs) ? jobs : [jobs]; + + // Filter for completed jobs + const completedJobs = jobArray.filter( + (job: any) => job.status === 'COMPLETED', + ); + + // Filter by languages if specified + let jobsToDownload = completedJobs; + if (languages && languages.length > 0) { + jobsToDownload = completedJobs.filter((job: any) => + languages.includes(job.target_lang), + ); + } + + console.log( + chalk.yellow( + `๐Ÿ“ฅ Found ${jobsToDownload.length} completed job(s) to download...`, + ), + ); + + for (const job of jobsToDownload) { + try { + const cmd = [ + 'memsource', + 'job', + 'download', + '--project-id', + projectId, + '--job-id', + job.uid, + '--type', + 'target', + '--output-dir', + outputDir, + ]; + + execSync(cmd.join(' '), { + stdio: 'pipe', + env: { ...process.env }, + }); + + downloadResults.push({ + jobId: job.uid, + filename: job.filename, + lang: job.target_lang, + }); + console.log( + chalk.green(`โœ… Downloaded: ${job.filename} (${job.target_lang})`), + ); + } catch (error: any) { + console.warn( + chalk.yellow( + `โš ๏ธ Warning: Could not download job ${job.uid}: ${error.message}`, + ), + ); + } + } + } catch (error: any) { + throw new Error(`Failed to list jobs: ${error.message}`); + } + } + + return downloadResults; +} + export async function downloadCommand(opts: OptionValues): Promise { console.log(chalk.blue('๐Ÿ“ฅ Downloading translated strings from TMS...')); // Load config and merge with options const config = await loadI18nConfig(); - // mergeConfigWithOptions is async (may generate token), so we await it const mergedOpts = await mergeConfigWithOptions(config, opts); const { - tmsUrl, - tmsToken, projectId, - outputDir = 'i18n', + outputDir = 'i18n/downloads', languages, - format = 'json', - includeCompleted = true, - includeDraft = false, + jobIds, } = mergedOpts as { - tmsUrl?: string; - tmsToken?: string; projectId?: string; outputDir?: string; languages?: string; - format?: string; - includeCompleted?: boolean; - includeDraft?: boolean; + jobIds?: string; }; // Validate required options - if (!tmsUrl || !tmsToken || !projectId) { + if (!projectId) { console.error(chalk.red('โŒ Missing required TMS configuration:')); console.error(''); - - if (!tmsUrl) { - console.error(chalk.yellow(' โœ— TMS URL')); - console.error( - chalk.gray( - ' Set via: --tms-url or I18N_TMS_URL or .i18n.config.json', - ), - ); - } - if (!tmsToken) { - console.error(chalk.yellow(' โœ— TMS Token')); - console.error( - chalk.gray( - ' Primary: Source ~/.memsourcerc (sets MEMSOURCE_TOKEN)', - ), - ); - console.error( - chalk.gray( - ' Fallback: --tms-token or I18N_TMS_TOKEN or ~/.i18n.auth.json', - ), - ); - } - if (!projectId) { - console.error(chalk.yellow(' โœ— Project ID')); - console.error( - chalk.gray( - ' Set via: --project-id or I18N_TMS_PROJECT_ID or .i18n.config.json', - ), - ); - } - - console.error(''); - console.error(chalk.blue('๐Ÿ“‹ Quick Setup Guide:')); - console.error(chalk.gray(' 1. Run: translations-cli i18n init')); - console.error(chalk.gray(' This creates .i18n.config.json')); - console.error(''); - console.error( - chalk.gray(' 2. Edit .i18n.config.json in your project root:'), - ); - console.error( - chalk.gray( - ' - Add your TMS URL (e.g., "https://cloud.memsource.com/web")', - ), - ); - console.error(chalk.gray(' - Add your Project ID')); - console.error(''); - console.error( - chalk.gray(' 3. Set up Memsource authentication (recommended):'), - ); - console.error( - chalk.gray(' - Run: translations-cli i18n setup-memsource'), - ); - console.error( - chalk.gray( - ' - Or manually create ~/.memsourcerc following localization team instructions', - ), - ); - console.error(chalk.gray(' - Then source it: source ~/.memsourcerc')); - console.error(''); + console.error(chalk.yellow(' โœ— Project ID')); console.error( chalk.gray( - ' OR use ~/.i18n.auth.json as fallback (run init to create it)', + ' Set via: --project-id or I18N_TMS_PROJECT_ID or .i18n.config.json', ), ); console.error(''); + console.error(chalk.blue('๐Ÿ“‹ Quick Setup Guide:')); + console.error(chalk.gray(' 1. Run: translations-cli i18n init')); + console.error(chalk.gray(' 2. Edit .i18n.config.json to add Project ID')); console.error( - chalk.gray(' See docs/i18n-commands.md for detailed instructions.'), + chalk.gray(' 3. Source ~/.memsourcerc: source ~/.memsourcerc'), ); process.exit(1); } - try { - // Ensure output directory exists - await fs.ensureDir(outputDir); - - // Initialize TMS client - console.log(chalk.yellow(`๐Ÿ”— Connecting to TMS at ${tmsUrl}...`)); - const tmsClient = new TMSClient(tmsUrl, tmsToken); - - // Test connection - await tmsClient.testConnection(); - console.log(chalk.green(`โœ… Connected to TMS successfully`)); + // Check if MEMSOURCE_TOKEN is available + if (!process.env.MEMSOURCE_TOKEN) { + console.error(chalk.red('โŒ MEMSOURCE_TOKEN not found')); + console.error(chalk.yellow(' Please source ~/.memsourcerc first:')); + console.error(chalk.gray(' source ~/.memsourcerc')); + process.exit(1); + } - // Get project information - console.log(chalk.yellow(`๐Ÿ“‹ Getting project information...`)); - const projectInfo = await tmsClient.getProjectInfo(projectId); - console.log(chalk.gray(` Project: ${projectInfo.name}`)); - console.log( - chalk.gray(` Languages: ${projectInfo.languages.join(', ')}`), - ); + try { + // Parse job IDs if provided (comma-separated) + const jobIdArray = + jobIds && typeof jobIds === 'string' + ? jobIds.split(',').map((id: string) => id.trim()) + : undefined; - // Parse target languages - const targetLanguages = + // Parse languages if provided (comma-separated) + const languageArray = languages && typeof languages === 'string' ? languages.split(',').map((lang: string) => lang.trim()) - : projectInfo.languages; - - // Download translations for each language - const downloadResults = []; - - for (const language of targetLanguages) { - console.log( - chalk.yellow(`๐Ÿ“ฅ Downloading translations for ${language}...`), - ); - - try { - const translationData = await tmsClient.downloadTranslations( - projectId, - language, - { - includeCompleted: Boolean(includeCompleted), - includeDraft: Boolean(includeDraft), - format: String(format || 'json'), - }, - ); - - if (translationData && Object.keys(translationData).length > 0) { - // Save translation file - const fileName = `${language}.${String(format || 'json')}`; - const filePath = path.join(String(outputDir || 'i18n'), fileName); - - await saveTranslationFile( - translationData, - filePath, - String(format || 'json'), - ); + : undefined; - downloadResults.push({ - language, - filePath, - keyCount: Object.keys(translationData).length, - }); - - console.log( - chalk.green( - `โœ… Downloaded ${language}: ${ - Object.keys(translationData).length - } keys`, - ), - ); - } else { - console.log( - chalk.yellow(`โš ๏ธ No translations found for ${language}`), - ); - } - } catch (error) { - console.warn( - chalk.yellow(`โš ๏ธ Warning: Could not download ${language}: ${error}`), - ); - } - } + const downloadResults = await downloadWithMemsourceCLI( + projectId, + String(outputDir), + jobIdArray, + languageArray, + ); // Summary console.log(chalk.green(`โœ… Download completed successfully!`)); @@ -218,13 +284,13 @@ export async function downloadCommand(opts: OptionValues): Promise { for (const result of downloadResults) { console.log( chalk.gray( - ` ${result.language}: ${result.filePath} (${result.keyCount} keys)`, + ` ${result.filename} (${result.lang}) - Job ID: ${result.jobId}`, ), ); } } - } catch (error) { - console.error(chalk.red('โŒ Error downloading from TMS:'), error); + } catch (error: any) { + console.error(chalk.red('โŒ Error downloading from TMS:'), error.message); throw error; } } diff --git a/workspaces/translations/packages/cli/src/commands/index.ts b/workspaces/translations/packages/cli/src/commands/index.ts index 1a78db8324..03ebc3dbb4 100644 --- a/workspaces/translations/packages/cli/src/commands/index.ts +++ b/workspaces/translations/packages/cli/src/commands/index.ts @@ -93,47 +93,34 @@ export function registerCommands(program: Command) { // Download command - download translated strings from TMS command .command('download') - .description('Download translated strings from TMS') - .option('--tms-url ', 'TMS API URL') - .option('--tms-token ', 'TMS API token') + .description('Download translated strings from TMS using Memsource CLI') .option('--project-id ', 'TMS project ID') .option( '--output-dir ', 'Output directory for downloaded translations', - 'i18n', + 'i18n/downloads', ) .option( '--languages ', - 'Comma-separated list of languages to download', + 'Comma-separated list of languages to download (e.g., "it,ja,fr")', + ) + .option( + '--job-ids ', + 'Comma-separated list of specific job IDs to download (e.g., "13,14,16")', ) - .option('--format ', 'Download format (json, po)', 'json') - .option('--include-completed', 'Include completed translations only', true) - .option('--include-draft', 'Include draft translations', false) .action(wrapCommand(downloadCommand)); // Deploy command - deploy translated strings back to language files command .command('deploy') .description( - 'Deploy translated strings back to the application language files', + 'Deploy downloaded translations to TypeScript translation files (it.ts, ja.ts, etc.)', ) .option( '--source-dir ', - 'Source directory containing downloaded translations', - 'i18n', - ) - .option( - '--target-dir ', - 'Target directory for language files', - 'src/locales', - ) - .option( - '--languages ', - 'Comma-separated list of languages to deploy', + 'Source directory containing downloaded translations (from Memsource)', + 'i18n/downloads', ) - .option('--format ', 'Input format (json, po)', 'json') - .option('--backup', 'Create backup of existing language files', true) - .option('--validate', 'Validate translations before deploying', true) .action(wrapCommand(deployCommand)); // Status command - show translation status diff --git a/workspaces/translations/yarn.lock b/workspaces/translations/yarn.lock index 6a0e800aa5..f7dc2591b1 100644 --- a/workspaces/translations/yarn.lock +++ b/workspaces/translations/yarn.lock @@ -31711,13 +31711,6 @@ __metadata: languageName: node linkType: hard -"std-env@npm:^3.5.0": - version: 3.10.0 - resolution: "std-env@npm:3.10.0" - checksum: 51d641b36b0fae494a546fb8446d39a837957fbf902c765c62bd12af8e50682d141c4087ca032f1192fa90330c4f6ff23fd6c9795324efacd1684e814471e0e0 - languageName: node - linkType: hard - "statuses@npm:~2.0.1, statuses@npm:~2.0.2": version: 2.0.2 resolution: "statuses@npm:2.0.2" @@ -31725,6 +31718,13 @@ __metadata: languageName: node linkType: hard +"std-env@npm:^3.5.0": + version: 3.10.0 + resolution: "std-env@npm:3.10.0" + checksum: 51d641b36b0fae494a546fb8446d39a837957fbf902c765c62bd12af8e50682d141c4087ca032f1192fa90330c4f6ff23fd6c9795324efacd1684e814471e0e0 + languageName: node + linkType: hard + "stop-iteration-iterator@npm:^1.0.0, stop-iteration-iterator@npm:^1.1.0": version: 1.1.0 resolution: "stop-iteration-iterator@npm:1.1.0" From e51c5c284ec1e723fbd9edf505e11960afa291e6 Mon Sep 17 00:00:00 2001 From: Yi Cai Date: Fri, 12 Dec 2025 15:40:18 -0500 Subject: [PATCH 4/5] fix(translations-cli): resolve SonarQube security and code quality issues - Replace execSync with safe spawnSync to prevent command injection - Create safeExecSync, commandExists, and safeExecSyncOrThrow utilities - Use separate command and args arrays for safer execution - Cross-platform support (Windows/Unix) for command existence checks - Applied to upload, download, deploy, and config commands - Remove code duplication to improve maintainability - Extract key counting logic to countTranslationKeys() utility - Extract download command args to buildDownloadJobArgs() helper - Extract job listing args to buildListJobsArgs() helper - Extract job download logic to downloadJob() helper function - Fix regex operator precedence in PO file parsing - Group alternation explicitly: /(^["']|["']$)/g - Makes operator precedence clear for SonarQube compliance - Applied to 3 locations in loadFile.ts All changes are refactoring only - no functional changes to workflow. Build and lint checks pass successfully. --- .../packages/cli/src/commands/deploy.ts | 10 +- .../packages/cli/src/commands/download.ts | 186 ++++++++---------- .../packages/cli/src/commands/upload.ts | 61 ++---- .../packages/cli/src/lib/i18n/config.ts | 25 ++- .../packages/cli/src/lib/i18n/loadFile.ts | 6 +- .../packages/cli/src/lib/utils/exec.ts | 96 +++++++++ .../cli/src/lib/utils/translationUtils.ts | 50 +++++ 7 files changed, 273 insertions(+), 161 deletions(-) create mode 100644 workspaces/translations/packages/cli/src/lib/utils/exec.ts create mode 100644 workspaces/translations/packages/cli/src/lib/utils/translationUtils.ts diff --git a/workspaces/translations/packages/cli/src/commands/deploy.ts b/workspaces/translations/packages/cli/src/commands/deploy.ts index 7ee82d90d3..537b6f4745 100644 --- a/workspaces/translations/packages/cli/src/commands/deploy.ts +++ b/workspaces/translations/packages/cli/src/commands/deploy.ts @@ -20,7 +20,8 @@ import { fileURLToPath } from 'url'; import { OptionValues } from 'commander'; import chalk from 'chalk'; import fs from 'fs-extra'; -import { execSync } from 'child_process'; + +import { commandExists, safeExecSyncOrThrow } from '../lib/utils/exec'; // Get __dirname equivalent in ES modules // eslint-disable-next-line no-restricted-syntax @@ -212,16 +213,15 @@ async function deployWithTypeScriptScript( } // Use tsx to run the TypeScript script - try { - execSync('which tsx', { stdio: 'pipe' }); - } catch { + if (!commandExists('tsx')) { throw new Error( 'tsx not found. Please install it: npm install -g tsx, or yarn add -D tsx', ); } // Run the script with tsx - execSync(`tsx ${scriptPath} ${sourceDir}`, { + // Note: scriptPath and sourceDir are validated paths, safe to use + safeExecSyncOrThrow('tsx', [scriptPath, sourceDir], { stdio: 'inherit', cwd: repoRoot, env: { ...process.env }, diff --git a/workspaces/translations/packages/cli/src/commands/download.ts b/workspaces/translations/packages/cli/src/commands/download.ts index ade6cd67ed..c263cf0124 100644 --- a/workspaces/translations/packages/cli/src/commands/download.ts +++ b/workspaces/translations/packages/cli/src/commands/download.ts @@ -19,9 +19,81 @@ import path from 'path'; import { OptionValues } from 'commander'; import chalk from 'chalk'; import fs from 'fs-extra'; -import { execSync } from 'child_process'; import { loadI18nConfig, mergeConfigWithOptions } from '../lib/i18n/config'; +import { commandExists, safeExecSyncOrThrow } from '../lib/utils/exec'; + +/** + * Build memsource job download command arguments + */ +function buildDownloadJobArgs( + projectId: string, + jobId: string, + outputDir: string, +): string[] { + return [ + 'job', + 'download', + '--project-id', + projectId, + '--job-id', + jobId, + '--type', + 'target', + '--output-dir', + outputDir, + ]; +} + +/** + * Build memsource job list command arguments + */ +function buildListJobsArgs(projectId: string): string[] { + return ['job', 'list', '--project-id', projectId, '--format', 'json']; +} + +/** + * Download a single job and return its info + */ +async function downloadJob( + projectId: string, + jobId: string, + outputDir: string, +): Promise<{ jobId: string; filename: string; lang: string } | null> { + try { + const cmdArgs = buildDownloadJobArgs(projectId, jobId, outputDir); + safeExecSyncOrThrow('memsource', cmdArgs, { + stdio: 'pipe', + env: { ...process.env }, + }); + + // Get job info to determine filename and language + const jobInfoArgs = buildListJobsArgs(projectId); + const jobListOutput = safeExecSyncOrThrow('memsource', jobInfoArgs, { + encoding: 'utf-8', + env: { ...process.env }, + }); + const jobs = JSON.parse(jobListOutput); + const jobArray = Array.isArray(jobs) ? jobs : [jobs]; + const job = jobArray.find((j: any) => j.uid === jobId); + + if (job) { + return { + jobId, + filename: job.filename, + lang: job.target_lang, + }; + } + return null; + } catch (error: any) { + console.warn( + chalk.yellow( + `โš ๏ธ Warning: Could not download job ${jobId}: ${error.message}`, + ), + ); + return null; + } +} /** * Download translations using Memsource CLI @@ -33,9 +105,7 @@ async function downloadWithMemsourceCLI( languages?: string[], ): Promise> { // Check if memsource CLI is available - try { - execSync('which memsource', { stdio: 'pipe' }); - } catch { + if (!commandExists('memsource')) { throw new Error( 'memsource CLI not found. Please ensure memsource-cli is installed and ~/.memsourcerc is sourced.', ); @@ -64,60 +134,12 @@ async function downloadWithMemsourceCLI( ); for (const jobId of jobIds) { - try { - const cmd = [ - 'memsource', - 'job', - 'download', - '--project-id', - projectId, - '--job-id', - jobId, - '--type', - 'target', - '--output-dir', - outputDir, - ]; - - execSync(cmd.join(' '), { - stdio: 'pipe', - env: { ...process.env }, - }); - - // Get job info to determine filename and language - const jobInfoCmd = [ - 'memsource', - 'job', - 'list', - '--project-id', - projectId, - '--format', - 'json', - ]; - const jobListOutput = execSync(jobInfoCmd.join(' '), { - encoding: 'utf-8', - env: { ...process.env }, - }); - const jobs = JSON.parse(jobListOutput); - const jobArray = Array.isArray(jobs) ? jobs : [jobs]; - const job = jobArray.find((j: any) => j.uid === jobId); - - if (job) { - downloadResults.push({ - jobId, - filename: job.filename, - lang: job.target_lang, - }); - console.log( - chalk.green( - `โœ… Downloaded job ${jobId}: ${job.filename} (${job.target_lang})`, - ), - ); - } - } catch (error: any) { - console.warn( - chalk.yellow( - `โš ๏ธ Warning: Could not download job ${jobId}: ${error.message}`, + const result = await downloadJob(projectId, jobId, outputDir); + if (result) { + downloadResults.push(result); + console.log( + chalk.green( + `โœ… Downloaded job ${result.jobId}: ${result.filename} (${result.lang})`, ), ); } @@ -127,16 +149,8 @@ async function downloadWithMemsourceCLI( console.log(chalk.yellow('๐Ÿ“‹ Listing available jobs...')); try { - const listCmd = [ - 'memsource', - 'job', - 'list', - '--project-id', - projectId, - '--format', - 'json', - ]; - const listOutput = execSync(listCmd.join(' '), { + const listArgs = buildListJobsArgs(projectId); + const listOutput = safeExecSyncOrThrow('memsource', listArgs, { encoding: 'utf-8', env: { ...process.env }, }); @@ -163,39 +177,11 @@ async function downloadWithMemsourceCLI( ); for (const job of jobsToDownload) { - try { - const cmd = [ - 'memsource', - 'job', - 'download', - '--project-id', - projectId, - '--job-id', - job.uid, - '--type', - 'target', - '--output-dir', - outputDir, - ]; - - execSync(cmd.join(' '), { - stdio: 'pipe', - env: { ...process.env }, - }); - - downloadResults.push({ - jobId: job.uid, - filename: job.filename, - lang: job.target_lang, - }); + const result = await downloadJob(projectId, job.uid, outputDir); + if (result) { + downloadResults.push(result); console.log( - chalk.green(`โœ… Downloaded: ${job.filename} (${job.target_lang})`), - ); - } catch (error: any) { - console.warn( - chalk.yellow( - `โš ๏ธ Warning: Could not download job ${job.uid}: ${error.message}`, - ), + chalk.green(`โœ… Downloaded: ${result.filename} (${result.lang})`), ); } } diff --git a/workspaces/translations/packages/cli/src/commands/upload.ts b/workspaces/translations/packages/cli/src/commands/upload.ts index 431c269727..9e303dcbd7 100644 --- a/workspaces/translations/packages/cli/src/commands/upload.ts +++ b/workspaces/translations/packages/cli/src/commands/upload.ts @@ -15,7 +15,6 @@ */ import path from 'path'; -import { execSync } from 'child_process'; import fs from 'fs-extra'; import { OptionValues } from 'commander'; @@ -28,6 +27,8 @@ import { saveUploadCache, getCachedUpload, } from '../lib/i18n/uploadCache'; +import { commandExists, safeExecSyncOrThrow } from '../lib/utils/exec'; +import { countTranslationKeys } from '../lib/utils/translationUtils'; /** * Detect repository name from git or directory @@ -35,10 +36,11 @@ import { function detectRepoName(): string { try { // Try to get repo name from git - const gitRepoUrl = execSync('git config --get remote.origin.url', { - encoding: 'utf-8', - stdio: 'pipe', - }).trim(); + const gitRepoUrl = safeExecSyncOrThrow('git', [ + 'config', + '--get', + 'remote.origin.url', + ]); if (gitRepoUrl) { // Extract repo name from URL (handles both https and ssh formats) const match = gitRepoUrl.match(/([^/]+?)(?:\.git)?$/); @@ -103,9 +105,7 @@ async function uploadWithMemsourceCLI( } // Check if memsource CLI is available - try { - execSync('which memsource', { stdio: 'pipe' }); - } catch { + if (!commandExists('memsource')) { throw new Error( 'memsource CLI not found. Please ensure memsource-cli is installed and ~/.memsourcerc is sourced.', ); @@ -132,7 +132,7 @@ async function uploadWithMemsourceCLI( // Execute memsource command // Note: MEMSOURCE_TOKEN should be set from ~/.memsourcerc try { - const output = execSync(`memsource ${args.join(' ')}`, { + const output = safeExecSyncOrThrow('memsource', args, { encoding: 'utf-8', stdio: 'pipe', // Capture both stdout and stderr env: { @@ -152,25 +152,7 @@ async function uploadWithMemsourceCLI( let keyCount = 0; try { const data = JSON.parse(fileContent); - // Handle nested structure: { "plugin": { "en": { "key": "value" } } } - if (data && typeof data === 'object') { - const isNested = Object.values(data).some( - (val: unknown) => - typeof val === 'object' && val !== null && 'en' in val, - ); - if (isNested) { - for (const pluginData of Object.values(data)) { - const enData = (pluginData as { en?: Record })?.en; - if (enData && typeof enData === 'object') { - keyCount += Object.keys(enData).length; - } - } - } else { - // Flat structure - const translations = data.translations || data; - keyCount = Object.keys(translations).length; - } - } + keyCount = countTranslationKeys(data); } catch { // If parsing fails, use a default keyCount = 0; @@ -183,7 +165,7 @@ async function uploadWithMemsourceCLI( return result; } catch (error: unknown) { - // Extract error message from execSync error + // Extract error message from command execution error let errorMessage = 'Unknown error'; if (error instanceof Error) { errorMessage = error.message; @@ -524,27 +506,10 @@ export async function uploadCommand(opts: OptionValues): Promise { // Fallback: count keys from file try { const data = JSON.parse(fileContent); - if (data && typeof data === 'object') { - const isNested = Object.values(data).some( - (val: unknown) => - typeof val === 'object' && val !== null && 'en' in val, - ); - if (isNested) { - keyCount = 0; - for (const pluginData of Object.values(data)) { - const enData = (pluginData as { en?: Record }) - ?.en; - if (enData && typeof enData === 'object') { - keyCount += Object.keys(enData).length; - } - } - } else { - const translations = data.translations || data; - keyCount = Object.keys(translations).length; - } - } + keyCount = countTranslationKeys(data); } catch { // If parsing fails, use 0 + keyCount = 0; } } diff --git a/workspaces/translations/packages/cli/src/lib/i18n/config.ts b/workspaces/translations/packages/cli/src/lib/i18n/config.ts index 50de47ea61..a7535122cc 100644 --- a/workspaces/translations/packages/cli/src/lib/i18n/config.ts +++ b/workspaces/translations/packages/cli/src/lib/i18n/config.ts @@ -16,7 +16,7 @@ import path from 'path'; import os from 'os'; -import { execSync } from 'child_process'; +import { commandExists, safeExecSyncOrThrow } from '../utils/exec'; import fs from 'fs-extra'; @@ -351,12 +351,27 @@ async function generateMemsourceToken( password: string, ): Promise { try { - // Check if memsource CLI is available by trying to run it - execSync('which memsource', { stdio: 'pipe' }); + // Check if memsource CLI is available + if (!commandExists('memsource')) { + return undefined; + } // Generate token using memsource CLI - const token = execSync( - `memsource auth login --user-name ${username} --password "${password}" -c token -f value`, + // Note: Password is passed as argument, but it's from user input during setup + const token = safeExecSyncOrThrow( + 'memsource', + [ + 'auth', + 'login', + '--user-name', + username, + '--password', + password, + '-c', + 'token', + '-f', + 'value', + ], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], diff --git a/workspaces/translations/packages/cli/src/lib/i18n/loadFile.ts b/workspaces/translations/packages/cli/src/lib/i18n/loadFile.ts index 32f67778de..4c90126ef4 100644 --- a/workspaces/translations/packages/cli/src/lib/i18n/loadFile.ts +++ b/workspaces/translations/packages/cli/src/lib/i18n/loadFile.ts @@ -94,19 +94,19 @@ async function loadPoFile(filePath: string): Promise { } currentKey = unescapePoString( - trimmed.substring(6).replace(/^["']|["']$/g, ''), + trimmed.substring(6).replace(/(^["']|["']$)/g, ''), ); currentValue = ''; inMsgId = true; inMsgStr = false; } else if (trimmed.startsWith('msgstr ')) { currentValue = unescapePoString( - trimmed.substring(7).replace(/^["']|["']$/g, ''), + trimmed.substring(7).replace(/(^["']|["']$)/g, ''), ); inMsgId = false; inMsgStr = true; } else if (trimmed.startsWith('"') && (inMsgId || inMsgStr)) { - const value = unescapePoString(trimmed.replace(/^["']|["']$/g, '')); + const value = unescapePoString(trimmed.replace(/(^["']|["']$)/g, '')); if (inMsgId) { currentKey += value; } else if (inMsgStr) { diff --git a/workspaces/translations/packages/cli/src/lib/utils/exec.ts b/workspaces/translations/packages/cli/src/lib/utils/exec.ts new file mode 100644 index 0000000000..912f9e49b1 --- /dev/null +++ b/workspaces/translations/packages/cli/src/lib/utils/exec.ts @@ -0,0 +1,96 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { spawnSync, SpawnSyncOptions } from 'child_process'; +import { platform } from 'os'; + +/** + * Safely execute a command with arguments. + * Uses spawnSync with separate command and args to prevent command injection. + * + * @param command - The command to execute (e.g., 'memsource', 'tsx', 'git') + * @param args - Array of arguments (e.g., ['job', 'create', '--project-id', '123']) + * @param options - Optional spawn options + * @returns Object with stdout, stderr, and status + */ +export function safeExecSync( + command: string, + args: string[] = [], + options: SpawnSyncOptions = {}, +): { + stdout: string; + stderr: string; + status: number | null; + error?: Error; +} { + const defaultOptions: SpawnSyncOptions = { + encoding: 'utf-8', + stdio: 'pipe', + ...options, + }; + + const result = spawnSync(command, args, defaultOptions); + + return { + stdout: (result.stdout?.toString() || '').trim(), + stderr: (result.stderr?.toString() || '').trim(), + status: result.status, + error: result.error, + }; +} + +/** + * Check if a command exists in PATH (cross-platform). + * Uses 'where' on Windows, 'which' on Unix-like systems. + * + * @param command - The command to check (e.g., 'memsource', 'tsx') + * @returns true if command exists, false otherwise + */ +export function commandExists(command: string): boolean { + const isWindows = platform() === 'win32'; + const checkCommand = isWindows ? 'where' : 'which'; + const result = safeExecSync(checkCommand, [command], { stdio: 'pipe' }); + return result.status === 0 && result.stdout.length > 0; +} + +/** + * Execute a command and throw an error if it fails. + * + * @param command - The command to execute + * @param args - Array of arguments + * @param options - Optional spawn options + * @returns The stdout output + * @throws Error if command fails + */ +export function safeExecSyncOrThrow( + command: string, + args: string[] = [], + options: SpawnSyncOptions = {}, +): string { + const result = safeExecSync(command, args, options); + + if (result.error) { + throw result.error; + } + + if (result.status !== 0) { + const errorMessage = + result.stderr || `Command failed with status ${result.status}`; + throw new Error(errorMessage); + } + + return result.stdout; +} diff --git a/workspaces/translations/packages/cli/src/lib/utils/translationUtils.ts b/workspaces/translations/packages/cli/src/lib/utils/translationUtils.ts new file mode 100644 index 0000000000..73b2ffdd58 --- /dev/null +++ b/workspaces/translations/packages/cli/src/lib/utils/translationUtils.ts @@ -0,0 +1,50 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Count translation keys from a JSON translation file. + * Handles both nested structure ({ "plugin": { "en": { "key": "value" } } }) + * and flat structure ({ "translations": { "key": "value" } } or { "key": "value" }). + * + * @param data - Parsed JSON data from translation file + * @returns Number of translation keys + */ +export function countTranslationKeys(data: unknown): number { + if (!data || typeof data !== 'object') { + return 0; + } + + // Check if it's a nested structure: { "plugin": { "en": { "key": "value" } } } + const isNested = Object.values(data).some( + (val: unknown) => typeof val === 'object' && val !== null && 'en' in val, + ); + + if (isNested) { + let keyCount = 0; + for (const pluginData of Object.values(data)) { + const enData = (pluginData as { en?: Record })?.en; + if (enData && typeof enData === 'object') { + keyCount += Object.keys(enData).length; + } + } + return keyCount; + } + + // Flat structure: { "translations": { "key": "value" } } or { "key": "value" } + const translations = + (data as { translations?: Record }).translations || data; + return Object.keys(translations).length; +} From 59a7a6dafe707085c2944795753c66a104750804 Mon Sep 17 00:00:00 2001 From: Yi Cai Date: Tue, 23 Dec 2025 18:21:44 -0500 Subject: [PATCH 5/5] fix SonarQube issue p1 Signed-off-by: Yi Cai --- .../packages/cli/bin/translations-cli | 2 +- .../cli/scripts/deploy-translations.ts | 674 ++++++++------ .../packages/cli/src/commands/clean.ts | 75 +- .../packages/cli/src/commands/deploy.ts | 148 +-- .../packages/cli/src/commands/download.ts | 163 ++-- .../packages/cli/src/commands/generate.ts | 626 +++++++------ .../packages/cli/src/commands/init.ts | 4 +- .../cli/src/commands/setupMemsource.ts | 415 +++++---- .../packages/cli/src/commands/sync.ts | 145 +-- .../packages/cli/src/commands/upload.ts | 847 +++++++++++------- .../packages/cli/src/lib/errors.ts | 3 +- .../cli/src/lib/i18n/analyzeStatus.ts | 2 +- .../packages/cli/src/lib/i18n/config.ts | 148 ++- .../packages/cli/src/lib/i18n/deployFiles.ts | 18 +- .../packages/cli/src/lib/i18n/extractKeys.ts | 520 ++++++----- .../packages/cli/src/lib/i18n/formatReport.ts | 175 ++-- .../cli/src/lib/i18n/generateFiles.ts | 26 +- .../packages/cli/src/lib/i18n/loadFile.ts | 20 +- .../packages/cli/src/lib/i18n/mergeFiles.ts | 32 +- .../packages/cli/src/lib/i18n/saveFile.ts | 18 +- .../packages/cli/src/lib/i18n/tmsClient.ts | 12 +- .../packages/cli/src/lib/i18n/uploadCache.ts | 6 +- .../packages/cli/src/lib/i18n/validateData.ts | 8 +- .../packages/cli/src/lib/i18n/validateFile.ts | 303 ++++--- .../packages/cli/src/lib/paths.ts | 11 +- .../packages/cli/src/lib/utils/exec.ts | 2 +- .../packages/cli/src/lib/version.ts | 2 +- .../packages/cli/test/generate.test.ts | 2 +- .../packages/cli/test/test-helpers.ts | 30 +- .../src/translations/index.ts | 1 + .../translations-test/src/translations/it.ts | 60 ++ .../translations-test/src/translations/ja.ts | 59 ++ .../translations/src/translations/index.ts | 1 + .../translations/src/translations/it.ts | 12 +- .../translations/src/translations/ja.ts | 45 + 35 files changed, 2652 insertions(+), 1963 deletions(-) create mode 100644 workspaces/translations/plugins/translations-test/src/translations/it.ts create mode 100644 workspaces/translations/plugins/translations-test/src/translations/ja.ts create mode 100644 workspaces/translations/plugins/translations/src/translations/ja.ts diff --git a/workspaces/translations/packages/cli/bin/translations-cli b/workspaces/translations/packages/cli/bin/translations-cli index f59627785d..e1d3d47191 100755 --- a/workspaces/translations/packages/cli/bin/translations-cli +++ b/workspaces/translations/packages/cli/bin/translations-cli @@ -15,7 +15,7 @@ * limitations under the License. */ -const path = require('path'); +const path = require('node:path'); // Figure out whether we're running inside the backstage repo or as an installed dependency /* eslint-disable-next-line no-restricted-syntax */ diff --git a/workspaces/translations/packages/cli/scripts/deploy-translations.ts b/workspaces/translations/packages/cli/scripts/deploy-translations.ts index d92b683b24..7c55b2b83b 100644 --- a/workspaces/translations/packages/cli/scripts/deploy-translations.ts +++ b/workspaces/translations/packages/cli/scripts/deploy-translations.ts @@ -16,7 +16,7 @@ */ import fs from 'fs-extra'; -import path from 'path'; +import path from 'node:path'; import chalk from 'chalk'; interface TranslationData { @@ -63,7 +63,7 @@ function extractRefInfo(filePath: string): { // Extract ref import: import { xxxTranslationRef } from './ref' or from '@backstage/...' // Match both local and external imports const refImportMatch = content.match( - /import\s*{\s*([a-zA-Z0-9_]+TranslationRef)\s*}\s*from\s*['"]([^'"]+)['"]/, + /import\s*{\s*(\w+TranslationRef)\s*}\s*from\s*['"]([^'"]+)['"]/, ); if (!refImportMatch) { return null; @@ -88,7 +88,7 @@ function extractRefInfo(filePath: string): { // Extract variable name: const xxxTranslationIt = ... or const de = ... // Try full pattern first let variableMatch = content.match( - /const\s+([a-zA-Z0-9_]+Translation(?:It|Ja|De|Fr|Es))\s*=/, + /const\s+(\w+Translation(?:It|Ja|De|Fr|Es))\s*=/, ); // If not found, try simple pattern (like const de = ...) @@ -199,74 +199,91 @@ function detectRepoType( } /** - * Find plugin translation directory (supports multiple repo structures) + * Find plugin translation directory in workspace-based repos (rhdh-plugins, community-plugins) */ -function findPluginTranslationDir( +function findPluginInWorkspaces( pluginName: string, repoRoot: string, ): string | null { - const repoType = detectRepoType(repoRoot); - - // Structure 1: workspaces/*/plugins/*/src/translations (rhdh-plugins, community-plugins) - if (repoType === 'rhdh-plugins' || repoType === 'community-plugins') { - const workspacesDir = path.join(repoRoot, 'workspaces'); - - if (fs.existsSync(workspacesDir)) { - const workspaceDirs = fs.readdirSync(workspacesDir); - - for (const workspace of workspaceDirs) { - const pluginsDir = path.join( - workspacesDir, - workspace, - 'plugins', - pluginName, - 'src', - 'translations', - ); - - if (fs.existsSync(pluginsDir)) { - return pluginsDir; - } - } - } + const workspacesDir = path.join(repoRoot, 'workspaces'); + if (!fs.existsSync(workspacesDir)) { + return null; } - // Structure 2: packages/app/src/translations/{plugin}/ (rhdh) - if (repoType === 'rhdh') { - // Try: packages/app/src/translations/{plugin}/ - const pluginDir = path.join( - repoRoot, - 'packages', - 'app', + const workspaceDirs = fs.readdirSync(workspacesDir); + for (const workspace of workspaceDirs) { + const pluginsDir = path.join( + workspacesDir, + workspace, + 'plugins', + pluginName, 'src', 'translations', - pluginName, ); - if (fs.existsSync(pluginDir)) { - return pluginDir; + if (fs.existsSync(pluginsDir)) { + return pluginsDir; } + } - // Try: packages/app/src/translations/ (flat structure with {plugin}-{lang}.ts files) - const translationsDir = path.join( - repoRoot, - 'packages', - 'app', - 'src', - 'translations', - ); + return null; +} - if (fs.existsSync(translationsDir)) { - // Check if there are files like {plugin}-{lang}.ts - const files = fs.readdirSync(translationsDir); - const hasPluginFiles = files.some( - f => f.startsWith(`${pluginName}-`) && f.endsWith('.ts'), - ); +/** + * Find plugin translation directory in rhdh repo structure + */ +function findPluginInRhdh(pluginName: string, repoRoot: string): string | null { + // Try: packages/app/src/translations/{plugin}/ + const pluginDir = path.join( + repoRoot, + 'packages', + 'app', + 'src', + 'translations', + pluginName, + ); - if (hasPluginFiles) { - return translationsDir; - } - } + if (fs.existsSync(pluginDir)) { + return pluginDir; + } + + // Try: packages/app/src/translations/ (flat structure with {plugin}-{lang}.ts files) + const translationsDir = path.join( + repoRoot, + 'packages', + 'app', + 'src', + 'translations', + ); + + if (!fs.existsSync(translationsDir)) { + return null; + } + + // Check if there are files like {plugin}-{lang}.ts + const files = fs.readdirSync(translationsDir); + const hasPluginFiles = files.some( + f => f.startsWith(`${pluginName}-`) && f.endsWith('.ts'), + ); + + return hasPluginFiles ? translationsDir : null; +} + +/** + * Find plugin translation directory (supports multiple repo structures) + */ +function findPluginTranslationDir( + pluginName: string, + repoRoot: string, +): string | null { + const repoType = detectRepoType(repoRoot); + + if (repoType === 'rhdh-plugins' || repoType === 'community-plugins') { + return findPluginInWorkspaces(pluginName, repoRoot); + } + + if (repoType === 'rhdh') { + return findPluginInRhdh(pluginName, repoRoot); } return null; @@ -296,9 +313,9 @@ function generateTranslationFile( .map(([key, value]) => { // Escape single quotes and backslashes in values const escapedValue = value - .replace(/\\/g, '\\\\') - .replace(/'/g, "\\'") - .replace(/\n/g, '\\n'); + .replaceAll(/\\/g, '\\\\') + .replaceAll(/'/g, "\\'") + .replaceAll(/\n/g, '\\n'); return ` '${key}': '${escapedValue}',`; }) .join('\n'); @@ -381,6 +398,297 @@ function detectDownloadedFiles( return files; } +/** + * Determine target file path for translation file + */ +function determineTargetFile( + pluginName: string, + lang: string, + repoType: string, + translationDir: string, +): string { + if (repoType === 'rhdh') { + const pluginLangFile = path.join( + translationDir, + `${pluginName}-${lang}.ts`, + ); + const langFile = path.join(translationDir, `${lang}.ts`); + + if (fs.existsSync(pluginLangFile)) { + return pluginLangFile; + } + if (fs.existsSync(langFile)) { + return langFile; + } + return langFile; // Default to {lang}.ts for new files + } + + return path.join(translationDir, `${lang}.ts`); +} + +/** + * Get list of other language files in the translation directory + */ +function getOtherLanguageFiles( + lang: string, + repoType: string, + pluginName: string, + translationDir: string, +): string[] { + const otherLangs = ['it', 'ja', 'de', 'fr', 'es', 'en'].filter( + l => l !== lang, + ); + + if (repoType === 'rhdh') { + return otherLangs.flatMap(l => { + const pluginLangFile = path.join(translationDir, `${pluginName}-${l}.ts`); + const langFile = path.join(translationDir, `${l}.ts`); + const files: string[] = []; + if (fs.existsSync(pluginLangFile)) files.push(pluginLangFile); + if (fs.existsSync(langFile)) files.push(langFile); + return files; + }); + } + + return otherLangs + .map(l => path.join(translationDir, `${l}.ts`)) + .filter(f => fs.existsSync(f)); +} + +/** + * Transform variable name for target language + */ +function transformVariableName(variableName: string, lang: string): string { + const langCapitalized = lang.charAt(0).toUpperCase() + lang.slice(1); + + if (variableName.match(/Translation(It|Ja|De|Fr|Es)$/)) { + return variableName.replaceAll( + /Translation(It|Ja|De|Fr|Es)$/g, + `Translation${langCapitalized}`, + ); + } + + return lang; +} + +/** + * Verify and fix import path if needed + */ +function verifyImportPath( + refInfo: { refImportPath: string }, + translationDir: string, +): void { + if (!refInfo.refImportPath.startsWith('./')) { + return; + } + + const expectedFile = path.join( + translationDir, + `${refInfo.refImportPath.replace('./', '')}.ts`, + ); + + if (!fs.existsSync(expectedFile)) { + refInfo.refImportPath = findRefImportPath(translationDir); + } +} + +/** + * Extract ref info from other language files + */ +function extractRefInfoFromOtherFiles( + otherLangFiles: string[], + repoType: string, + lang: string, + translationDir: string, +): { + refImportName: string; + refImportPath: string; + variableName: string; +} | null { + for (const otherFile of otherLangFiles) { + const otherRefInfo = extractRefInfo(otherFile); + if (!otherRefInfo) { + continue; + } + + verifyImportPath(otherRefInfo, translationDir); + + // Prioritize external package imports for rhdh + const isExternalImport = !otherRefInfo.refImportPath.startsWith('./'); + const shouldUseForRhdh = repoType === 'rhdh' && isExternalImport; + const shouldUseForOthers = repoType !== 'rhdh' || !isExternalImport; + + if (shouldUseForRhdh || shouldUseForOthers) { + return { + refImportName: otherRefInfo.refImportName, + refImportPath: otherRefInfo.refImportPath, + variableName: transformVariableName(otherRefInfo.variableName, lang), + }; + } + } + + return null; +} + +/** + * Get ref info for a plugin translation file + */ +function getRefInfoForPlugin( + pluginName: string, + lang: string, + repoType: string, + translationDir: string, + targetFile: string, + exists: boolean, +): { + refImportName: string; + refImportPath: string; + variableName: string; +} { + const otherLangFiles = getOtherLanguageFiles( + lang, + repoType, + pluginName, + translationDir, + ); + + // Try to extract from other language files first + const refInfoFromOthers = extractRefInfoFromOtherFiles( + otherLangFiles, + repoType, + lang, + translationDir, + ); + + if (refInfoFromOthers) { + return refInfoFromOthers; + } + + // Try existing file + if (exists) { + const existingRefInfo = extractRefInfo(targetFile); + if (existingRefInfo) { + return existingRefInfo; + } + } + + // Try any other language file as fallback + const anyOtherFile = otherLangFiles.find(f => fs.existsSync(f)); + if (anyOtherFile) { + const otherRefInfo = extractRefInfo(anyOtherFile); + if (otherRefInfo) { + verifyImportPath(otherRefInfo, translationDir); + return { + refImportName: otherRefInfo.refImportName, + refImportPath: otherRefInfo.refImportPath, + variableName: transformVariableName(otherRefInfo.variableName, lang), + }; + } + } + + // Last resort: infer from plugin name + return inferRefInfo(pluginName, lang, repoType, translationDir); +} + +/** + * Process translation for a single plugin + */ +function processPluginTranslation( + pluginName: string, + translations: { [key: string]: string }, + lang: string, + repoType: string, + repoRoot: string, +): { updated: boolean; created: boolean } | null { + const translationDir = findPluginTranslationDir(pluginName, repoRoot); + + if (!translationDir) { + console.warn( + chalk.yellow(` โš ๏ธ Plugin "${pluginName}" not found, skipping...`), + ); + return null; + } + + const targetFile = determineTargetFile( + pluginName, + lang, + repoType, + translationDir, + ); + const exists = fs.existsSync(targetFile); + + const refInfo = getRefInfoForPlugin( + pluginName, + lang, + repoType, + translationDir, + targetFile, + exists, + ); + + const content = generateTranslationFile( + pluginName, + lang, + translations, + refInfo.refImportName, + refInfo.refImportPath, + refInfo.variableName, + ); + + fs.writeFileSync(targetFile, content, 'utf-8'); + + const relativePath = path.relative(repoRoot, targetFile); + const keyCount = Object.keys(translations).length; + + if (exists) { + console.log( + chalk.green(` โœ… Updated: ${relativePath} (${keyCount} keys)`), + ); + return { updated: true, created: false }; + } + + console.log( + chalk.green(` โœจ Created: ${relativePath} (${keyCount} keys)`), + ); + return { updated: false, created: true }; +} + +/** + * Process all plugin translations for a language + */ +function processLanguageTranslations( + data: TranslationData, + lang: string, + repoType: string, + repoRoot: string, +): { updated: number; created: number } { + let updated = 0; + let created = 0; + + for (const [pluginName, pluginData] of Object.entries(data)) { + const translations = pluginData.en || {}; + + if (Object.keys(translations).length === 0) { + continue; + } + + const result = processPluginTranslation( + pluginName, + translations, + lang, + repoType, + repoRoot, + ); + + if (result) { + if (result.updated) updated++; + if (result.created) created++; + } + } + + return { updated, created }; +} + /** * Deploy translations from downloaded JSON files */ @@ -390,7 +698,6 @@ async function deployTranslations( ): Promise { console.log(chalk.blue('๐Ÿš€ Deploying translations...\n')); - // Detect repository type const repoType = detectRepoType(repoRoot); console.log(chalk.cyan(`๐Ÿ“ฆ Detected repository: ${repoType}\n`)); @@ -400,7 +707,6 @@ async function deployTranslations( ); } - // Auto-detect downloaded files for this repo const repoFiles = detectDownloadedFiles(downloadDir, repoType); if (Object.keys(repoFiles).length === 0) { @@ -442,237 +748,15 @@ async function deployTranslations( console.log(chalk.cyan(`\n ๐ŸŒ Language: ${lang.toUpperCase()}`)); - for (const [pluginName, pluginData] of Object.entries(data)) { - const translations = pluginData.en || {}; - - if (Object.keys(translations).length === 0) { - continue; - } - - // Find plugin translation directory - const translationDir = findPluginTranslationDir(pluginName, repoRoot); - - if (!translationDir) { - console.warn( - chalk.yellow(` โš ๏ธ Plugin "${pluginName}" not found, skipping...`), - ); - continue; - } - - // For rhdh repo, files might be named {plugin}-{lang}.ts instead of {lang}.ts - let targetFile: string; - if (repoType === 'rhdh') { - // Check if files use {plugin}-{lang}.ts format - const pluginLangFile = path.join( - translationDir, - `${pluginName}-${lang}.ts`, - ); - const langFile = path.join(translationDir, `${lang}.ts`); - - // Prefer existing file format, or default to {lang}.ts - if (fs.existsSync(pluginLangFile)) { - targetFile = pluginLangFile; - } else if (fs.existsSync(langFile)) { - targetFile = langFile; - } else { - // Default to {lang}.ts for new files - targetFile = langFile; - } - } else { - targetFile = path.join(translationDir, `${lang}.ts`); - } - - const exists = fs.existsSync(targetFile); - - // Get ref info from existing file or infer - // Strategy: Always check other existing files first to get correct import path, - // then fall back to existing file or inference - let refInfo: { - refImportName: string; - refImportPath: string; - variableName: string; - }; - - // First, try to get from another language file in same directory (prioritize these) - // For rhdh, check both naming conventions: {plugin}-{lang}.ts and {lang}.ts - const otherLangFiles = ['it', 'ja', 'de', 'fr', 'es', 'en'] - .filter(l => l !== lang) - .flatMap(l => { - if (repoType === 'rhdh') { - // Check both naming patterns - const pluginLangFile = path.join( - translationDir, - `${pluginName}-${l}.ts`, - ); - const langFile = path.join(translationDir, `${l}.ts`); - const files = []; - if (fs.existsSync(pluginLangFile)) files.push(pluginLangFile); - if (fs.existsSync(langFile)) files.push(langFile); - return files; - } - const langFile = path.join(translationDir, `${l}.ts`); - return fs.existsSync(langFile) ? [langFile] : []; - }); - - // Try to extract from other language files first (they likely have correct imports) - let foundRefInfo = false; - for (const otherFile of otherLangFiles) { - const otherRefInfo = extractRefInfo(otherFile); - if (otherRefInfo) { - // Verify the import path is valid (file exists) - if (otherRefInfo.refImportPath.startsWith('./')) { - const expectedFile = path.join( - translationDir, - `${otherRefInfo.refImportPath.replace('./', '')}.ts`, - ); - if (!fs.existsSync(expectedFile)) { - // Import path is invalid, try to find correct one - otherRefInfo.refImportPath = findRefImportPath(translationDir); - } - } - - // Use this ref info (prioritize external package imports for rhdh) - if ( - repoType === 'rhdh' && - !otherRefInfo.refImportPath.startsWith('./') - ) { - // Found a file with external package import - use it - const langCapitalized = - lang.charAt(0).toUpperCase() + lang.slice(1); - // For variable name, try to match pattern or use simple lang code - let variableName = otherRefInfo.variableName; - if (variableName.match(/Translation(It|Ja|De|Fr|Es)$/)) { - variableName = variableName.replace( - /Translation(It|Ja|De|Fr|Es)$/, - `Translation${langCapitalized}`, - ); - } else { - // Simple pattern like "const de = ..." - variableName = lang; - } - refInfo = { - refImportName: otherRefInfo.refImportName, - refImportPath: otherRefInfo.refImportPath, - variableName, - }; - foundRefInfo = true; - break; - } else if ( - repoType !== 'rhdh' || - otherRefInfo.refImportPath.startsWith('./') - ) { - // For non-rhdh repos, or local imports, use it - const langCapitalized = - lang.charAt(0).toUpperCase() + lang.slice(1); - let variableName = otherRefInfo.variableName; - if (variableName.match(/Translation(It|Ja|De|Fr|Es)$/)) { - variableName = variableName.replace( - /Translation(It|Ja|De|Fr|Es)$/, - `Translation${langCapitalized}`, - ); - } else { - variableName = lang; - } - refInfo = { - refImportName: otherRefInfo.refImportName, - refImportPath: otherRefInfo.refImportPath, - variableName, - }; - foundRefInfo = true; - break; - } - } - } - - if (!foundRefInfo) { - // If no good import found in other files, try existing file or infer - if (exists) { - const existingRefInfo = extractRefInfo(targetFile); - if (existingRefInfo) { - refInfo = existingRefInfo; - foundRefInfo = true; - } - } - - if (!foundRefInfo) { - // Try any other language file (even with ./ref or ./translations) - const anyOtherFile = otherLangFiles.find(f => fs.existsSync(f)); - if (anyOtherFile) { - const otherRefInfo = extractRefInfo(anyOtherFile); - if (otherRefInfo) { - // Verify and fix import path if needed - if (otherRefInfo.refImportPath.startsWith('./')) { - const expectedFile = path.join( - translationDir, - `${otherRefInfo.refImportPath.replace('./', '')}.ts`, - ); - if (!fs.existsSync(expectedFile)) { - otherRefInfo.refImportPath = - findRefImportPath(translationDir); - } - } - - const langCapitalized = - lang.charAt(0).toUpperCase() + lang.slice(1); - let variableName = otherRefInfo.variableName; - if (variableName.match(/Translation(It|Ja|De|Fr|Es)$/)) { - variableName = variableName.replace( - /Translation(It|Ja|De|Fr|Es)$/, - `Translation${langCapitalized}`, - ); - } else { - variableName = lang; - } - refInfo = { - refImportName: otherRefInfo.refImportName, - refImportPath: otherRefInfo.refImportPath, - variableName, - }; - foundRefInfo = true; - } - } - } - - if (!foundRefInfo) { - // Last resort: infer from plugin name - refInfo = inferRefInfo(pluginName, lang, repoType, translationDir); - } - } - - // Generate file content - const content = generateTranslationFile( - pluginName, - lang, - translations, - refInfo.refImportName, - refInfo.refImportPath, - refInfo.variableName, - ); + const { updated, created } = processLanguageTranslations( + data, + lang, + repoType, + repoRoot, + ); - // Write file - fs.writeFileSync(targetFile, content, 'utf-8'); - - const relativePath = path.relative(repoRoot, targetFile); - if (exists) { - console.log( - chalk.green( - ` โœ… Updated: ${relativePath} (${ - Object.keys(translations).length - } keys)`, - ), - ); - totalUpdated++; - } else { - console.log( - chalk.green( - ` โœจ Created: ${relativePath} (${ - Object.keys(translations).length - } keys)`, - ), - ); - totalCreated++; - } - } + totalUpdated += updated; + totalCreated += created; } console.log(chalk.blue(`\n\n๐Ÿ“Š Summary:`)); @@ -685,7 +769,9 @@ async function deployTranslations( const downloadDir = process.argv[2] || 'workspaces/i18n/downloads'; const repoRoot = process.cwd(); -deployTranslations(downloadDir, repoRoot).catch(error => { +try { + await deployTranslations(downloadDir, repoRoot); +} catch (error) { console.error(chalk.red('โŒ Error:'), error); process.exit(1); -}); +} diff --git a/workspaces/translations/packages/cli/src/commands/clean.ts b/workspaces/translations/packages/cli/src/commands/clean.ts index b758f84ed5..76c5fdf570 100644 --- a/workspaces/translations/packages/cli/src/commands/clean.ts +++ b/workspaces/translations/packages/cli/src/commands/clean.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import path from 'path'; +import path from 'node:path'; import chalk from 'chalk'; import { OptionValues } from 'commander'; @@ -49,34 +49,40 @@ async function collectCleanupTasks( cacheDir: string, backupDir: string, ): Promise { - const cleanupTasks: CleanupTask[] = []; - - const i18nTempFiles = await findI18nTempFiles(i18nDir); - if (i18nTempFiles.length > 0) { - cleanupTasks.push({ - name: 'i18n directory', - path: i18nDir, - files: i18nTempFiles, - }); - } - - if (await fs.pathExists(cacheDir)) { - cleanupTasks.push({ - name: 'cache directory', - path: cacheDir, - files: await fs.readdir(cacheDir), - }); - } - - if (await fs.pathExists(backupDir)) { - cleanupTasks.push({ - name: 'backup directory', - path: backupDir, - files: await fs.readdir(backupDir), - }); - } - - return cleanupTasks; + const [i18nTempFiles, cacheExists, backupExists] = await Promise.all([ + findI18nTempFiles(i18nDir), + fs.pathExists(cacheDir), + fs.pathExists(backupDir), + ]); + + const taskPromises: Promise[] = [ + Promise.resolve( + i18nTempFiles.length > 0 + ? { + name: 'i18n directory', + path: i18nDir, + files: i18nTempFiles, + } + : null, + ), + cacheExists + ? fs.readdir(cacheDir).then(files => ({ + name: 'cache directory', + path: cacheDir, + files, + })) + : Promise.resolve(null), + backupExists + ? fs.readdir(backupDir).then(files => ({ + name: 'backup directory', + path: backupDir, + files, + })) + : Promise.resolve(null), + ]; + + const tasks = await Promise.all(taskPromises); + return tasks.filter((task): task is CleanupTask => task !== null); } /** @@ -173,17 +179,16 @@ export async function cleanCommand(opts: OptionValues): Promise { displayCleanupPreview(cleanupTasks); - if (!force) { + if (force) { + const totalCleaned = await performCleanup(cleanupTasks); + await removeEmptyDirectories(cleanupTasks); + displaySummary(totalCleaned, cleanupTasks.length); + } else { console.log( chalk.yellow('โš ๏ธ This will permanently delete the above files.'), ); console.log(chalk.yellow(' Use --force to skip this confirmation.')); - return; } - - const totalCleaned = await performCleanup(cleanupTasks); - await removeEmptyDirectories(cleanupTasks); - displaySummary(totalCleaned, cleanupTasks.length); } catch (error) { console.error(chalk.red('โŒ Error during cleanup:'), error); throw error; diff --git a/workspaces/translations/packages/cli/src/commands/deploy.ts b/workspaces/translations/packages/cli/src/commands/deploy.ts index 537b6f4745..ace4e70144 100644 --- a/workspaces/translations/packages/cli/src/commands/deploy.ts +++ b/workspaces/translations/packages/cli/src/commands/deploy.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import path from 'path'; +import path from 'node:path'; import { fileURLToPath } from 'url'; import { OptionValues } from 'commander'; @@ -29,152 +29,6 @@ const __filename = fileURLToPath(import.meta.url); // eslint-disable-next-line no-restricted-syntax const __dirname = path.dirname(__filename); -interface DeployResult { - language: string; - sourcePath: string; - targetPath: string; - keyCount: number; -} - -/** - * Find and filter translation files based on language requirements - */ -async function findTranslationFiles( - sourceDir: string, - format: string, - languages?: string, -): Promise { - const translationFiles = await fs.readdir(sourceDir); - const languageFiles = translationFiles.filter( - file => file.endsWith(`.${format}`) && !file.startsWith('reference.'), - ); - - if (!languages) { - return languageFiles; - } - - const targetLanguages = languages - .split(',') - .map((lang: string) => lang.trim()); - return languageFiles.filter(file => { - const language = file.replace(`.${format}`, ''); - return targetLanguages.includes(language); - }); -} - -/** - * Create backup of existing translation files - */ -async function createBackup(targetDir: string, format: string): Promise { - const backupDir = path.join( - targetDir, - '.backup', - new Date().toISOString().replace(/[:.]/g, '-'), - ); - await fs.ensureDir(backupDir); - console.log(chalk.yellow(`๐Ÿ’พ Creating backup in ${backupDir}...`)); - - const existingFiles = await fs.readdir(targetDir).catch(() => []); - for (const file of existingFiles) { - if (file.endsWith(`.${format}`)) { - await fs.copy(path.join(targetDir, file), path.join(backupDir, file)); - } - } -} - -/** - * Validate translation data if validation is enabled - */ -async function validateTranslations( - translationData: Record, - language: string, - validate: boolean, -): Promise { - if (!validate) { - return; - } - - console.log(chalk.yellow(`๐Ÿ” Validating ${language} translations...`)); - const validationResult = await validateTranslationData( - translationData, - language, - ); - - if (!validationResult.isValid) { - console.warn(chalk.yellow(`โš ๏ธ Validation warnings for ${language}:`)); - for (const warning of validationResult.warnings) { - console.warn(chalk.gray(` ${warning}`)); - } - } -} - -/** - * Process a single translation file - */ -async function processTranslationFile( - fileName: string, - sourceDir: string, - targetDir: string, - format: string, - validate: boolean, -): Promise { - const language = fileName.replace(`.${format}`, ''); - const sourcePath = path.join(sourceDir, fileName); - const targetPath = path.join(targetDir, fileName); - - console.log(chalk.yellow(`๐Ÿ”„ Processing ${language}...`)); - - const translationData = await loadTranslationFile(sourcePath, format); - - if (!translationData || Object.keys(translationData).length === 0) { - console.log(chalk.yellow(`โš ๏ธ No translation data found in ${fileName}`)); - throw new Error(`No translation data in ${fileName}`); - } - - await validateTranslations(translationData, language, validate); - await deployTranslationFiles(translationData, targetPath, format); - - const keyCount = Object.keys(translationData).length; - console.log(chalk.green(`โœ… Deployed ${language}: ${keyCount} keys`)); - - return { - language, - sourcePath, - targetPath, - keyCount, - }; -} - -/** - * Display deployment summary - */ -function displaySummary( - deployResults: DeployResult[], - targetDir: string, - backup: boolean, -): void { - console.log(chalk.green(`โœ… Deployment completed successfully!`)); - console.log(chalk.gray(` Target directory: ${targetDir}`)); - console.log(chalk.gray(` Files deployed: ${deployResults.length}`)); - - if (deployResults.length > 0) { - console.log(chalk.blue('๐Ÿ“ Deployed files:')); - for (const result of deployResults) { - console.log( - chalk.gray( - ` ${result.language}: ${result.targetPath} (${result.keyCount} keys)`, - ), - ); - } - } - - if (backup) { - console.log( - chalk.blue(`๐Ÿ’พ Backup created: ${path.join(targetDir, '.backup')}`), - ); - } -} - /** * Deploy translations using the TypeScript deployment script */ diff --git a/workspaces/translations/packages/cli/src/commands/download.ts b/workspaces/translations/packages/cli/src/commands/download.ts index c263cf0124..c54d7d415d 100644 --- a/workspaces/translations/packages/cli/src/commands/download.ts +++ b/workspaces/translations/packages/cli/src/commands/download.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import path from 'path'; +import path from 'node:path'; import { OptionValues } from 'commander'; import chalk from 'chalk'; @@ -96,30 +96,33 @@ async function downloadJob( } /** - * Download translations using Memsource CLI + * Validate prerequisites for Memsource CLI download */ -async function downloadWithMemsourceCLI( - projectId: string, - outputDir: string, - jobIds?: string[], - languages?: string[], -): Promise> { - // Check if memsource CLI is available +function validateMemsourcePrerequisites(): void { if (!commandExists('memsource')) { throw new Error( 'memsource CLI not found. Please ensure memsource-cli is installed and ~/.memsourcerc is sourced.', ); } - // Check if MEMSOURCE_TOKEN is available if (!process.env.MEMSOURCE_TOKEN) { throw new Error( 'MEMSOURCE_TOKEN not found. Please source ~/.memsourcerc first: source ~/.memsourcerc', ); } +} - // Ensure output directory exists - await fs.ensureDir(outputDir); +/** + * Download specific jobs by their IDs + */ +async function downloadSpecificJobs( + projectId: string, + jobIds: string[], + outputDir: string, +): Promise> { + console.log( + chalk.yellow(`๐Ÿ“ฅ Downloading ${jobIds.length} specific job(s)...`), + ); const downloadResults: Array<{ jobId: string; @@ -127,70 +130,106 @@ async function downloadWithMemsourceCLI( lang: string; }> = []; - // If job IDs are provided, download those specific jobs - if (jobIds && jobIds.length > 0) { + for (const jobId of jobIds) { + const result = await downloadJob(projectId, jobId, outputDir); + if (result) { + downloadResults.push(result); + console.log( + chalk.green( + `โœ… Downloaded job ${result.jobId}: ${result.filename} (${result.lang})`, + ), + ); + } + } + + return downloadResults; +} + +/** + * List and filter completed jobs + */ +function listCompletedJobs( + projectId: string, + languages?: string[], +): Array<{ uid: string; filename: string; target_lang: string }> { + const listArgs = buildListJobsArgs(projectId); + const listOutput = safeExecSyncOrThrow('memsource', listArgs, { + encoding: 'utf-8', + env: { ...process.env }, + }); + const jobs = JSON.parse(listOutput); + const jobArray = Array.isArray(jobs) ? jobs : [jobs]; + + const completedJobs = jobArray.filter( + (job: any) => job.status === 'COMPLETED', + ); + + if (!languages || languages.length === 0) { + return completedJobs; + } + + const languageSet = new Set(languages); + return completedJobs.filter((job: any) => languageSet.has(job.target_lang)); +} + +/** + * Download all completed jobs + */ +async function downloadAllCompletedJobs( + projectId: string, + outputDir: string, + languages?: string[], +): Promise> { + console.log(chalk.yellow('๐Ÿ“‹ Listing available jobs...')); + + try { + const jobsToDownload = listCompletedJobs(projectId, languages); + console.log( - chalk.yellow(`๐Ÿ“ฅ Downloading ${jobIds.length} specific job(s)...`), + chalk.yellow( + `๐Ÿ“ฅ Found ${jobsToDownload.length} completed job(s) to download...`, + ), ); - for (const jobId of jobIds) { - const result = await downloadJob(projectId, jobId, outputDir); + const downloadResults: Array<{ + jobId: string; + filename: string; + lang: string; + }> = []; + + for (const job of jobsToDownload) { + const result = await downloadJob(projectId, job.uid, outputDir); if (result) { downloadResults.push(result); console.log( - chalk.green( - `โœ… Downloaded job ${result.jobId}: ${result.filename} (${result.lang})`, - ), + chalk.green(`โœ… Downloaded: ${result.filename} (${result.lang})`), ); } } - } else { - // List all completed jobs and download them - console.log(chalk.yellow('๐Ÿ“‹ Listing available jobs...')); - - try { - const listArgs = buildListJobsArgs(projectId); - const listOutput = safeExecSyncOrThrow('memsource', listArgs, { - encoding: 'utf-8', - env: { ...process.env }, - }); - const jobs = JSON.parse(listOutput); - const jobArray = Array.isArray(jobs) ? jobs : [jobs]; - - // Filter for completed jobs - const completedJobs = jobArray.filter( - (job: any) => job.status === 'COMPLETED', - ); - // Filter by languages if specified - let jobsToDownload = completedJobs; - if (languages && languages.length > 0) { - jobsToDownload = completedJobs.filter((job: any) => - languages.includes(job.target_lang), - ); - } + return downloadResults; + } catch (error: any) { + throw new Error(`Failed to list jobs: ${error.message}`); + } +} - console.log( - chalk.yellow( - `๐Ÿ“ฅ Found ${jobsToDownload.length} completed job(s) to download...`, - ), - ); +/** + * Download translations using Memsource CLI + */ +async function downloadWithMemsourceCLI( + projectId: string, + outputDir: string, + jobIds?: string[], + languages?: string[], +): Promise> { + validateMemsourcePrerequisites(); + await fs.ensureDir(outputDir); - for (const job of jobsToDownload) { - const result = await downloadJob(projectId, job.uid, outputDir); - if (result) { - downloadResults.push(result); - console.log( - chalk.green(`โœ… Downloaded: ${result.filename} (${result.lang})`), - ); - } - } - } catch (error: any) { - throw new Error(`Failed to list jobs: ${error.message}`); - } + if (jobIds && jobIds.length > 0) { + return downloadSpecificJobs(projectId, jobIds, outputDir); } - return downloadResults; + return downloadAllCompletedJobs(projectId, outputDir, languages); } export async function downloadCommand(opts: OptionValues): Promise { diff --git a/workspaces/translations/packages/cli/src/commands/generate.ts b/workspaces/translations/packages/cli/src/commands/generate.ts index a0063d899b..0320f290a5 100644 --- a/workspaces/translations/packages/cli/src/commands/generate.ts +++ b/workspaces/translations/packages/cli/src/commands/generate.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import path from 'path'; +import path from 'node:path'; import { OptionValues } from 'commander'; import chalk from 'chalk'; @@ -39,12 +39,327 @@ function isNestedStructure( ); } +/** + * Language codes to exclude from reference files + */ +const LANGUAGE_CODES = [ + 'de', + 'es', + 'fr', + 'it', + 'ja', + 'ko', + 'pt', + 'zh', + 'ru', + 'ar', + 'hi', + 'nl', + 'pl', + 'sv', + 'tr', + 'uk', + 'vi', +] as const; + +/** + * Check if a file is a language file (non-English) + */ +function isLanguageFile(fileName: string): boolean { + const isNonEnglishLanguage = LANGUAGE_CODES.some(code => { + if (fileName === code) return true; + if (fileName.endsWith(`-${code}`)) return true; + if (fileName.includes(`.${code}.`) || fileName.includes(`-${code}-`)) { + return true; + } + return false; + }); + + return isNonEnglishLanguage && !fileName.includes('-en') && fileName !== 'en'; +} + +/** + * Check if a file is an English reference file + */ +function isEnglishReferenceFile(filePath: string, content: string): boolean { + const fileName = path.basename(filePath, path.extname(filePath)); + const fullFileName = path.basename(filePath); + + // Check if it's a language file (exclude non-English) + if (isLanguageFile(fileName)) { + return false; + } + + // Check if file contains createTranslationRef (defines new translation keys) + const hasCreateTranslationRef = + content.includes('createTranslationRef') && + (content.includes("from '@backstage/core-plugin-api/alpha'") || + content.includes("from '@backstage/frontend-plugin-api'")); + + // Check if it's an English file with createTranslationMessages that has a ref + const isEnglishFile = + fullFileName.endsWith('-en.ts') || + fullFileName.endsWith('-en.tsx') || + fullFileName === 'en.ts' || + fullFileName === 'en.tsx' || + fileName.endsWith('-en') || + fileName === 'en'; + + const hasCreateTranslationMessagesWithRef = + isEnglishFile && + content.includes('createTranslationMessages') && + content.includes('ref:') && + (content.includes("from '@backstage/core-plugin-api/alpha'") || + content.includes("from '@backstage/frontend-plugin-api'")); + + return hasCreateTranslationRef || hasCreateTranslationMessagesWithRef; +} + +/** + * Find all English reference files + */ +async function findEnglishReferenceFiles( + sourceDir: string, + includePattern: string, + excludePattern: string, +): Promise { + const allSourceFiles = glob.sync(includePattern, { + cwd: sourceDir, + ignore: excludePattern, + absolute: true, + }); + + const sourceFiles: string[] = []; + + for (const filePath of allSourceFiles) { + try { + const content = await fs.readFile(filePath, 'utf-8'); + if (isEnglishReferenceFile(filePath, content)) { + sourceFiles.push(filePath); + } + } catch { + // Skip files that can't be read + continue; + } + } + + return sourceFiles; +} + +/** + * Detect plugin name from file path + */ +function detectPluginName(filePath: string): string | null { + // Pattern 1: workspaces/{workspace}/plugins/{plugin}/... + const workspaceRegex = /workspaces\/([^/]+)\/plugins\/([^/]+)/; + const workspaceMatch = workspaceRegex.exec(filePath); + if (workspaceMatch) { + return workspaceMatch[2]; + } + + // Pattern 2: .../translations/{plugin}/ref.ts + const translationsRegex = /translations\/([^/]+)\//; + const translationsMatch = translationsRegex.exec(filePath); + if (translationsMatch) { + return translationsMatch[1]; + } + + // Pattern 3: Fallback - use parent directory name + const dirName = path.dirname(filePath); + const parentDir = path.basename(dirName); + if (parentDir === 'translations' || parentDir.includes('translation')) { + const grandParentDir = path.basename(path.dirname(dirName)); + return grandParentDir; + } + + return parentDir; +} + +/** + * Invalid plugin names to filter out + */ +const INVALID_PLUGIN_NAMES = new Set([ + 'dist', + 'build', + 'node_modules', + 'packages', + 'src', + 'lib', + 'components', + 'utils', +]); + +/** + * Extract translation keys and group by plugin + */ +async function extractAndGroupKeys( + sourceFiles: string[], +): Promise }>> { + const pluginGroups: Record> = {}; + + for (const filePath of sourceFiles) { + try { + const content = await fs.readFile(filePath, 'utf-8'); + const keys = extractTranslationKeys(content, filePath); + + const pluginName = detectPluginName(filePath); + + if (!pluginName) { + console.warn( + chalk.yellow( + `โš ๏ธ Warning: Could not determine plugin name for ${path.relative( + process.cwd(), + filePath, + )}, skipping`, + ), + ); + continue; + } + + if (INVALID_PLUGIN_NAMES.has(pluginName.toLowerCase())) { + console.warn( + chalk.yellow( + `โš ๏ธ Warning: Skipping invalid plugin name "${pluginName}" from ${path.relative( + process.cwd(), + filePath, + )}`, + ), + ); + continue; + } + + if (!pluginGroups[pluginName]) { + pluginGroups[pluginName] = {}; + } + + // Merge keys into plugin group (warn about overwrites) + const overwrittenKeys: string[] = []; + for (const [key, value] of Object.entries(keys)) { + if ( + pluginGroups[pluginName][key] && + pluginGroups[pluginName][key] !== value + ) { + overwrittenKeys.push(key); + } + pluginGroups[pluginName][key] = value; + } + + if (overwrittenKeys.length > 0) { + console.warn( + chalk.yellow( + `โš ๏ธ Warning: ${ + overwrittenKeys.length + } keys were overwritten in plugin "${pluginName}" from ${path.relative( + process.cwd(), + filePath, + )}`, + ), + ); + } + } catch (error) { + console.warn( + chalk.yellow(`โš ๏ธ Warning: Could not process ${filePath}: ${error}`), + ); + } + } + + // Convert to nested structure: { plugin: { en: { keys } } } + const structuredData: Record }> = {}; + for (const [pluginName, keys] of Object.entries(pluginGroups)) { + structuredData[pluginName] = { en: keys }; + } + + return structuredData; +} + +/** + * Generate or merge translation files + */ +async function generateOrMergeFiles( + translationKeys: + | Record + | Record }>, + outputPath: string, + formatStr: string, + mergeExisting: boolean, +): Promise { + if (mergeExisting && (await fs.pathExists(outputPath))) { + console.log(chalk.yellow(`๐Ÿ”„ Merging with existing ${outputPath}...`)); + await mergeTranslationFiles(translationKeys, outputPath, formatStr); + } else { + console.log(chalk.yellow(`๐Ÿ“ Generating ${outputPath}...`)); + await generateTranslationFiles(translationKeys, outputPath, formatStr); + } +} + +/** + * Validate generated file + */ +async function validateGeneratedFile( + outputPath: string, + formatStr: string, +): Promise { + if (formatStr !== 'json') { + return; + } + + console.log(chalk.yellow(`๐Ÿ” Validating generated file...`)); + const { validateTranslationFile } = await import('../lib/i18n/validateFile'); + const isValid = await validateTranslationFile(outputPath); + if (!isValid) { + throw new Error(`Generated file failed validation: ${outputPath}`); + } + console.log(chalk.green(`โœ… Generated file is valid`)); +} + +/** + * Display summary of included plugins + */ +function displaySummary( + translationKeys: Record }>, +): void { + console.log(chalk.blue('\n๐Ÿ“‹ Included Plugins Summary:')); + console.log(chalk.gray('โ”€'.repeat(60))); + + const plugins = Object.entries(translationKeys) + .map(([pluginName, pluginData]) => ({ + name: pluginName, + keyCount: Object.keys(pluginData.en || {}).length, + })) + .sort((a, b) => a.name.localeCompare(b.name)); + + let totalKeys = 0; + for (const plugin of plugins) { + const keyLabel = plugin.keyCount === 1 ? 'key' : 'keys'; + console.log( + chalk.cyan( + ` โ€ข ${plugin.name.padEnd(35)} ${chalk.yellow( + plugin.keyCount.toString().padStart(4), + )} ${keyLabel}`, + ), + ); + totalKeys += plugin.keyCount; + } + + console.log(chalk.gray('โ”€'.repeat(60))); + const pluginLabel = plugins.length === 1 ? 'plugin' : 'plugins'; + const totalKeyLabel = totalKeys === 1 ? 'key' : 'keys'; + console.log( + chalk.cyan( + ` Total: ${chalk.yellow( + plugins.length.toString(), + )} ${pluginLabel}, ${chalk.yellow( + totalKeys.toString(), + )} ${totalKeyLabel}`, + ), + ); + console.log(''); +} + export async function generateCommand(opts: OptionValues): Promise { console.log(chalk.blue('๐ŸŒ Generating translation reference files...')); - // Load config and merge with options const config = await loadI18nConfig(); - // mergeConfigWithOptions is async (may generate token), so we await it const mergedOpts = await mergeConfigWithOptions(config, opts); const { @@ -66,10 +381,8 @@ export async function generateCommand(opts: OptionValues): Promise { }; try { - // Ensure output directory exists await fs.ensureDir(outputDir); - // Can be either flat structure (legacy) or nested structure (new) const translationKeys: | Record | Record }> = {}; @@ -79,100 +392,17 @@ export async function generateCommand(opts: OptionValues): Promise { chalk.yellow(`๐Ÿ“ Scanning ${sourceDir} for translation keys...`), ); - // Find all source files matching the pattern - const allSourceFiles = glob.sync( - String(includePattern || '**/*.{ts,tsx,js,jsx}'), - { - cwd: String(sourceDir || 'src'), - ignore: String(excludePattern || '**/node_modules/**'), - absolute: true, - }, - ); + const allSourceFiles = glob.sync(includePattern, { + cwd: sourceDir, + ignore: excludePattern, + absolute: true, + }); - // Filter to only English reference files: - // 1. Files with createTranslationRef (defines new translation keys) - // 2. Files with createTranslationMessages that are English (overrides/extends existing keys) - // 3. Files with createTranslationResource (sets up translation resources - may contain keys) - // - Exclude language files (de.ts, es.ts, fr.ts, it.ts, etc.) - const sourceFiles: string[] = []; - const languageCodes = [ - 'de', - 'es', - 'fr', - 'it', - 'ja', - 'ko', - 'pt', - 'zh', - 'ru', - 'ar', - 'hi', - 'nl', - 'pl', - 'sv', - 'tr', - 'uk', - 'vi', - ]; - - for (const filePath of allSourceFiles) { - try { - const content = await fs.readFile(filePath, 'utf-8'); - const fileName = path.basename(filePath, path.extname(filePath)); - - // Check if it's a language file: - // 1. Filename is exactly a language code (e.g., "es.ts", "fr.ts") - // 2. Filename ends with language code (e.g., "something-es.ts", "something-fr.ts") - // 3. Filename contains language code with separators (e.g., "something.de.ts") - // Exclude if it's explicitly English (e.g., "something-en.ts", "en.ts") - const isLanguageFile = - languageCodes.some(code => { - if (fileName === code) return true; // Exact match: "es.ts" - if (fileName.endsWith(`-${code}`)) return true; // Ends with: "something-es.ts" - if ( - fileName.includes(`.${code}.`) || - fileName.includes(`-${code}-`) - ) - return true; // Contains: "something.de.ts" - return false; - }) && - !fileName.includes('-en') && - fileName !== 'en'; - - // Check if file contains createTranslationRef import (defines new translation keys) - // This is the primary source for English reference keys - const hasCreateTranslationRef = - content.includes('createTranslationRef') && - (content.includes("from '@backstage/core-plugin-api/alpha'") || - content.includes("from '@backstage/frontend-plugin-api'")); - - // Also include English files with createTranslationMessages that have a ref - // These are English overrides/extensions of existing translations - // Only include -en.ts files to avoid non-English translations - const fullFileName = path.basename(filePath); - const isEnglishFile = - fullFileName.endsWith('-en.ts') || - fullFileName.endsWith('-en.tsx') || - fullFileName === 'en.ts' || - fullFileName === 'en.tsx' || - fileName.endsWith('-en') || - fileName === 'en'; - const hasCreateTranslationMessagesWithRef = - isEnglishFile && - content.includes('createTranslationMessages') && - content.includes('ref:') && - (content.includes("from '@backstage/core-plugin-api/alpha'") || - content.includes("from '@backstage/frontend-plugin-api'")); - - // Include files that define new translation keys OR English overrides - if (hasCreateTranslationRef || hasCreateTranslationMessagesWithRef) { - sourceFiles.push(filePath); - } - } catch { - // Skip files that can't be read - continue; - } - } + const sourceFiles = await findEnglishReferenceFiles( + sourceDir, + includePattern, + excludePattern, + ); console.log( chalk.gray( @@ -180,218 +410,42 @@ export async function generateCommand(opts: OptionValues): Promise { ), ); - // Structure: { pluginName: { en: { key: value } } } - const pluginGroups: Record> = {}; - - // Extract translation keys from each reference file and group by plugin - for (const filePath of sourceFiles) { - try { - const content = await fs.readFile(filePath, 'utf-8'); - const keys = extractTranslationKeys(content, filePath); - - // Detect plugin name from file path - let pluginName: string | null = null; - - // Pattern 1: workspaces/{workspace}/plugins/{plugin}/... - const workspaceMatch = filePath.match( - /workspaces\/([^/]+)\/plugins\/([^/]+)/, - ); - if (workspaceMatch) { - // Use plugin name (not workspace.plugin) - pluginName = workspaceMatch[2]; - } else { - // Pattern 2: .../translations/{plugin}/ref.ts or .../translations/{plugin}/translation.ts - // Look for a folder named "translations" and use the next folder as plugin name - const translationsMatch = filePath.match(/translations\/([^/]+)\//); - if (translationsMatch) { - pluginName = translationsMatch[1]; - } else { - // Pattern 3: Fallback - use parent directory name if file is in a translations folder - const dirName = path.dirname(filePath); - const parentDir = path.basename(dirName); - if ( - parentDir === 'translations' || - parentDir.includes('translation') - ) { - const grandParentDir = path.basename(path.dirname(dirName)); - pluginName = grandParentDir; - } else { - // Last resort: use the directory containing the file - pluginName = parentDir; - } - } - } - - if (!pluginName) { - console.warn( - chalk.yellow( - `โš ๏ธ Warning: Could not determine plugin name for ${path.relative( - process.cwd(), - filePath, - )}, skipping`, - ), - ); - continue; - } - - // Filter out invalid plugin names (common directory names that shouldn't be plugins) - const invalidPluginNames = [ - 'dist', - 'build', - 'node_modules', - 'packages', - 'src', - 'lib', - 'components', - 'utils', - ]; - if (invalidPluginNames.includes(pluginName.toLowerCase())) { - console.warn( - chalk.yellow( - `โš ๏ธ Warning: Skipping invalid plugin name "${pluginName}" from ${path.relative( - process.cwd(), - filePath, - )}`, - ), - ); - continue; - } - - // Initialize plugin group if it doesn't exist - if (!pluginGroups[pluginName]) { - pluginGroups[pluginName] = {}; - } - - // Merge keys into plugin group (warn about overwrites) - const overwrittenKeys: string[] = []; - for (const [key, value] of Object.entries(keys)) { - if ( - pluginGroups[pluginName][key] && - pluginGroups[pluginName][key] !== value - ) { - overwrittenKeys.push(key); - } - pluginGroups[pluginName][key] = value; - } - - if (overwrittenKeys.length > 0) { - console.warn( - chalk.yellow( - `โš ๏ธ Warning: ${ - overwrittenKeys.length - } keys were overwritten in plugin "${pluginName}" from ${path.relative( - process.cwd(), - filePath, - )}`, - ), - ); - } - } catch (error) { - console.warn( - chalk.yellow( - `โš ๏ธ Warning: Could not process ${filePath}: ${error}`, - ), - ); - } - } - - // Convert plugin groups to the final structure: { plugin: { en: { keys } } } - const structuredData: Record }> = {}; - for (const [pluginName, keys] of Object.entries(pluginGroups)) { - structuredData[pluginName] = { en: keys }; - } + const structuredData = await extractAndGroupKeys(sourceFiles); - const totalKeys = Object.values(pluginGroups).reduce( - (sum, keys) => sum + Object.keys(keys).length, + const totalKeys = Object.values(structuredData).reduce( + (sum, pluginData) => sum + Object.keys(pluginData.en || {}).length, 0, ); console.log( chalk.green( `โœ… Extracted ${totalKeys} translation keys from ${ - Object.keys(pluginGroups).length + Object.keys(structuredData).length } plugins`, ), ); - // Store structured data in translationKeys (will be passed to generateTranslationFiles) Object.assign(translationKeys, structuredData); } - // Generate translation files const formatStr = String(format || 'json'); const outputPath = path.join( String(outputDir || 'i18n'), `reference.${formatStr}`, ); - if (mergeExisting && (await fs.pathExists(outputPath))) { - console.log(chalk.yellow(`๐Ÿ”„ Merging with existing ${outputPath}...`)); - // mergeTranslationFiles now accepts both structures - await mergeTranslationFiles( - translationKeys as - | Record - | Record }>, - outputPath, - formatStr, - ); - } else { - console.log(chalk.yellow(`๐Ÿ“ Generating ${outputPath}...`)); - await generateTranslationFiles(translationKeys, outputPath, formatStr); - } + await generateOrMergeFiles( + translationKeys, + outputPath, + formatStr, + mergeExisting, + ); - // Validate the generated file - if (formatStr === 'json') { - console.log(chalk.yellow(`๐Ÿ” Validating generated file...`)); - const { validateTranslationFile } = await import( - '../lib/i18n/validateFile' - ); - const isValid = await validateTranslationFile(outputPath); - if (!isValid) { - throw new Error(`Generated file failed validation: ${outputPath}`); - } - console.log(chalk.green(`โœ… Generated file is valid`)); - } + await validateGeneratedFile(outputPath, formatStr); - // Print summary of included plugins if (extractKeys && isNestedStructure(translationKeys)) { - console.log(chalk.blue('\n๐Ÿ“‹ Included Plugins Summary:')); - console.log(chalk.gray('โ”€'.repeat(60))); - - const plugins = Object.entries( + displaySummary( translationKeys as Record }>, - ) - .map(([pluginName, pluginData]) => ({ - name: pluginName, - keyCount: Object.keys(pluginData.en || {}).length, - })) - .sort((a, b) => a.name.localeCompare(b.name)); - - let totalKeys = 0; - for (const plugin of plugins) { - const keyLabel = plugin.keyCount === 1 ? 'key' : 'keys'; - console.log( - chalk.cyan( - ` โ€ข ${plugin.name.padEnd(35)} ${chalk.yellow( - plugin.keyCount.toString().padStart(4), - )} ${keyLabel}`, - ), - ); - totalKeys += plugin.keyCount; - } - - console.log(chalk.gray('โ”€'.repeat(60))); - const pluginLabel = plugins.length === 1 ? 'plugin' : 'plugins'; - const totalKeyLabel = totalKeys === 1 ? 'key' : 'keys'; - console.log( - chalk.cyan( - ` Total: ${chalk.yellow( - plugins.length.toString(), - )} ${pluginLabel}, ${chalk.yellow( - totalKeys.toString(), - )} ${totalKeyLabel}`, - ), ); - console.log(''); } console.log( diff --git a/workspaces/translations/packages/cli/src/commands/init.ts b/workspaces/translations/packages/cli/src/commands/init.ts index 6ee14b8977..b3d7def86d 100644 --- a/workspaces/translations/packages/cli/src/commands/init.ts +++ b/workspaces/translations/packages/cli/src/commands/init.ts @@ -14,8 +14,8 @@ * limitations under the License. */ -import os from 'os'; -import path from 'path'; +import os from 'node:os'; +import path from 'node:path'; import { OptionValues } from 'commander'; import chalk from 'chalk'; diff --git a/workspaces/translations/packages/cli/src/commands/setupMemsource.ts b/workspaces/translations/packages/cli/src/commands/setupMemsource.ts index 6ef9a1cfda..7eca566674 100644 --- a/workspaces/translations/packages/cli/src/commands/setupMemsource.ts +++ b/workspaces/translations/packages/cli/src/commands/setupMemsource.ts @@ -14,9 +14,9 @@ * limitations under the License. */ -import path from 'path'; -import os from 'os'; -import * as readline from 'readline'; +import path from 'node:path'; +import os from 'node:os'; +import * as readline from 'node:readline'; import { stdin, stdout } from 'process'; import { OptionValues } from 'commander'; @@ -24,214 +24,273 @@ import chalk from 'chalk'; import fs from 'fs-extra'; /** - * Set up .memsourcerc file following localization team instructions + * Check if terminal is interactive */ -export async function setupMemsourceCommand(opts: OptionValues): Promise { - console.log( - chalk.blue('๐Ÿ”ง Setting up .memsourcerc file for Memsource CLI...'), - ); +function isInteractiveTerminal(): boolean { + return stdin.isTTY && stdout.isTTY; +} - const { - memsourceVenv = '${HOME}/git/memsource-cli-client/.memsource/bin/activate', - memsourceUrl = 'https://cloud.memsource.com/web', - username, - password, - } = opts; +/** + * Prompt for username + */ +async function promptUsername(rl: readline.Interface): Promise { + const question = (query: string): Promise => { + return new Promise(resolve => { + rl.question(query, resolve); + }); + }; + + const username = await question(chalk.yellow('Enter Memsource username: ')); + if (!username || username.trim() === '') { + rl.close(); + throw new Error('Username is required'); + } + return username; +} - try { - let finalUsername = username; - let finalPassword = password; +/** + * Prompt for password with masking + */ +async function promptPassword(rl: readline.Interface): Promise { + const questionPassword = (query: string): Promise => { + return new Promise(resolve => { + const wasRawMode = stdin.isRaw || false; - // Check if we're in an interactive terminal (TTY) - const isInteractive = stdin.isTTY && stdout.isTTY; - const noInput = opts.noInput === true; + if (stdin.isTTY) { + stdin.setRawMode(true); + } + stdin.resume(); + stdin.setEncoding('utf8'); - // Prompt for credentials if not provided and we're in an interactive terminal - if ((!finalUsername || !finalPassword) && isInteractive && !noInput) { - const rl = readline.createInterface({ - input: stdin, - output: stdout, - }); - - const question = (query: string): Promise => { - return new Promise(resolve => { - rl.question(query, resolve); - }); - }; + stdout.write(query); - // Helper to hide password input (masks with asterisks) - const questionPassword = (query: string): Promise => { - return new Promise(resolve => { - const wasRawMode = stdin.isRaw || false; + let inputPassword = ''; - // Set raw mode to capture individual characters - if (stdin.isTTY) { - stdin.setRawMode(true); + // eslint-disable-next-line prefer-const + let cleanup: () => void; + + const onData = (char: string) => { + if (char === '\r' || char === '\n') { + cleanup(); + stdout.write('\n'); + resolve(inputPassword); + return; + } + + if (char === '\u0003') { + cleanup(); + stdout.write('\n'); + process.exit(130); + return; + } + + if (char === '\u007f' || char === '\b' || char === '\u001b[3~') { + if (inputPassword.length > 0) { + inputPassword = inputPassword.slice(0, -1); + stdout.write('\b \b'); } - stdin.resume(); - stdin.setEncoding('utf8'); - - stdout.write(query); - - let inputPassword = ''; - - // Declare cleanup first so it can be referenced in onData - // eslint-disable-next-line prefer-const - let cleanup: () => void; - - const onData = (char: string) => { - // Handle Enter/Return - if (char === '\r' || char === '\n') { - cleanup(); - stdout.write('\n'); - resolve(inputPassword); - return; - } - - // Handle Ctrl+C - if (char === '\u0003') { - cleanup(); - stdout.write('\n'); - process.exit(130); - return; - } - - // Handle backspace/delete - if (char === '\u007f' || char === '\b' || char === '\u001b[3~') { - if (inputPassword.length > 0) { - inputPassword = inputPassword.slice(0, -1); - stdout.write('\b \b'); - } - return; - } - - // Ignore control characters - if (char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127) { - return; - } - - // Add character and mask it - inputPassword += char; - stdout.write('*'); - }; - - cleanup = () => { - stdin.removeListener('data', onData); - if (stdin.isTTY) { - stdin.setRawMode(wasRawMode); - } - stdin.pause(); - }; - - stdin.on('data', onData); - }); - }; + return; + } - if (!finalUsername) { - finalUsername = await question( - chalk.yellow('Enter Memsource username: '), - ); - if (!finalUsername || finalUsername.trim() === '') { - rl.close(); - throw new Error('Username is required'); + if (char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127) { + return; } - } - if (!finalPassword) { - finalPassword = await questionPassword( - chalk.yellow('Enter Memsource password: '), - ); - if (!finalPassword || finalPassword.trim() === '') { - rl.close(); - throw new Error('Password is required'); + inputPassword += char; + stdout.write('*'); + }; + + cleanup = () => { + stdin.removeListener('data', onData); + if (stdin.isTTY) { + stdin.setRawMode(wasRawMode); } - } + stdin.pause(); + }; - rl.close(); - } - - // Validate required credentials - if (!finalUsername || !finalPassword) { - if (!isInteractive || noInput) { - throw new Error( - 'Username and password are required. ' + - 'Provide them via --username and --password options, ' + - 'or use environment variables (MEMSOURCE_USERNAME, MEMSOURCE_PASSWORD), ' + - 'or run in an interactive terminal to be prompted.', - ); - } - throw new Error('Username and password are required'); - } + stdin.on('data', onData); + }); + }; - // Keep ${HOME} in venv path (don't expand it - it should be expanded by the shell when sourced) - // The path should remain as ${HOME}/git/memsource-cli-client/.memsource/bin/activate + const password = await questionPassword( + chalk.yellow('Enter Memsource password: '), + ); + if (!password || password.trim() === '') { + rl.close(); + throw new Error('Password is required'); + } + return password; +} + +/** + * Prompt for credentials interactively + */ +async function promptCredentials(): Promise<{ + username: string; + password: string; +}> { + const rl = readline.createInterface({ + input: stdin, + output: stdout, + }); - // Create .memsourcerc content following localization team format - // Note: Using string concatenation to avoid template literal interpretation of ${MEMSOURCE_PASSWORD} - const memsourceRcContent = `source ${memsourceVenv} + try { + const username = await promptUsername(rl); + const password = await promptPassword(rl); + return { username, password }; + } finally { + rl.close(); + } +} + +/** + * Get credentials from options or prompt + */ +async function getCredentials( + isInteractive: boolean, + noInput: boolean, + username?: string, + password?: string, +): Promise<{ username: string; password: string }> { + if (username && password) { + return { username, password }; + } + + if (isInteractive && !noInput) { + const prompted = await promptCredentials(); + return { + username: username || prompted.username, + password: password || prompted.password, + }; + } + + if (!isInteractive || noInput) { + throw new Error( + 'Username and password are required. ' + + 'Provide them via --username and --password options, ' + + 'or use environment variables (MEMSOURCE_USERNAME, MEMSOURCE_PASSWORD), ' + + 'or run in an interactive terminal to be prompted.', + ); + } + + throw new Error('Username and password are required'); +} + +/** + * Generate .memsourcerc file content + */ +function generateMemsourceRcContent( + memsourceVenv: string, + memsourceUrl: string, + username: string, + password: string, +): string { + return `source ${memsourceVenv} export MEMSOURCE_URL="${memsourceUrl}" -export MEMSOURCE_USERNAME=${finalUsername} +export MEMSOURCE_USERNAME=${username} -export MEMSOURCE_PASSWORD="${finalPassword}" +export MEMSOURCE_PASSWORD="${password}" export MEMSOURCE_TOKEN=$(memsource auth login --user-name $MEMSOURCE_USERNAME --password "$"MEMSOURCE_PASSWORD -c token -f value) `.replace('$"MEMSOURCE_PASSWORD', '${MEMSOURCE_PASSWORD}'); +} - // Write to ~/.memsourcerc - const memsourceRcPath = path.join(os.homedir(), '.memsourcerc'); - await fs.writeFile(memsourceRcPath, memsourceRcContent, { mode: 0o600 }); // Read/write for owner only +/** + * Display setup instructions + */ +function displaySetupInstructions(memsourceRcPath: string): void { + console.log( + chalk.green(`โœ… Created .memsourcerc file at ${memsourceRcPath}`), + ); + console.log(chalk.yellow('\nโš ๏ธ Security Note:')); + console.log(chalk.gray(' This file contains your password in plain text.')); + console.log( + chalk.gray(' File permissions are set to 600 (owner read/write only).'), + ); + console.log( + chalk.gray( + ' Keep this file secure and never commit it to version control.', + ), + ); - console.log( - chalk.green(`โœ… Created .memsourcerc file at ${memsourceRcPath}`), - ); - console.log(chalk.yellow('\nโš ๏ธ Security Note:')); - console.log( - chalk.gray(' This file contains your password in plain text.'), - ); - console.log( - chalk.gray(' File permissions are set to 600 (owner read/write only).'), - ); - console.log( - chalk.gray( - ' Keep this file secure and never commit it to version control.', - ), - ); + console.log(chalk.yellow('\n๐Ÿ“ Next steps:')); + console.log(chalk.gray(' 1. Source the file in your shell:')); + console.log(chalk.cyan(` source ~/.memsourcerc`)); + console.log( + chalk.gray( + ' 2. Or add it to your shell profile (~/.bashrc, ~/.zshrc, etc.):', + ), + ); + console.log(chalk.cyan(` echo "source ~/.memsourcerc" >> ~/.zshrc`)); + console.log(chalk.gray(' 3. Verify the setup:')); + console.log( + chalk.cyan(` source ~/.memsourcerc && echo $MEMSOURCE_TOKEN`), + ); + console.log( + chalk.gray( + ' 4. After sourcing, you can use i18n commands without additional setup', + ), + ); +} - console.log(chalk.yellow('\n๐Ÿ“ Next steps:')); - console.log(chalk.gray(' 1. Source the file in your shell:')); - console.log(chalk.cyan(` source ~/.memsourcerc`)); +/** + * Check and warn about virtual environment path + */ +async function checkVirtualEnvironment(memsourceVenv: string): Promise { + const expandedVenvPath = memsourceVenv.replaceAll( + /\$\{HOME\}/g, + os.homedir(), + ); + if (!(await fs.pathExists(expandedVenvPath))) { console.log( - chalk.gray( - ' 2. Or add it to your shell profile (~/.bashrc, ~/.zshrc, etc.):', + chalk.yellow( + `\nโš ๏ธ Warning: Virtual environment not found at ${expandedVenvPath}`, ), ); - console.log(chalk.cyan(` echo "source ~/.memsourcerc" >> ~/.zshrc`)); - console.log(chalk.gray(' 3. Verify the setup:')); - console.log( - chalk.cyan(` source ~/.memsourcerc && echo $MEMSOURCE_TOKEN`), - ); console.log( chalk.gray( - ' 4. After sourcing, you can use i18n commands without additional setup', + ' Please update the path in ~/.memsourcerc if your venv is located elsewhere.', ), ); + } +} + +/** + * Set up .memsourcerc file following localization team instructions + */ +export async function setupMemsourceCommand(opts: OptionValues): Promise { + console.log( + chalk.blue('๐Ÿ”ง Setting up .memsourcerc file for Memsource CLI...'), + ); + + const { + memsourceVenv = '${HOME}/git/memsource-cli-client/.memsource/bin/activate', + memsourceUrl = 'https://cloud.memsource.com/web', + username, + password, + } = opts; + + try { + const isInteractive = isInteractiveTerminal(); + const noInput = opts.noInput === true; + + const { username: finalUsername, password: finalPassword } = + await getCredentials(isInteractive, noInput, username, password); + + const memsourceRcContent = generateMemsourceRcContent( + memsourceVenv, + memsourceUrl, + finalUsername, + finalPassword, + ); + + const memsourceRcPath = path.join(os.homedir(), '.memsourcerc'); + await fs.writeFile(memsourceRcPath, memsourceRcContent, { mode: 0o600 }); - // Check if virtual environment path exists (expand ${HOME} for checking) - const expandedVenvPath = memsourceVenv.replace(/\$\{HOME\}/g, os.homedir()); - if (!(await fs.pathExists(expandedVenvPath))) { - console.log( - chalk.yellow( - `\nโš ๏ธ Warning: Virtual environment not found at ${expandedVenvPath}`, - ), - ); - console.log( - chalk.gray( - ' Please update the path in ~/.memsourcerc if your venv is located elsewhere.', - ), - ); - } + displaySetupInstructions(memsourceRcPath); + await checkVirtualEnvironment(memsourceVenv); } catch (error) { console.error(chalk.red('โŒ Error setting up .memsourcerc:'), error); throw error; diff --git a/workspaces/translations/packages/cli/src/commands/sync.ts b/workspaces/translations/packages/cli/src/commands/sync.ts index 5d25f11077..fb1a86a2eb 100644 --- a/workspaces/translations/packages/cli/src/commands/sync.ts +++ b/workspaces/translations/packages/cli/src/commands/sync.ts @@ -50,18 +50,20 @@ function hasTmsConfig( } /** - * Execute step with dry run support + * Execute a step (actually perform the action) */ async function executeStep( stepName: string, - dryRun: boolean, action: () => Promise, ): Promise { - if (dryRun) { - console.log(chalk.yellow(`๐Ÿ” Dry run: Would ${stepName}`)); - } else { - await action(); - } + await action(); +} + +/** + * Simulate a step (show what would be done) + */ +function simulateStep(stepName: string): void { + console.log(chalk.yellow(`๐Ÿ” Dry run: Would ${stepName}`)); } /** @@ -76,17 +78,21 @@ async function stepGenerate( chalk.blue('\n๐Ÿ“ Step 1: Generating translation reference files...'), ); - await executeStep('generate translation files', dryRun, async () => { - await generateCommand({ - sourceDir, - outputDir, - format: 'json', - includePattern: '**/*.{ts,tsx,js,jsx}', - excludePattern: '**/node_modules/**', - extractKeys: true, - mergeExisting: false, + if (dryRun) { + simulateStep('generate translation files'); + } else { + await executeStep('generate translation files', async () => { + await generateCommand({ + sourceDir, + outputDir, + format: 'json', + includePattern: '**/*.{ts,tsx,js,jsx}', + excludePattern: '**/node_modules/**', + extractKeys: true, + mergeExisting: false, + }); }); - }); + } return 'Generate'; } @@ -105,18 +111,26 @@ async function stepUpload(options: SyncOptions): Promise { return null; } + const tmsUrl = options.tmsUrl; + const tmsToken = options.tmsToken; + const projectId = options.projectId; + console.log(chalk.blue('\n๐Ÿ“ค Step 2: Uploading to TMS...')); - await executeStep('upload to TMS', options.dryRun, async () => { - await uploadCommand({ - tmsUrl: options.tmsUrl!, - tmsToken: options.tmsToken!, - projectId: options.projectId!, - sourceFile: `${options.outputDir}/reference.json`, - targetLanguages: options.languages, - dryRun: false, + if (options.dryRun) { + simulateStep('upload to TMS'); + } else { + await executeStep('upload to TMS', async () => { + await uploadCommand({ + tmsUrl, + tmsToken, + projectId, + sourceFile: `${options.outputDir}/reference.json`, + targetLanguages: options.languages, + dryRun: false, + }); }); - }); + } return 'Upload'; } @@ -139,20 +153,28 @@ async function stepDownload(options: SyncOptions): Promise { return null; } + const tmsUrl = options.tmsUrl; + const tmsToken = options.tmsToken; + const projectId = options.projectId; + console.log(chalk.blue('\n๐Ÿ“ฅ Step 3: Downloading from TMS...')); - await executeStep('download from TMS', options.dryRun, async () => { - await downloadCommand({ - tmsUrl: options.tmsUrl!, - tmsToken: options.tmsToken!, - projectId: options.projectId!, - outputDir: options.outputDir, - languages: options.languages, - format: 'json', - includeCompleted: true, - includeDraft: false, + if (options.dryRun) { + simulateStep('download from TMS'); + } else { + await executeStep('download from TMS', async () => { + await downloadCommand({ + tmsUrl, + tmsToken, + projectId, + outputDir: options.outputDir, + languages: options.languages, + format: 'json', + includeCompleted: true, + includeDraft: false, + }); }); - }); + } return 'Download'; } @@ -168,16 +190,20 @@ async function stepDeploy(options: SyncOptions): Promise { console.log(chalk.blue('\n๐Ÿš€ Step 4: Deploying to application...')); - await executeStep('deploy to application', options.dryRun, async () => { - await deployCommand({ - sourceDir: options.outputDir, - targetDir: options.localesDir, - languages: options.languages, - format: 'json', - backup: true, - validate: true, + if (options.dryRun) { + simulateStep('deploy to application'); + } else { + await executeStep('deploy to application', async () => { + await deployCommand({ + sourceDir: options.outputDir, + targetDir: options.localesDir, + languages: options.languages, + format: 'json', + backup: true, + validate: true, + }); }); - }); + } return 'Deploy'; } @@ -233,29 +259,20 @@ export async function syncCommand(opts: OptionValues): Promise { }; try { - const steps: string[] = []; - const generateStep = await stepGenerate( options.sourceDir, options.outputDir, options.dryRun, ); - steps.push(generateStep); - - const uploadStep = await stepUpload(options); - if (uploadStep) { - steps.push(uploadStep); - } - - const downloadStep = await stepDownload(options); - if (downloadStep) { - steps.push(downloadStep); - } - - const deployStep = await stepDeploy(options); - if (deployStep) { - steps.push(deployStep); - } + + const allSteps = [ + generateStep, + await stepUpload(options), + await stepDownload(options), + await stepDeploy(options), + ]; + + const steps = allSteps.filter((step): step is string => Boolean(step)); displaySummary(steps, options); } catch (error) { diff --git a/workspaces/translations/packages/cli/src/commands/upload.ts b/workspaces/translations/packages/cli/src/commands/upload.ts index 9e303dcbd7..2120d09805 100644 --- a/workspaces/translations/packages/cli/src/commands/upload.ts +++ b/workspaces/translations/packages/cli/src/commands/upload.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import path from 'path'; +import path from 'node:path'; import fs from 'fs-extra'; import { OptionValues } from 'commander'; @@ -43,9 +43,15 @@ function detectRepoName(): string { ]); if (gitRepoUrl) { // Extract repo name from URL (handles both https and ssh formats) - const match = gitRepoUrl.match(/([^/]+?)(?:\.git)?$/); - if (match) { - return match[1]; + // Use a safer regex pattern to avoid ReDoS vulnerability + // Remove .git suffix first, then extract the last path segment + let repoName = gitRepoUrl.replace(/\.git$/, ''); + const lastSlashIndex = repoName.lastIndexOf('/'); + if (lastSlashIndex >= 0) { + repoName = repoName.substring(lastSlashIndex + 1); + } + if (repoName) { + return repoName; } } } catch { @@ -77,23 +83,17 @@ function generateUploadFileName( } /** - * Upload file using memsource CLI (matching the team's script approach) + * Create temporary file with custom name if needed */ -async function uploadWithMemsourceCLI( +async function prepareUploadFile( filePath: string, - projectId: string, - targetLanguages: string[], uploadFileName?: string, -): Promise<{ fileName: string; keyCount: number }> { - // Ensure file path is absolute +): Promise<{ fileToUpload: string; tempFile: string | null }> { const absoluteFilePath = path.resolve(filePath); - - // If a custom upload filename is provided, create a temporary copy with that name let fileToUpload = absoluteFilePath; let tempFile: string | null = null; if (uploadFileName && path.basename(absoluteFilePath) !== uploadFileName) { - // Create temporary directory and copy file with new name const tempDir = path.join(path.dirname(absoluteFilePath), '.i18n-temp'); await fs.ensureDir(tempDir); tempFile = path.join(tempDir, uploadFileName); @@ -104,117 +104,359 @@ async function uploadWithMemsourceCLI( ); } - // Check if memsource CLI is available + return { fileToUpload, tempFile }; +} + +/** + * Validate memsource CLI prerequisites + */ +function validateMemsourcePrerequisites(): void { if (!commandExists('memsource')) { throw new Error( 'memsource CLI not found. Please ensure memsource-cli is installed and ~/.memsourcerc is sourced.', ); } +} - // Build memsource job create command - // Format: memsource job create --project-id --target-langs ... --filenames - // Note: targetLangs is REQUIRED by memsource API - const args = ['job', 'create', '--project-id', projectId]; - - // Target languages should already be provided by the caller - // This function just uses them directly - const finalTargetLanguages = targetLanguages; - - if (finalTargetLanguages.length === 0) { +/** + * Build memsource job create command arguments + */ +function buildUploadCommandArgs( + projectId: string, + targetLanguages: string[], + fileToUpload: string, +): string[] { + if (targetLanguages.length === 0) { throw new Error( 'Target languages are required. Please specify --target-languages or configure them in .i18n.config.json', ); } - args.push('--target-langs', ...finalTargetLanguages); - args.push('--filenames', fileToUpload); + return [ + 'job', + 'create', + '--project-id', + projectId, + '--target-langs', + ...targetLanguages, + '--filenames', + fileToUpload, + ]; +} + +/** + * Extract error message from command execution error + */ +function extractErrorMessage(error: unknown): string { + if (!(error instanceof Error)) { + return 'Unknown error'; + } - // Execute memsource command - // Note: MEMSOURCE_TOKEN should be set from ~/.memsourcerc + let errorMessage = error.message; + + if ( + 'stderr' in error && + typeof (error as { stderr?: Buffer }).stderr === 'object' + ) { + const stderr = (error as { stderr: Buffer }).stderr; + if (stderr) { + const stderrText = stderr.toString('utf-8'); + if (stderrText) { + errorMessage = stderrText.trim(); + } + } + } + + return errorMessage; +} + +/** + * Count translation keys from file + */ +async function countKeysFromFile(filePath: string): Promise { try { - const output = safeExecSyncOrThrow('memsource', args, { - encoding: 'utf-8', - stdio: 'pipe', // Capture both stdout and stderr - env: { - ...process.env, - // Ensure MEMSOURCE_TOKEN is available (should be set from .memsourcerc) - }, - }); + const fileContent = await fs.readFile(filePath, 'utf-8'); + const data = JSON.parse(fileContent); + return countTranslationKeys(data); + } catch { + return 0; + } +} - // Log output if any - if (output && output.trim()) { - console.log(chalk.gray(` ${output.trim()}`)); +/** + * Clean up temporary file and directory + */ +async function cleanupTempFile(tempFile: string): Promise { + try { + if (await fs.pathExists(tempFile)) { + await fs.remove(tempFile); } - // Parse output to get job info if available - // For now, we'll estimate key count from the file - const fileContent = await fs.readFile(fileToUpload, 'utf-8'); - let keyCount = 0; - try { - const data = JSON.parse(fileContent); - keyCount = countTranslationKeys(data); - } catch { - // If parsing fails, use a default - keyCount = 0; + const tempDir = path.dirname(tempFile); + if (await fs.pathExists(tempDir)) { + const files = await fs.readdir(tempDir); + if (files.length === 0) { + await fs.remove(tempDir); + } } + } catch (cleanupError) { + console.warn( + chalk.yellow( + ` Warning: Failed to clean up temporary file: ${cleanupError}`, + ), + ); + } +} + +/** + * Execute memsource upload command + */ +async function executeMemsourceUpload( + args: string[], + fileToUpload: string, +): Promise { + const output = safeExecSyncOrThrow('memsource', args, { + encoding: 'utf-8', + stdio: 'pipe', + env: { ...process.env }, + }); + + const trimmed = output?.trim(); + if (trimmed) { + console.log(chalk.gray(` ${trimmed}`)); + } +} + +/** + * Upload file using memsource CLI (matching the team's script approach) + */ +async function uploadWithMemsourceCLI( + filePath: string, + projectId: string, + targetLanguages: string[], + uploadFileName?: string, +): Promise<{ fileName: string; keyCount: number }> { + validateMemsourcePrerequisites(); + + const absoluteFilePath = path.resolve(filePath); + const { fileToUpload, tempFile } = await prepareUploadFile( + filePath, + uploadFileName, + ); + + const args = buildUploadCommandArgs(projectId, targetLanguages, fileToUpload); - const result = { + try { + await executeMemsourceUpload(args, fileToUpload); + + const keyCount = await countKeysFromFile(fileToUpload); + + return { fileName: uploadFileName || path.basename(absoluteFilePath), keyCount, }; - - return result; } catch (error: unknown) { - // Extract error message from command execution error - let errorMessage = 'Unknown error'; - if (error instanceof Error) { - errorMessage = error.message; - // execSync errors include stderr in the message sometimes - if ( - 'stderr' in error && - typeof (error as { stderr?: Buffer }).stderr === 'object' - ) { - const stderr = (error as { stderr: Buffer }).stderr; - if (stderr) { - const stderrText = stderr.toString('utf-8'); - if (stderrText) { - errorMessage = stderrText.trim(); - } - } - } - } + const errorMessage = extractErrorMessage(error); throw new Error(`memsource CLI upload failed: ${errorMessage}`); } finally { - // Clean up temporary file if created (even on error) if (tempFile) { - try { - if (await fs.pathExists(tempFile)) { - await fs.remove(tempFile); - } - // Also remove temp directory if empty - const tempDir = path.dirname(tempFile); - if (await fs.pathExists(tempDir)) { - const files = await fs.readdir(tempDir); - if (files.length === 0) { - await fs.remove(tempDir); - } - } - } catch (cleanupError) { - // Log but don't fail on cleanup errors - console.warn( - chalk.yellow( - ` Warning: Failed to clean up temporary file: ${cleanupError}`, - ), - ); - } + await cleanupTempFile(tempFile); } } } +/** + * Extract and validate string values from merged options + */ +function extractStringOption(value: unknown): string | undefined { + return value && typeof value === 'string' ? value : undefined; +} + +/** + * Validate TMS configuration and return validated values + */ +function validateTmsConfig( + tmsUrl: unknown, + tmsToken: unknown, + projectId: unknown, +): { + tmsUrl: string; + tmsToken: string; + projectId: string; +} | null { + const tmsUrlStr = extractStringOption(tmsUrl); + const tmsTokenStr = extractStringOption(tmsToken); + const projectIdStr = extractStringOption(projectId); + + if (!tmsUrlStr || !tmsTokenStr || !projectIdStr) { + return null; + } + + return { tmsUrl: tmsUrlStr, tmsToken: tmsTokenStr, projectId: projectIdStr }; +} + +/** + * Display error message for missing TMS configuration + */ +function displayMissingConfigError( + tmsUrlStr?: string, + tmsTokenStr?: string, + projectIdStr?: string, +): void { + console.error(chalk.red('โŒ Missing required TMS configuration:')); + console.error(''); + + const missingConfigs = [ + { + value: tmsUrlStr, + label: 'TMS URL', + messages: [ + ' Set via: --tms-url or I18N_TMS_URL or .i18n.config.json', + ], + }, + { + value: tmsTokenStr, + label: 'TMS Token', + messages: [ + ' Primary: Source ~/.memsourcerc (sets MEMSOURCE_TOKEN)', + ' Fallback: --tms-token or I18N_TMS_TOKEN or ~/.i18n.auth.json', + ], + }, + { + value: projectIdStr, + label: 'Project ID', + messages: [ + ' Set via: --project-id or I18N_TMS_PROJECT_ID or .i18n.config.json', + ], + }, + ]; + + missingConfigs + .filter(item => !item.value) + .forEach(item => { + console.error(chalk.yellow(` โœ— ${item.label}`)); + item.messages.forEach(message => { + console.error(chalk.gray(message)); + }); + }); + + console.error(''); + console.error(chalk.blue('๐Ÿ“‹ Quick Setup Guide:')); + console.error(chalk.gray(' 1. Run: translations-cli i18n init')); + console.error(chalk.gray(' This creates .i18n.config.json')); + console.error(''); + console.error( + chalk.gray(' 2. Edit .i18n.config.json in your project root:'), + ); + console.error( + chalk.gray( + ' - Add your TMS URL (e.g., "https://cloud.memsource.com/web")', + ), + ); + console.error(chalk.gray(' - Add your Project ID')); + console.error(''); + console.error( + chalk.gray(' 3. Set up Memsource authentication (recommended):'), + ); + console.error( + chalk.gray(' - Run: translations-cli i18n setup-memsource'), + ); + console.error( + chalk.gray( + ' - Or manually create ~/.memsourcerc following localization team instructions', + ), + ); + console.error(chalk.gray(' - Then source it: source ~/.memsourcerc')); + console.error(''); + console.error( + chalk.gray( + ' OR use ~/.i18n.auth.json as fallback (run init to create it)', + ), + ); + console.error(''); + console.error( + chalk.gray(' See docs/i18n-commands.md for detailed instructions.'), + ); +} + +/** + * Validate source file exists and has valid format + */ +async function validateSourceFile(sourceFile: string): Promise { + if (!(await fs.pathExists(sourceFile))) { + throw new Error(`Source file not found: ${sourceFile}`); + } + + console.log(chalk.yellow(`๐Ÿ” Validating ${sourceFile}...`)); + const isValid = await validateTranslationFile(sourceFile); + if (!isValid) { + throw new Error(`Invalid translation file format: ${sourceFile}`); + } + + console.log(chalk.green(`โœ… Translation file is valid`)); +} + +/** + * Check file change status and display appropriate warnings + */ +async function checkFileChangeAndWarn( + sourceFile: string, + projectId: string, + tmsUrl: string, + finalUploadFileName: string, + force: boolean, + cachedEntry: + | { uploadedAt: string; uploadFileName?: string } + | null + | undefined, +): Promise { + if (force) { + console.log( + chalk.yellow(`โš ๏ธ Force upload enabled - skipping cache check`), + ); + return true; + } + + const fileChanged = await hasFileChanged(sourceFile, projectId, tmsUrl); + const sameFilename = cachedEntry?.uploadFileName === finalUploadFileName; + + if (!fileChanged && cachedEntry && sameFilename) { + console.log( + chalk.yellow( + `โ„น๏ธ File has not changed since last upload (${new Date( + cachedEntry.uploadedAt, + ).toLocaleString()})`, + ), + ); + console.log(chalk.gray(` Upload filename: ${finalUploadFileName}`)); + console.log(chalk.gray(` Skipping upload to avoid duplicate.`)); + console.log( + chalk.gray( + ` Use --force to upload anyway, or delete .i18n-cache to clear cache.`, + ), + ); + return false; + } + + if (!fileChanged && cachedEntry && !sameFilename) { + console.log( + chalk.yellow( + `โš ๏ธ File content unchanged, but upload filename differs from last upload:`, + ), + ); + console.log( + chalk.gray(` Last upload: ${cachedEntry.uploadFileName || 'unknown'}`), + ); + console.log(chalk.gray(` This upload: ${finalUploadFileName}`)); + console.log(chalk.gray(` This will create a new job in Memsource.`)); + } + + return true; +} + export async function uploadCommand(opts: OptionValues): Promise { console.log(chalk.blue('๐Ÿ“ค Uploading translation reference files to TMS...')); - // Load config and merge with options const config = await loadI18nConfig(); const mergedOpts = await mergeConfigWithOptions(config, opts); @@ -238,298 +480,213 @@ export async function uploadCommand(opts: OptionValues): Promise { force?: boolean; }; - // Validate required options - const tmsUrlStr = tmsUrl && typeof tmsUrl === 'string' ? tmsUrl : undefined; - const tmsTokenStr = - tmsToken && typeof tmsToken === 'string' ? tmsToken : undefined; - const projectIdStr = - projectId && typeof projectId === 'string' ? projectId : undefined; - const sourceFileStr = - sourceFile && typeof sourceFile === 'string' ? sourceFile : undefined; - - if (!tmsUrlStr || !tmsTokenStr || !projectIdStr) { - console.error(chalk.red('โŒ Missing required TMS configuration:')); - console.error(''); - - const missingItems: string[] = []; - if (!tmsUrlStr) { - missingItems.push('TMS URL'); - console.error(chalk.yellow(' โœ— TMS URL')); - console.error( - chalk.gray( - ' Set via: --tms-url or I18N_TMS_URL or .i18n.config.json', - ), - ); - } - if (!tmsTokenStr) { - missingItems.push('TMS Token'); - console.error(chalk.yellow(' โœ— TMS Token')); - console.error( - chalk.gray( - ' Primary: Source ~/.memsourcerc (sets MEMSOURCE_TOKEN)', - ), - ); - console.error( - chalk.gray( - ' Fallback: --tms-token or I18N_TMS_TOKEN or ~/.i18n.auth.json', - ), - ); - } - if (!projectIdStr) { - missingItems.push('Project ID'); - console.error(chalk.yellow(' โœ— Project ID')); - console.error( - chalk.gray( - ' Set via: --project-id or I18N_TMS_PROJECT_ID or .i18n.config.json', - ), - ); - } - - console.error(''); - console.error(chalk.blue('๐Ÿ“‹ Quick Setup Guide:')); - console.error(chalk.gray(' 1. Run: translations-cli i18n init')); - console.error(chalk.gray(' This creates .i18n.config.json')); - console.error(''); - console.error( - chalk.gray(' 2. Edit .i18n.config.json in your project root:'), - ); - console.error( - chalk.gray( - ' - Add your TMS URL (e.g., "https://cloud.memsource.com/web")', - ), - ); - console.error(chalk.gray(' - Add your Project ID')); - console.error(''); - console.error( - chalk.gray(' 3. Set up Memsource authentication (recommended):'), - ); - console.error( - chalk.gray(' - Run: translations-cli i18n setup-memsource'), - ); - console.error( - chalk.gray( - ' - Or manually create ~/.memsourcerc following localization team instructions', - ), - ); - console.error(chalk.gray(' - Then source it: source ~/.memsourcerc')); - console.error(''); - console.error( - chalk.gray( - ' OR use ~/.i18n.auth.json as fallback (run init to create it)', - ), - ); - console.error(''); - console.error( - chalk.gray(' See docs/i18n-commands.md for detailed instructions.'), - ); + const tmsConfig = validateTmsConfig(tmsUrl, tmsToken, projectId); + if (!tmsConfig) { + const tmsUrlStr = extractStringOption(tmsUrl); + const tmsTokenStr = extractStringOption(tmsToken); + const projectIdStr = extractStringOption(projectId); + displayMissingConfigError(tmsUrlStr, tmsTokenStr, projectIdStr); process.exit(1); } + const sourceFileStr = extractStringOption(sourceFile); if (!sourceFileStr) { console.error(chalk.red('โŒ Missing required option: --source-file')); process.exit(1); } try { - // Check if source file exists - if (!(await fs.pathExists(sourceFileStr))) { - throw new Error(`Source file not found: ${sourceFileStr}`); - } - - // Validate translation file format - console.log(chalk.yellow(`๐Ÿ” Validating ${sourceFileStr}...`)); - const isValid = await validateTranslationFile(sourceFileStr); - if (!isValid) { - throw new Error(`Invalid translation file format: ${sourceFileStr}`); - } + await validateSourceFile(sourceFileStr); - console.log(chalk.green(`โœ… Translation file is valid`)); - - // Generate upload filename const finalUploadFileName = uploadFileName && typeof uploadFileName === 'string' ? generateUploadFileName(sourceFileStr, uploadFileName) : generateUploadFileName(sourceFileStr); - // Get cached entry for display purposes const cachedEntry = await getCachedUpload( sourceFileStr, - projectIdStr, - tmsUrlStr, + tmsConfig.projectId, + tmsConfig.tmsUrl, ); - // Check if file has changed since last upload (unless --force is used) - if (!force) { - const fileChanged = await hasFileChanged( - sourceFileStr, - projectIdStr, - tmsUrlStr, - ); + const shouldProceed = await checkFileChangeAndWarn( + sourceFileStr, + tmsConfig.projectId, + tmsConfig.tmsUrl, + finalUploadFileName, + force, + cachedEntry, + ); - // Also check if we're uploading with the same filename that was already uploaded - const sameFilename = cachedEntry?.uploadFileName === finalUploadFileName; - - if (!fileChanged && cachedEntry && sameFilename) { - console.log( - chalk.yellow( - `โ„น๏ธ File has not changed since last upload (${new Date( - cachedEntry.uploadedAt, - ).toLocaleString()})`, - ), - ); - console.log(chalk.gray(` Upload filename: ${finalUploadFileName}`)); - console.log(chalk.gray(` Skipping upload to avoid duplicate.`)); - console.log( - chalk.gray( - ` Use --force to upload anyway, or delete .i18n-cache to clear cache.`, - ), - ); - return; - } else if (!fileChanged && cachedEntry && !sameFilename) { - // File content hasn't changed but upload filename is different - warn user - console.log( - chalk.yellow( - `โš ๏ธ File content unchanged, but upload filename differs from last upload:`, - ), - ); - console.log( - chalk.gray( - ` Last upload: ${cachedEntry.uploadFileName || 'unknown'}`, - ), - ); - console.log(chalk.gray(` This upload: ${finalUploadFileName}`)); - console.log(chalk.gray(` This will create a new job in Memsource.`)); - } - } else { - console.log( - chalk.yellow(`โš ๏ธ Force upload enabled - skipping cache check`), - ); + if (!shouldProceed) { + return; } if (dryRun) { - console.log( - chalk.yellow('๐Ÿ” Dry run mode - showing what would be uploaded:'), - ); - console.log(chalk.gray(` TMS URL: ${tmsUrlStr}`)); - console.log(chalk.gray(` Project ID: ${projectIdStr}`)); - console.log(chalk.gray(` Source file: ${sourceFileStr}`)); - console.log(chalk.gray(` Upload filename: ${finalUploadFileName}`)); - console.log( - chalk.gray( - ` Target languages: ${ - targetLanguages || 'All configured languages' - }`, - ), + simulateUpload( + tmsConfig.tmsUrl, + tmsConfig.projectId, + sourceFileStr, + finalUploadFileName, + targetLanguages, + cachedEntry, ); - if (cachedEntry) { - console.log( - chalk.gray( - ` Last uploaded: ${new Date( - cachedEntry.uploadedAt, - ).toLocaleString()}`, - ), - ); - } return; } - // Check if MEMSOURCE_TOKEN is available (should be set from ~/.memsourcerc) - if (!process.env.MEMSOURCE_TOKEN && !tmsTokenStr) { - console.error(chalk.red('โŒ MEMSOURCE_TOKEN not found in environment')); - console.error(chalk.yellow(' Please source ~/.memsourcerc first:')); - console.error(chalk.gray(' source ~/.memsourcerc')); - console.error( - chalk.gray(' Or set MEMSOURCE_TOKEN environment variable'), - ); - process.exit(1); - } + await performUpload( + tmsConfig.tmsUrl, + tmsConfig.tmsToken, + tmsConfig.projectId, + sourceFileStr, + finalUploadFileName, + targetLanguages, + force, + ); + } catch (error) { + console.error(chalk.red('โŒ Error uploading translation file:'), error); + throw error; + } +} - // Use memsource CLI for upload (matching team's script approach) +/** + * Simulate upload (show what would be uploaded) + */ +function simulateUpload( + tmsUrl: string, + projectId: string, + sourceFile: string, + uploadFileName: string, + targetLanguages?: string, + cachedEntry?: { uploadedAt: string; uploadFileName?: string } | null, +): void { + console.log( + chalk.yellow('๐Ÿ” Dry run mode - showing what would be uploaded:'), + ); + console.log(chalk.gray(` TMS URL: ${tmsUrl}`)); + console.log(chalk.gray(` Project ID: ${projectId}`)); + console.log(chalk.gray(` Source file: ${sourceFile}`)); + console.log(chalk.gray(` Upload filename: ${uploadFileName}`)); + console.log( + chalk.gray( + ` Target languages: ${targetLanguages || 'All configured languages'}`, + ), + ); + if (cachedEntry) { console.log( - chalk.yellow( - `๐Ÿ”— Using memsource CLI to upload to project ${projectIdStr}...`, + chalk.gray( + ` Last uploaded: ${new Date( + cachedEntry.uploadedAt, + ).toLocaleString()}`, ), ); + } +} - // Parse target languages - check config first if not provided via CLI - let languages: string[] = []; - if (targetLanguages && typeof targetLanguages === 'string') { - languages = targetLanguages - .split(',') - .map((lang: string) => lang.trim()) - .filter(Boolean); - } else if ( - config.languages && - Array.isArray(config.languages) && - config.languages.length > 0 - ) { - // Fallback to config languages - languages = config.languages; - console.log( - chalk.gray( - ` Using target languages from config: ${languages.join(', ')}`, - ), - ); - } +/** + * Perform actual upload + */ +async function performUpload( + tmsUrl: string, + tmsToken: string | undefined, + projectId: string, + sourceFile: string, + uploadFileName: string, + targetLanguages: string | undefined, + force: boolean, +): Promise { + // Check if MEMSOURCE_TOKEN is available (should be set from ~/.memsourcerc) + if (!process.env.MEMSOURCE_TOKEN && !tmsToken) { + console.error(chalk.red('โŒ MEMSOURCE_TOKEN not found in environment')); + console.error(chalk.yellow(' Please source ~/.memsourcerc first:')); + console.error(chalk.gray(' source ~/.memsourcerc')); + console.error(chalk.gray(' Or set MEMSOURCE_TOKEN environment variable')); + process.exit(1); + } - // Target languages are REQUIRED by memsource - if (languages.length === 0) { - console.error(chalk.red('โŒ Target languages are required')); - console.error(chalk.yellow(' Please specify one of:')); - console.error( - chalk.gray(' 1. --target-languages it (or other language codes)'), - ); - console.error( - chalk.gray(' 2. Add "languages": ["it"] to .i18n.config.json'), - ); - process.exit(1); - } + // Load config for language fallback + const config = await loadI18nConfig(); - // Upload using memsource CLI - console.log(chalk.yellow(`๐Ÿ“ค Uploading ${sourceFileStr}...`)); - console.log(chalk.gray(` Upload filename: ${finalUploadFileName}`)); - if (languages.length > 0) { - console.log(chalk.gray(` Target languages: ${languages.join(', ')}`)); - } + // Use memsource CLI for upload (matching team's script approach) + console.log( + chalk.yellow(`๐Ÿ”— Using memsource CLI to upload to project ${projectId}...`), + ); + + // Parse target languages - check config first if not provided via CLI + let languages: string[] = []; + if (targetLanguages && typeof targetLanguages === 'string') { + languages = targetLanguages + .split(',') + .map((lang: string) => lang.trim()) + .filter(Boolean); + } else if ( + config.languages && + Array.isArray(config.languages) && + config.languages.length > 0 + ) { + // Fallback to config languages + languages = config.languages; + console.log( + chalk.gray( + ` Using target languages from config: ${languages.join(', ')}`, + ), + ); + } - const uploadResult = await uploadWithMemsourceCLI( - sourceFileStr, - projectIdStr, - languages, - finalUploadFileName, // Pass the generated filename + // Target languages are REQUIRED by memsource + if (languages.length === 0) { + console.error(chalk.red('โŒ Target languages are required')); + console.error(chalk.yellow(' Please specify one of:')); + console.error( + chalk.gray(' 1. --target-languages it (or other language codes)'), + ); + console.error( + chalk.gray(' 2. Add "languages": ["it"] to .i18n.config.json'), ); + process.exit(1); + } - // Calculate key count for cache - const fileContent = await fs.readFile(sourceFileStr, 'utf-8'); - let keyCount = uploadResult.keyCount; - if (keyCount === 0) { - // Fallback: count keys from file - try { - const data = JSON.parse(fileContent); - keyCount = countTranslationKeys(data); - } catch { - // If parsing fails, use 0 - keyCount = 0; - } - } + // Upload using memsource CLI + console.log(chalk.yellow(`๐Ÿ“ค Uploading ${sourceFile}...`)); + console.log(chalk.gray(` Upload filename: ${uploadFileName}`)); + if (languages.length > 0) { + console.log(chalk.gray(` Target languages: ${languages.join(', ')}`)); + } - // Save upload cache (include upload filename to prevent duplicates with different names) - await saveUploadCache( - sourceFileStr, - projectIdStr, - tmsUrlStr, - keyCount, - finalUploadFileName, - ); + const uploadResult = await uploadWithMemsourceCLI( + sourceFile, + projectId, + languages, + uploadFileName, + ); - console.log(chalk.green(`โœ… Upload completed successfully!`)); - console.log(chalk.gray(` File: ${uploadResult.fileName}`)); - console.log(chalk.gray(` Keys: ${keyCount}`)); - if (languages.length > 0) { - console.log(chalk.gray(` Target languages: ${languages.join(', ')}`)); + // Calculate key count for cache + const fileContent = await fs.readFile(sourceFile, 'utf-8'); + let keyCount = uploadResult.keyCount; + if (keyCount === 0) { + // Fallback: count keys from file + try { + const data = JSON.parse(fileContent); + keyCount = countTranslationKeys(data); + } catch { + // If parsing fails, use 0 + keyCount = 0; } - } catch (error) { - console.error(chalk.red('โŒ Error uploading to TMS:'), error); - throw error; + } + + // Save upload cache (include upload filename to prevent duplicates with different names) + await saveUploadCache( + sourceFile, + projectId, + tmsUrl, + keyCount, + uploadFileName, + ); + + console.log(chalk.green(`โœ… Upload completed successfully!`)); + console.log(chalk.gray(` File: ${uploadResult.fileName}`)); + console.log(chalk.gray(` Keys: ${keyCount}`)); + if (languages.length > 0) { + console.log(chalk.gray(` Target languages: ${languages.join(', ')}`)); } } diff --git a/workspaces/translations/packages/cli/src/lib/errors.ts b/workspaces/translations/packages/cli/src/lib/errors.ts index b850de7032..68cdce01e2 100644 --- a/workspaces/translations/packages/cli/src/lib/errors.ts +++ b/workspaces/translations/packages/cli/src/lib/errors.ts @@ -40,7 +40,8 @@ export function exitWithError(error: Error): never { process.stderr.write(`\n${chalk.red(error.message)}\n\n`); process.exit(error.code); } else { - process.stderr.write(`\n${chalk.red(`${error}`)}\n\n`); + const errorMessage = String(error); + process.stderr.write(`\n${chalk.red(errorMessage)}\n\n`); process.exit(1); } } diff --git a/workspaces/translations/packages/cli/src/lib/i18n/analyzeStatus.ts b/workspaces/translations/packages/cli/src/lib/i18n/analyzeStatus.ts index 1b154fdf93..ab0efd5055 100644 --- a/workspaces/translations/packages/cli/src/lib/i18n/analyzeStatus.ts +++ b/workspaces/translations/packages/cli/src/lib/i18n/analyzeStatus.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import path from 'path'; +import path from 'node:path'; import fs from 'fs-extra'; import glob from 'glob'; diff --git a/workspaces/translations/packages/cli/src/lib/i18n/config.ts b/workspaces/translations/packages/cli/src/lib/i18n/config.ts index a7535122cc..ff70eb31ba 100644 --- a/workspaces/translations/packages/cli/src/lib/i18n/config.ts +++ b/workspaces/translations/packages/cli/src/lib/i18n/config.ts @@ -14,8 +14,8 @@ * limitations under the License. */ -import path from 'path'; -import os from 'os'; +import path from 'node:path'; +import os from 'node:os'; import { commandExists, safeExecSyncOrThrow } from '../utils/exec'; import fs from 'fs-extra'; @@ -195,16 +195,13 @@ export async function loadI18nConfig(): Promise { } /** - * Merge command options with config, command options take precedence - * This function is async because it may need to generate a token using memsource CLI + * Merge directory configuration from config to merged options */ -export async function mergeConfigWithOptions( +function mergeDirectoryConfig( config: I18nConfig, options: Record, -): Promise { - const merged: MergedOptions = {}; - - // Apply config defaults + merged: MergedOptions, +): void { if (config.directories?.sourceDir && !options.sourceDir) { merged.sourceDir = config.directories.sourceDir; } @@ -219,74 +216,133 @@ export async function mergeConfigWithOptions( merged.targetDir = config.directories.localesDir; merged.localesDir = config.directories.localesDir; } - if (config.tms?.url && !options.tmsUrl) { - merged.tmsUrl = config.tms.url; - } +} + +/** + * Check if this is a Memsource setup based on environment and config + */ +function isMemsourceSetup(config: I18nConfig): boolean { + return ( + Boolean(process.env.MEMSOURCE_URL) || + Boolean(process.env.MEMSOURCE_USERNAME) || + Boolean(config.tms?.url?.includes('memsource')) + ); +} - // Get token from auth config (personal only, not in project config) - // Priority: environment variable > config file > generate from username/password - // Note: If user sources .memsourcerc, MEMSOURCE_TOKEN will be in environment and used first +/** + * Generate or retrieve TMS token from config + */ +async function getTmsToken( + config: I18nConfig, + options: Record, +): Promise { let token = config.auth?.tms?.token; - // Only generate token if: - // 1. Token is not already set - // 2. Username and password are available - // 3. Not provided via command-line option - // 4. Memsource CLI is likely available (user is using memsource workflow) - if ( + const shouldGenerateToken = !token && - config.auth?.tms?.username && - config.auth?.tms?.password && - !options.tmsToken - ) { - // Check if this looks like a Memsource setup (has MEMSOURCE_URL or username suggests memsource) - const isMemsourceSetup = - process.env.MEMSOURCE_URL || - process.env.MEMSOURCE_USERNAME || - config.tms?.url?.includes('memsource'); - - if (isMemsourceSetup) { - // For Memsource, prefer using .memsourcerc workflow - // Only generate if memsource CLI is available and token generation is needed - token = await generateMemsourceToken( - config.auth.tms.username, - config.auth.tms.password, - ); - } + Boolean(config.auth?.tms?.username) && + Boolean(config.auth?.tms?.password) && + !options.tmsToken; + + if (shouldGenerateToken && isMemsourceSetup(config)) { + token = await generateMemsourceToken( + config.auth!.tms!.username!, + config.auth!.tms!.password!, + ); } - if (token && !options.tmsToken) { + return token && !options.tmsToken ? token : undefined; +} + +/** + * Merge authentication configuration from config to merged options + */ +async function mergeAuthConfig( + config: I18nConfig, + options: Record, + merged: MergedOptions, +): Promise { + const token = await getTmsToken(config, options); + if (token) { merged.tmsToken = token; } - // Get username/password from auth config if (config.auth?.tms?.username && !options.tmsUsername) { merged.tmsUsername = config.auth.tms.username; } if (config.auth?.tms?.password && !options.tmsPassword) { merged.tmsPassword = config.auth.tms.password; } +} + +/** + * Merge TMS configuration from config to merged options + */ +function mergeTmsConfig( + config: I18nConfig, + options: Record, + merged: MergedOptions, +): void { + if (config.tms?.url && !options.tmsUrl) { + merged.tmsUrl = config.tms.url; + } if (config.tms?.projectId && !options.projectId) { merged.projectId = config.tms.projectId; } +} + +/** + * Merge language and format configuration from config to merged options + */ +function mergeLanguageAndFormatConfig( + config: I18nConfig, + options: Record, + merged: MergedOptions, +): void { if (config.languages && !options.languages && !options.targetLanguages) { - merged.languages = config.languages.join(','); - merged.targetLanguages = config.languages.join(','); + const languagesStr = config.languages.join(','); + merged.languages = languagesStr; + merged.targetLanguages = languagesStr; } if (config.format && !options.format) { merged.format = config.format; } +} + +/** + * Merge pattern configuration from config to merged options + */ +function mergePatternConfig( + config: I18nConfig, + options: Record, + merged: MergedOptions, +): void { if (config.patterns?.include && !options.includePattern) { merged.includePattern = config.patterns.include; } if (config.patterns?.exclude && !options.excludePattern) { merged.excludePattern = config.patterns.exclude; } +} + +/** + * Merge command options with config, command options take precedence + * This function is async because it may need to generate a token using memsource CLI + */ +export async function mergeConfigWithOptions( + config: I18nConfig, + options: Record, +): Promise { + const merged: MergedOptions = {}; + + mergeDirectoryConfig(config, options, merged); + mergeTmsConfig(config, options, merged); + await mergeAuthConfig(config, options, merged); + mergeLanguageAndFormatConfig(config, options, merged); + mergePatternConfig(config, options, merged); // Command options override config - // Ensure we always return a Promise (async function always returns Promise) - const result = { ...merged, ...options }; - return Promise.resolve(result); + return { ...merged, ...options }; } /** diff --git a/workspaces/translations/packages/cli/src/lib/i18n/deployFiles.ts b/workspaces/translations/packages/cli/src/lib/i18n/deployFiles.ts index 08af53e33a..536f0c3ce6 100644 --- a/workspaces/translations/packages/cli/src/lib/i18n/deployFiles.ts +++ b/workspaces/translations/packages/cli/src/lib/i18n/deployFiles.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import path from 'path'; +import path from 'node:path'; import fs from 'fs-extra'; @@ -77,9 +77,9 @@ async function deployPoFile( // PO file header lines.push('msgid ""'); lines.push('msgstr ""'); - lines.push('"Content-Type: text/plain; charset=UTF-8\\n"'); - lines.push(`"Generated: ${new Date().toISOString()}\\n"`); - lines.push(`"Total-Keys: ${Object.keys(data).length}\\n"`); + lines.push(String.raw`"Content-Type: text/plain; charset=UTF-8\n"`); + lines.push(String.raw`"Generated: ${new Date().toISOString()}\n"`); + lines.push(String.raw`"Total-Keys: ${Object.keys(data).length}\n"`); lines.push(''); // Translation entries @@ -97,9 +97,9 @@ async function deployPoFile( */ function escapePoString(str: string): string { return str - .replace(/\\/g, '\\\\') - .replace(/"/g, '\\"') - .replace(/\n/g, '\\n') - .replace(/\r/g, '\\r') - .replace(/\t/g, '\\t'); + .replaceAll(/\\/g, '\\\\') + .replaceAll(/"/g, '\\"') + .replaceAll(/\n/g, '\\n') + .replaceAll(/\r/g, '\\r') + .replaceAll(/\t/g, '\\t'); } diff --git a/workspaces/translations/packages/cli/src/lib/i18n/extractKeys.ts b/workspaces/translations/packages/cli/src/lib/i18n/extractKeys.ts index 58d153a849..d5467a04a7 100644 --- a/workspaces/translations/packages/cli/src/lib/i18n/extractKeys.ts +++ b/workspaces/translations/packages/cli/src/lib/i18n/extractKeys.ts @@ -45,241 +45,332 @@ export function extractTranslationKeys( // Extract from exported object literals (Backstage translation ref pattern) // Pattern: export const messages = { key: 'value', nested: { key: 'value' } } // Also handles type assertions: { ... } as any + + /** + * Unwrap type assertion expressions (e.g., { ... } as any) + */ + const unwrapTypeAssertion = (node: ts.Node): ts.Node => { + return ts.isAsExpression(node) ? node.expression : node; + }; + + /** + * Extract key name from property name (identifier or string literal) + */ + const extractPropertyKeyName = ( + propertyName: ts.PropertyName, + ): string | null => { + if (ts.isIdentifier(propertyName)) { + return propertyName.text; + } + if (ts.isStringLiteral(propertyName)) { + return propertyName.text; + } + return null; + }; + + /** + * Extract translation keys from an object literal expression + */ const extractFromObjectLiteral = (node: ts.Node, prefix = ''): void => { - // Handle type assertions: { ... } as any - let objectNode: ts.Node = node; - if (ts.isAsExpression(node)) { - objectNode = node.expression; + const objectNode = unwrapTypeAssertion(node); + + if (!ts.isObjectLiteralExpression(objectNode)) { + return; } - if (ts.isObjectLiteralExpression(objectNode)) { - for (const property of objectNode.properties) { - if (ts.isPropertyAssignment(property) && property.name) { - let keyName = ''; - if (ts.isIdentifier(property.name)) { - keyName = property.name.text; - } else if (ts.isStringLiteral(property.name)) { - keyName = property.name.text; - } - - if (keyName) { - const fullKey = prefix ? `${prefix}.${keyName}` : keyName; - - // Handle type assertions in property initializers too - let initializer = property.initializer; - if (ts.isAsExpression(initializer)) { - initializer = initializer.expression; - } - - if (ts.isStringLiteral(initializer)) { - // Leaf node - this is a translation value - keys[fullKey] = initializer.text; - } else if (ts.isObjectLiteralExpression(initializer)) { - // Nested object - recurse - extractFromObjectLiteral(initializer, fullKey); - } - } - } + for (const property of objectNode.properties) { + if (!ts.isPropertyAssignment(property) || !property.name) { + continue; + } + + const keyName = extractPropertyKeyName(property.name); + if (!keyName) { + continue; + } + + const fullKey = prefix ? `${prefix}.${keyName}` : keyName; + const initializer = property.initializer; + if (!initializer) { + continue; + } + + const unwrappedInitializer = unwrapTypeAssertion(initializer); + + if (ts.isStringLiteral(unwrappedInitializer)) { + // Leaf node - this is a translation value + keys[fullKey] = unwrappedInitializer.text; + } else if (ts.isObjectLiteralExpression(unwrappedInitializer)) { + // Nested object - recurse + extractFromObjectLiteral(unwrappedInitializer, fullKey); } } }; - // Visit all nodes in the AST - const visit = (node: ts.Node) => { - // Look for createTranslationRef calls with messages property - // Pattern: createTranslationRef({ id: '...', messages: { key: 'value' } }) + /** + * Extract messages from object literal property + */ + const extractMessagesFromProperty = ( + property: ts.ObjectLiteralElementLike, + propertyName: string, + ): void => { if ( - ts.isCallExpression(node) && - ts.isIdentifier(node.expression) && - node.expression.text === 'createTranslationRef' + !ts.isPropertyAssignment(property) || + !ts.isIdentifier(property.name) || + property.name.text !== propertyName ) { - const args = node.arguments; - if (args.length > 0 && ts.isObjectLiteralExpression(args[0])) { - // Find the 'messages' property in the object literal - for (const property of args[0].properties) { - if ( - ts.isPropertyAssignment(property) && - ts.isIdentifier(property.name) && - property.name.text === 'messages' - ) { - // Handle type assertions: { ... } as any - let messagesNode = property.initializer; - if (ts.isAsExpression(messagesNode)) { - messagesNode = messagesNode.expression; - } - - if (ts.isObjectLiteralExpression(messagesNode)) { - // Extract keys from the messages object - extractFromObjectLiteral(messagesNode); - } - } - } - } + return; } - // Look for createTranslationResource calls - // Pattern: createTranslationResource({ ref: ..., translations: { ... } }) - // Note: Most files using this don't contain keys directly, but we check anyway - if ( - ts.isCallExpression(node) && - ts.isIdentifier(node.expression) && - node.expression.text === 'createTranslationResource' - ) { - const args = node.arguments; - if (args.length > 0 && ts.isObjectLiteralExpression(args[0])) { - // Look for any object literals in the arguments that might contain keys - // Most createTranslationResource calls just set up imports, but check for direct keys - for (const property of args[0].properties) { - if (ts.isPropertyAssignment(property)) { - // If there's a 'translations' property with an object literal, extract from it - if ( - ts.isIdentifier(property.name) && - property.name.text === 'translations' && - ts.isObjectLiteralExpression(property.initializer) - ) { - extractFromObjectLiteral(property.initializer); - } - } - } - } + const messagesNode = unwrapTypeAssertion(property.initializer); + if (ts.isObjectLiteralExpression(messagesNode)) { + extractFromObjectLiteral(messagesNode); } + }; - // Look for createTranslationMessages calls - // Pattern: createTranslationMessages({ ref: ..., messages: { key: 'value' } }) - // Also handles: messages: { ... } as any - if ( - ts.isCallExpression(node) && - ts.isIdentifier(node.expression) && - node.expression.text === 'createTranslationMessages' - ) { - const args = node.arguments; - if (args.length > 0 && ts.isObjectLiteralExpression(args[0])) { - // Find the 'messages' property in the object literal - for (const property of args[0].properties) { - if ( - ts.isPropertyAssignment(property) && - ts.isIdentifier(property.name) && - property.name.text === 'messages' - ) { - // Handle type assertions: { ... } as any - let messagesNode = property.initializer; - if (ts.isAsExpression(messagesNode)) { - messagesNode = messagesNode.expression; - } - - if (ts.isObjectLiteralExpression(messagesNode)) { - // Extract keys from the messages object - extractFromObjectLiteral(messagesNode); - } - } - } - } + /** + * Extract from createTranslationRef calls + * Pattern: createTranslationRef({ id: '...', messages: { key: 'value' } }) + */ + const extractFromCreateTranslationRef = (node: ts.CallExpression): void => { + const args = node.arguments; + if (args.length === 0 || !ts.isObjectLiteralExpression(args[0])) { + return; } - // Look for exported const declarations with object literals (Backstage pattern) - // Pattern: export const messages = { ... } - if (ts.isVariableStatement(node)) { - for (const declaration of node.declarationList.declarations) { - if ( - declaration.initializer && - ts.isObjectLiteralExpression(declaration.initializer) - ) { - // Check if it's exported and has a name suggesting it's a messages object - const isExported = node.modifiers?.some( - m => m.kind === ts.SyntaxKind.ExportKeyword, - ); - const varName = ts.isIdentifier(declaration.name) - ? declaration.name.text - : ''; - if ( - isExported && - (varName.includes('Messages') || - varName.includes('messages') || - varName.includes('translations')) - ) { - extractFromObjectLiteral(declaration.initializer); - } - } + for (const property of args[0].properties) { + extractMessagesFromProperty(property, 'messages'); + } + }; + + /** + * Extract from createTranslationResource calls + * Pattern: createTranslationResource({ ref: ..., translations: { ... } }) + */ + const extractFromCreateTranslationResource = ( + node: ts.CallExpression, + ): void => { + const args = node.arguments; + if (args.length === 0 || !ts.isObjectLiteralExpression(args[0])) { + return; + } + + for (const property of args[0].properties) { + if ( + ts.isPropertyAssignment(property) && + ts.isIdentifier(property.name) && + property.name.text === 'translations' && + ts.isObjectLiteralExpression(property.initializer) + ) { + extractFromObjectLiteral(property.initializer); } } + }; + + /** + * Extract from createTranslationMessages calls + * Pattern: createTranslationMessages({ ref: ..., messages: { key: 'value' } }) + */ + const extractFromCreateTranslationMessages = ( + node: ts.CallExpression, + ): void => { + const args = node.arguments; + if (args.length === 0 || !ts.isObjectLiteralExpression(args[0])) { + return; + } - // Look for t() function calls - if ( - ts.isCallExpression(node) && - ts.isIdentifier(node.expression) && - node.expression.text === 't' - ) { - const args = node.arguments; - if (args.length > 0 && ts.isStringLiteral(args[0])) { - const key = args[0].text; - const value = - args.length > 1 && ts.isStringLiteral(args[1]) ? args[1].text : key; - keys[key] = value; + for (const property of args[0].properties) { + extractMessagesFromProperty(property, 'messages'); + } + }; + + /** + * Check if variable name suggests it's a messages object + */ + const isMessagesVariableName = (varName: string): boolean => { + return ( + varName.includes('Messages') || + varName.includes('messages') || + varName.includes('translations') + ); + }; + + /** + * Extract from exported const declarations + * Pattern: export const messages = { ... } + */ + const extractFromVariableStatement = (node: ts.VariableStatement): void => { + const isExported = node.modifiers?.some( + m => m.kind === ts.SyntaxKind.ExportKeyword, + ); + + if (!isExported) { + return; + } + + for (const declaration of node.declarationList.declarations) { + if ( + !declaration.initializer || + !ts.isObjectLiteralExpression(declaration.initializer) + ) { + continue; + } + + const varName = ts.isIdentifier(declaration.name) + ? declaration.name.text + : ''; + + if (isMessagesVariableName(varName)) { + extractFromObjectLiteral(declaration.initializer); } } + }; + + /** + * Extract key-value pair from translation function call + */ + const extractFromTranslationCall = ( + args: ts.NodeArray, + ): void => { + if (args.length === 0 || !ts.isStringLiteral(args[0])) { + return; + } - // Look for i18n.t() method calls - if ( - ts.isCallExpression(node) && + const key = args[0].text; + const value = + args.length > 1 && ts.isStringLiteral(args[1]) ? args[1].text : key; + keys[key] = value; + }; + + /** + * Extract from t() function calls + */ + const extractFromTFunction = (node: ts.CallExpression): void => { + extractFromTranslationCall(node.arguments); + }; + + /** + * Check if node is i18n.t() call + */ + const isI18nTCall = (node: ts.CallExpression): boolean => { + return ( ts.isPropertyAccessExpression(node.expression) && ts.isIdentifier(node.expression.expression) && node.expression.expression.text === 'i18n' && ts.isIdentifier(node.expression.name) && node.expression.name.text === 't' - ) { - const args = node.arguments; - if (args.length > 0 && ts.isStringLiteral(args[0])) { - const key = args[0].text; - const value = - args.length > 1 && ts.isStringLiteral(args[1]) ? args[1].text : key; - keys[key] = value; - } + ); + }; + + /** + * Extract from i18n.t() method calls + */ + const extractFromI18nT = (node: ts.CallExpression): void => { + if (!isI18nTCall(node)) { + return; } + extractFromTranslationCall(node.arguments); + }; - // Look for useTranslation hook usage - if ( - ts.isCallExpression(node) && - ts.isPropertyAccessExpression(node.expression) && - ts.isCallExpression(node.expression.expression) && - ts.isIdentifier(node.expression.expression.expression) && - node.expression.expression.expression.text === 'useTranslation' && - ts.isIdentifier(node.expression.name) && - node.expression.name.text === 't' - ) { - const args = node.arguments; - if (args.length > 0 && ts.isStringLiteral(args[0])) { - const key = args[0].text; - const value = - args.length > 1 && ts.isStringLiteral(args[1]) ? args[1].text : key; - keys[key] = value; - } + /** + * Check if node is useTranslation().t() call + */ + const isUseTranslationTCall = (node: ts.CallExpression): boolean => { + if (!ts.isPropertyAccessExpression(node.expression)) { + return false; + } + + const propertyAccess = node.expression; + if (!ts.isCallExpression(propertyAccess.expression)) { + return false; + } + + const innerCall = propertyAccess.expression; + return ( + ts.isIdentifier(innerCall.expression) && + innerCall.expression.text === 'useTranslation' && + ts.isIdentifier(propertyAccess.name) && + propertyAccess.name.text === 't' + ); + }; + + /** + * Extract from useTranslation().t() calls + */ + const extractFromUseTranslationT = (node: ts.CallExpression): void => { + if (!isUseTranslationTCall(node)) { + return; + } + extractFromTranslationCall(node.arguments); + }; + + /** + * Extract from JSX Trans component + */ + const extractFromJsxTrans = ( + node: ts.JsxElement | ts.JsxSelfClosingElement, + ): void => { + const tagName = ts.isJsxElement(node) + ? node.openingElement.tagName + : node.tagName; + + if (!ts.isIdentifier(tagName) || tagName.text !== 'Trans') { + return; + } + + const attributes = ts.isJsxElement(node) + ? node.openingElement.attributes + : node.attributes; + + if (!ts.isJsxAttributes(attributes)) { + return; } - // Look for translation key patterns in JSX - if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) { - const tagName = ts.isJsxElement(node) - ? node.openingElement.tagName - : node.tagName; - if (ts.isIdentifier(tagName) && tagName.text === 'Trans') { - // Handle react-i18next Trans component - const attributes = ts.isJsxElement(node) - ? node.openingElement.attributes - : node.attributes; - if (ts.isJsxAttributes(attributes)) { - attributes.properties.forEach((attr: any) => { - if ( - ts.isJsxAttribute(attr) && - ts.isIdentifier(attr.name) && - attr.name.text === 'i18nKey' && - attr.initializer && - ts.isStringLiteral(attr.initializer) - ) { - const key = attr.initializer.text; - keys[key] = key; // Default value is the key itself - } - }); - } + attributes.properties.forEach((attr: any) => { + if ( + ts.isJsxAttribute(attr) && + ts.isIdentifier(attr.name) && + attr.name.text === 'i18nKey' && + attr.initializer && + ts.isStringLiteral(attr.initializer) + ) { + const key = attr.initializer.text; + keys[key] = key; } + }); + }; + + /** + * Check if node is a call expression with specific function name + */ + const isCallExpressionWithName = ( + node: ts.Node, + functionName: string, + ): node is ts.CallExpression => { + return ( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === functionName + ); + }; + + // Visit all nodes in the AST + const visit = (node: ts.Node) => { + if (isCallExpressionWithName(node, 'createTranslationRef')) { + extractFromCreateTranslationRef(node); + } else if (isCallExpressionWithName(node, 'createTranslationResource')) { + extractFromCreateTranslationResource(node); + } else if (isCallExpressionWithName(node, 'createTranslationMessages')) { + extractFromCreateTranslationMessages(node); + } else if (ts.isVariableStatement(node)) { + extractFromVariableStatement(node); + } else if (isCallExpressionWithName(node, 't')) { + extractFromTFunction(node); + } else if (ts.isCallExpression(node) && isI18nTCall(node)) { + extractFromI18nT(node); + } else if (ts.isCallExpression(node) && isUseTranslationTCall(node)) { + extractFromUseTranslationT(node); + } else if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) { + extractFromJsxTrans(node); } // Recursively visit child nodes @@ -304,15 +395,22 @@ function extractKeysWithRegex(content: string): Record { // Common patterns for translation keys // Note: createTranslationMessages pattern is handled by AST parser above // This regex fallback is for non-TypeScript files or when AST parsing fails + // Split patterns to avoid nested optional groups that cause ReDoS vulnerabilities const patterns = [ - // t('key', 'value') - /t\s*\(\s*['"`]([^'"`]+)['"`]\s*(?:,\s*['"`]([^'"`]*)['"`])?\s*\)/g, - // i18n.t('key', 'value') - /i18n\s*\.\s*t\s*\(\s*['"`]([^'"`]+)['"`]\s*(?:,\s*['"`]([^'"`]*)['"`])?\s*\)/g, - // useTranslation().t('key', 'value') - /useTranslation\s*\(\s*\)\s*\.\s*t\s*\(\s*['"`]([^'"`]+)['"`]\s*(?:,\s*['"`]([^'"`]*)['"`])?\s*\)/g, + // t('key', 'value') - with second parameter + /t\s*\(\s*['"`]([^'"`]+?)['"`]\s*,\s*['"`]([^'"`]*?)['"`]\s*\)/g, + // t('key') - without second parameter + /t\s*\(\s*['"`]([^'"`]+?)['"`]\s*\)/g, + // i18n.t('key', 'value') - with second parameter + /i18n\s*\.\s*t\s*\(\s*['"`]([^'"`]+?)['"`]\s*,\s*['"`]([^'"`]*?)['"`]\s*\)/g, + // i18n.t('key') - without second parameter + /i18n\s*\.\s*t\s*\(\s*['"`]([^'"`]+?)['"`]\s*\)/g, + // useTranslation().t('key', 'value') - with second parameter + /useTranslation\s*\(\s*\)\s*\.\s*t\s*\(\s*['"`]([^'"`]+?)['"`]\s*,\s*['"`]([^'"`]*?)['"`]\s*\)/g, + // useTranslation().t('key') - without second parameter + /useTranslation\s*\(\s*\)\s*\.\s*t\s*\(\s*['"`]([^'"`]+?)['"`]\s*\)/g, // Trans i18nKey="key" - /i18nKey\s*=\s*['"`]([^'"`]+)['"`]/g, + /i18nKey\s*=\s*['"`]([^'"`]+?)['"`]/g, ]; for (const pattern of patterns) { diff --git a/workspaces/translations/packages/cli/src/lib/i18n/formatReport.ts b/workspaces/translations/packages/cli/src/lib/i18n/formatReport.ts index c553120213..fa0a5149d5 100644 --- a/workspaces/translations/packages/cli/src/lib/i18n/formatReport.ts +++ b/workspaces/translations/packages/cli/src/lib/i18n/formatReport.ts @@ -37,95 +37,134 @@ export async function formatStatusReport( } /** - * Format table report + * Add header section to report */ -function formatTableReport( - status: TranslationStatus, - includeStats: boolean, -): string { - const lines: string[] = []; - - // Header +function addHeader(lines: string[]): void { lines.push(chalk.blue('๐Ÿ“Š Translation Status Report')); lines.push(chalk.gray('โ•'.repeat(50))); +} - // Summary +/** + * Add summary section to report + */ +function addSummary(lines: string[], status: TranslationStatus): void { lines.push(chalk.yellow('\n๐Ÿ“ˆ Summary:')); lines.push(` Total Keys: ${status.totalKeys}`); lines.push(` Languages: ${status.languages.length}`); lines.push(` Overall Completion: ${status.overallCompletion.toFixed(1)}%`); +} - // Language breakdown - if (status.languages.length > 0) { - lines.push(chalk.yellow('\n๐ŸŒ Language Status:')); - lines.push(chalk.gray(' Language | Translated | Total | Completion')); - lines.push(chalk.gray(' โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€')); - - for (const language of status.languages) { - const stats = status.languageStats[language]; - const completion = stats.completion.toFixed(1); - const completionBar = getCompletionBar(stats.completion); - - lines.push( - ` ${language.padEnd(12)} | ${stats.translated - .toString() - .padStart(10)} | ${stats.total - .toString() - .padStart(5)} | ${completion.padStart(8)}% ${completionBar}`, - ); - } +/** + * Add language breakdown section to report + */ +function addLanguageBreakdown( + lines: string[], + status: TranslationStatus, +): void { + if (status.languages.length === 0) { + return; } - // Missing keys - if (status.missingKeys.length > 0) { - lines.push(chalk.red(`\nโŒ Missing Keys (${status.missingKeys.length}):`)); - for (const key of status.missingKeys.slice(0, 10)) { - lines.push(chalk.gray(` ${key}`)); - } - if (status.missingKeys.length > 10) { - lines.push( - chalk.gray(` ... and ${status.missingKeys.length - 10} more`), - ); - } + lines.push(chalk.yellow('\n๐ŸŒ Language Status:')); + lines.push(chalk.gray(' Language | Translated | Total | Completion')); + lines.push(chalk.gray(' โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€')); + + for (const language of status.languages) { + const stats = status.languageStats[language]; + const completion = stats.completion.toFixed(1); + const completionBar = getCompletionBar(stats.completion); + + lines.push( + ` ${language.padEnd(12)} | ${stats.translated + .toString() + .padStart(10)} | ${stats.total + .toString() + .padStart(5)} | ${completion.padStart(8)}% ${completionBar}`, + ); } +} - // Extra keys +/** + * Add missing keys section to report + */ +function addMissingKeys(lines: string[], status: TranslationStatus): void { + if (status.missingKeys.length === 0) { + return; + } + + lines.push(chalk.red(`\nโŒ Missing Keys (${status.missingKeys.length}):`)); + for (const key of status.missingKeys.slice(0, 10)) { + lines.push(chalk.gray(` ${key}`)); + } + if (status.missingKeys.length > 10) { + lines.push(chalk.gray(` ... and ${status.missingKeys.length - 10} more`)); + } +} + +/** + * Add extra keys section to report + */ +function addExtraKeys(lines: string[], status: TranslationStatus): void { const languagesWithExtraKeys = status.languages.filter( lang => status.extraKeys[lang] && status.extraKeys[lang].length > 0, ); - if (languagesWithExtraKeys.length > 0) { - lines.push(chalk.yellow(`\nโš ๏ธ Extra Keys:`)); - for (const language of languagesWithExtraKeys) { - const extraKeys = status.extraKeys[language]; - lines.push(chalk.gray(` ${language}: ${extraKeys.length} extra keys`)); - for (const key of extraKeys.slice(0, 5)) { - lines.push(chalk.gray(` ${key}`)); - } - if (extraKeys.length > 5) { - lines.push(chalk.gray(` ... and ${extraKeys.length - 5} more`)); - } + if (languagesWithExtraKeys.length === 0) { + return; + } + + lines.push(chalk.yellow(`\nโš ๏ธ Extra Keys:`)); + for (const language of languagesWithExtraKeys) { + const extraKeys = status.extraKeys[language]; + lines.push(chalk.gray(` ${language}: ${extraKeys.length} extra keys`)); + for (const key of extraKeys.slice(0, 5)) { + lines.push(chalk.gray(` ${key}`)); + } + if (extraKeys.length > 5) { + lines.push(chalk.gray(` ... and ${extraKeys.length - 5} more`)); } } +} - // Detailed stats - if (includeStats) { - lines.push(chalk.yellow('\n๐Ÿ“Š Detailed Statistics:')); - lines.push(` Source Files: ${status.sourceFiles.length}`); - lines.push( - ` Total Translations: ${status.languages.reduce( - (sum, lang) => sum + (status.languageStats[lang]?.translated || 0), +/** + * Add detailed statistics section to report + */ +function addDetailedStats(lines: string[], status: TranslationStatus): void { + lines.push(chalk.yellow('\n๐Ÿ“Š Detailed Statistics:')); + lines.push(` Source Files: ${status.sourceFiles.length}`); + lines.push( + ` Total Translations: ${status.languages.reduce( + (sum, lang) => sum + (status.languageStats[lang]?.translated || 0), + 0, + )}`, + ); + lines.push( + ` Average Completion: ${( + status.languages.reduce( + (sum, lang) => sum + (status.languageStats[lang]?.completion || 0), 0, - )}`, - ); - lines.push( - ` Average Completion: ${( - status.languages.reduce( - (sum, lang) => sum + (status.languageStats[lang]?.completion || 0), - 0, - ) / status.languages.length - ).toFixed(1)}%`, - ); + ) / status.languages.length + ).toFixed(1)}%`, + ); +} + +/** + * Format table report + */ +function formatTableReport( + status: TranslationStatus, + includeStats: boolean, +): string { + const lines: string[] = []; + + addHeader(lines); + addSummary(lines, status); + addLanguageBreakdown(lines, status); + addMissingKeys(lines, status); + addExtraKeys(lines, status); + + if (includeStats) { + addDetailedStats(lines, status); } return lines.join('\n'); diff --git a/workspaces/translations/packages/cli/src/lib/i18n/generateFiles.ts b/workspaces/translations/packages/cli/src/lib/i18n/generateFiles.ts index 83d0650fda..8e16b5a715 100644 --- a/workspaces/translations/packages/cli/src/lib/i18n/generateFiles.ts +++ b/workspaces/translations/packages/cli/src/lib/i18n/generateFiles.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import path from 'path'; +import path from 'node:path'; import fs from 'fs-extra'; @@ -81,10 +81,10 @@ async function generateJsonFile( // This ensures compatibility with TMS systems that might not handle Unicode quotes well const normalizeValue = (value: string): string => { return value - .replace(/'/g, "'") // U+2018 LEFT SINGLE QUOTATION MARK โ†’ U+0027 APOSTROPHE - .replace(/'/g, "'") // U+2019 RIGHT SINGLE QUOTATION MARK โ†’ U+0027 APOSTROPHE - .replace(/"/g, '"') // U+201C LEFT DOUBLE QUOTATION MARK โ†’ U+0022 QUOTATION MARK - .replace(/"/g, '"'); // U+201D RIGHT DOUBLE QUOTATION MARK โ†’ U+0022 QUOTATION MARK + .replaceAll(/'/g, "'") // U+2018 LEFT SINGLE QUOTATION MARK โ†’ U+0027 APOSTROPHE + .replaceAll(/'/g, "'") // U+2019 RIGHT SINGLE QUOTATION MARK โ†’ U+0027 APOSTROPHE + .replaceAll(/"/g, '"') // U+201C LEFT DOUBLE QUOTATION MARK โ†’ U+0022 QUOTATION MARK + .replaceAll(/"/g, '"'); // U+201D RIGHT DOUBLE QUOTATION MARK โ†’ U+0022 QUOTATION MARK }; if (isNestedStructure(keys)) { @@ -150,9 +150,9 @@ async function generatePoFile( // PO file header lines.push('msgid ""'); lines.push('msgstr ""'); - lines.push('"Content-Type: text/plain; charset=UTF-8\\n"'); - lines.push(`"Generated: ${new Date().toISOString()}\\n"`); - lines.push(`"Total-Keys: ${Object.keys(flatKeys).length}\\n"`); + lines.push(String.raw`"Content-Type: text/plain; charset=UTF-8\n"`); + lines.push(String.raw`"Generated: ${new Date().toISOString()}\n"`); + lines.push(String.raw`"Total-Keys: ${Object.keys(flatKeys).length}\n"`); lines.push(''); // Translation entries @@ -170,9 +170,9 @@ async function generatePoFile( */ function escapePoString(str: string): string { return str - .replace(/\\/g, '\\\\') - .replace(/"/g, '\\"') - .replace(/\n/g, '\\n') - .replace(/\r/g, '\\r') - .replace(/\t/g, '\\t'); + .replaceAll(/\\/g, '\\\\') + .replaceAll(/"/g, '\\"') + .replaceAll(/\n/g, '\\n') + .replaceAll(/\r/g, '\\r') + .replaceAll(/\t/g, '\\t'); } diff --git a/workspaces/translations/packages/cli/src/lib/i18n/loadFile.ts b/workspaces/translations/packages/cli/src/lib/i18n/loadFile.ts index 4c90126ef4..f890bfb262 100644 --- a/workspaces/translations/packages/cli/src/lib/i18n/loadFile.ts +++ b/workspaces/translations/packages/cli/src/lib/i18n/loadFile.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import path from 'path'; +import path from 'node:path'; import fs from 'fs-extra'; @@ -94,19 +94,21 @@ async function loadPoFile(filePath: string): Promise { } currentKey = unescapePoString( - trimmed.substring(6).replace(/(^["']|["']$)/g, ''), + trimmed.substring(6).replaceAll(/(^["']|["']$)/g, ''), ); currentValue = ''; inMsgId = true; inMsgStr = false; } else if (trimmed.startsWith('msgstr ')) { currentValue = unescapePoString( - trimmed.substring(7).replace(/(^["']|["']$)/g, ''), + trimmed.substring(7).replaceAll(/(^["']|["']$)/g, ''), ); inMsgId = false; inMsgStr = true; } else if (trimmed.startsWith('"') && (inMsgId || inMsgStr)) { - const value = unescapePoString(trimmed.replace(/(^["']|["']$)/g, '')); + const value = unescapePoString( + trimmed.replaceAll(/(^["']|["']$)/g, ''), + ); if (inMsgId) { currentKey += value; } else if (inMsgStr) { @@ -131,9 +133,9 @@ async function loadPoFile(filePath: string): Promise { */ function unescapePoString(str: string): string { return str - .replace(/\\n/g, '\n') - .replace(/\\r/g, '\r') - .replace(/\\t/g, '\t') - .replace(/\\"/g, '"') - .replace(/\\\\/g, '\\'); + .replaceAll(/\\n/g, '\n') + .replaceAll(/\\r/g, '\r') + .replaceAll(/\\t/g, '\t') + .replaceAll(/\\"/g, '"') + .replaceAll(/\\\\/g, '\\'); } diff --git a/workspaces/translations/packages/cli/src/lib/i18n/mergeFiles.ts b/workspaces/translations/packages/cli/src/lib/i18n/mergeFiles.ts index e949b6a833..be4f38f8fc 100644 --- a/workspaces/translations/packages/cli/src/lib/i18n/mergeFiles.ts +++ b/workspaces/translations/packages/cli/src/lib/i18n/mergeFiles.ts @@ -200,19 +200,19 @@ async function loadPoFile(filePath: string): Promise { data[currentKey] = currentValue; } currentKey = unescapePoString( - trimmed.substring(6).replace(/^["']|["']$/g, ''), + trimmed.substring(6).replaceAll(/(^["']|["']$)/g, ''), ); currentValue = ''; inMsgId = true; inMsgStr = false; } else if (trimmed.startsWith('msgstr ')) { currentValue = unescapePoString( - trimmed.substring(7).replace(/^["']|["']$/g, ''), + trimmed.substring(7).replaceAll(/(^["']|["']$)/g, ''), ); inMsgId = false; inMsgStr = true; } else if (trimmed.startsWith('"') && (inMsgId || inMsgStr)) { - const value = unescapePoString(trimmed.replace(/^["']|["']$/g, '')); + const value = unescapePoString(trimmed.replaceAll(/(^["']|["']$)/g, '')); if (inMsgId) { currentKey += value; } else if (inMsgStr) { @@ -241,9 +241,9 @@ async function savePoFile( // PO file header lines.push('msgid ""'); lines.push('msgstr ""'); - lines.push('"Content-Type: text/plain; charset=UTF-8\\n"'); - lines.push(`"Generated: ${new Date().toISOString()}\\n"`); - lines.push(`"Total-Keys: ${Object.keys(data).length}\\n"`); + lines.push(String.raw`"Content-Type: text/plain; charset=UTF-8\n"`); + lines.push(String.raw`"Generated: ${new Date().toISOString()}\n"`); + lines.push(String.raw`"Total-Keys: ${Object.keys(data).length}\n"`); lines.push(''); // Translation entries @@ -261,11 +261,11 @@ async function savePoFile( */ function escapePoString(str: string): string { return str - .replace(/\\/g, '\\\\') - .replace(/"/g, '\\"') - .replace(/\n/g, '\\n') - .replace(/\r/g, '\\r') - .replace(/\t/g, '\\t'); + .replaceAll(/\\/g, '\\\\') + .replaceAll(/"/g, '\\"') + .replaceAll(/\n/g, '\\n') + .replaceAll(/\r/g, '\\r') + .replaceAll(/\t/g, '\\t'); } /** @@ -273,9 +273,9 @@ function escapePoString(str: string): string { */ function unescapePoString(str: string): string { return str - .replace(/\\n/g, '\n') - .replace(/\\r/g, '\r') - .replace(/\\t/g, '\t') - .replace(/\\"/g, '"') - .replace(/\\\\/g, '\\'); + .replaceAll(/\\n/g, '\n') + .replaceAll(/\\r/g, '\r') + .replaceAll(/\\t/g, '\t') + .replaceAll(/\\"/g, '"') + .replaceAll(/\\\\/g, '\\'); } diff --git a/workspaces/translations/packages/cli/src/lib/i18n/saveFile.ts b/workspaces/translations/packages/cli/src/lib/i18n/saveFile.ts index 4f77e6b8c3..545d1221cb 100644 --- a/workspaces/translations/packages/cli/src/lib/i18n/saveFile.ts +++ b/workspaces/translations/packages/cli/src/lib/i18n/saveFile.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import path from 'path'; +import path from 'node:path'; import fs from 'fs-extra'; @@ -76,9 +76,9 @@ async function savePoFile( // PO file header lines.push('msgid ""'); lines.push('msgstr ""'); - lines.push('"Content-Type: text/plain; charset=UTF-8\\n"'); - lines.push(`"Generated: ${new Date().toISOString()}\\n"`); - lines.push(`"Total-Keys: ${Object.keys(data).length}\\n"`); + lines.push(String.raw`"Content-Type: text/plain; charset=UTF-8\n"`); + lines.push(String.raw`"Generated: ${new Date().toISOString()}\n"`); + lines.push(String.raw`"Total-Keys: ${Object.keys(data).length}\n"`); lines.push(''); // Translation entries @@ -96,9 +96,9 @@ async function savePoFile( */ function escapePoString(str: string): string { return str - .replace(/\\/g, '\\\\') - .replace(/"/g, '\\"') - .replace(/\n/g, '\\n') - .replace(/\r/g, '\\r') - .replace(/\t/g, '\\t'); + .replaceAll(/\\/g, '\\\\') + .replaceAll(/"/g, '\\"') + .replaceAll(/\n/g, '\\n') + .replaceAll(/\r/g, '\\r') + .replaceAll(/\t/g, '\\t'); } diff --git a/workspaces/translations/packages/cli/src/lib/i18n/tmsClient.ts b/workspaces/translations/packages/cli/src/lib/i18n/tmsClient.ts index 7febb37665..690372eb7b 100644 --- a/workspaces/translations/packages/cli/src/lib/i18n/tmsClient.ts +++ b/workspaces/translations/packages/cli/src/lib/i18n/tmsClient.ts @@ -38,8 +38,8 @@ export interface TMSDownloadOptions { } export class TMSClient { - private client: AxiosInstance; - private baseUrl: string; + private readonly client: AxiosInstance; + private readonly baseUrl: string; // private token: string; constructor(baseUrl: string, token: string) { @@ -55,12 +55,14 @@ export class TMSClient { normalizedUrl.includes('/project/') ) { // Extract base URL (e.g., https://cloud.memsource.com/web) - const urlMatch = normalizedUrl.match(/^(https?:\/\/[^\/]+\/web)/); + const urlRegex = /^(https?:\/\/[^/]+\/web)/; + const urlMatch = urlRegex.exec(normalizedUrl); if (urlMatch) { normalizedUrl = `${urlMatch[1]}/api2`; // Memsource uses /api2 } else { // Fallback: try to extract domain and use /web/api2 - const domainMatch = normalizedUrl.match(/^(https?:\/\/[^\/]+)/); + const domainRegex = /^(https?:\/\/[^/]+)/; + const domainMatch = domainRegex.exec(normalizedUrl); if (domainMatch) { normalizedUrl = `${domainMatch[1]}/web/api2`; } @@ -102,7 +104,7 @@ export class TMSClient { // Memsource API doesn't have a standard /health endpoint // Connection will be tested when we actually make API calls // This is a no-op for now - actual connection test happens in API calls - return Promise.resolve(); + return; } /** diff --git a/workspaces/translations/packages/cli/src/lib/i18n/uploadCache.ts b/workspaces/translations/packages/cli/src/lib/i18n/uploadCache.ts index 44f0096123..a66fabd7bc 100644 --- a/workspaces/translations/packages/cli/src/lib/i18n/uploadCache.ts +++ b/workspaces/translations/packages/cli/src/lib/i18n/uploadCache.ts @@ -14,8 +14,8 @@ * limitations under the License. */ -import { createHash } from 'crypto'; -import path from 'path'; +import { createHash } from 'node:crypto'; +import path from 'node:path'; import fs from 'fs-extra'; @@ -42,7 +42,7 @@ function getCacheDir(): string { function getCacheFilePath(projectId: string, tmsUrl: string): string { const cacheDir = getCacheDir(); // Create a safe filename from projectId and URL - const safeProjectId = projectId.replace(/[^a-zA-Z0-9]/g, '_'); + const safeProjectId = projectId.replaceAll(/[^a-zA-Z0-9]/g, '_'); const urlHash = createHash('md5') .update(tmsUrl) .digest('hex') diff --git a/workspaces/translations/packages/cli/src/lib/i18n/validateData.ts b/workspaces/translations/packages/cli/src/lib/i18n/validateData.ts index d861892d88..67edba924f 100644 --- a/workspaces/translations/packages/cli/src/lib/i18n/validateData.ts +++ b/workspaces/translations/packages/cli/src/lib/i18n/validateData.ts @@ -99,16 +99,18 @@ export async function validateTranslationData( } // Check for HTML tags in translations + // Use non-greedy quantifier to prevent ReDoS vulnerability const htmlTags = Object.entries(data).filter(([, value]) => - /<[^>]*>/.test(value), + /<[^>]*?>/.test(value), ); if (htmlTags.length > 0) { result.warnings.push(`Found ${htmlTags.length} values with HTML tags`); } // Check for placeholder patterns + // Use non-greedy quantifier to prevent ReDoS vulnerability const placeholderPatterns = Object.entries(data).filter(([, value]) => - /\{\{|\$\{|\%\{|\{.*\}/.test(value), + /\{\{|\$\{|\%\{|\{.*?\}/.test(value), ); if (placeholderPatterns.length > 0) { result.warnings.push( @@ -122,7 +124,7 @@ export async function validateTranslationData( // U+201C: LEFT DOUBLE QUOTATION MARK (") // U+201D: RIGHT DOUBLE QUOTATION MARK (") const curlyApostrophes = Object.entries(data).filter(([, value]) => - /['']/.test(value), + /[\u2018\u2019]/.test(value), ); if (curlyApostrophes.length > 0) { result.warnings.push( diff --git a/workspaces/translations/packages/cli/src/lib/i18n/validateFile.ts b/workspaces/translations/packages/cli/src/lib/i18n/validateFile.ts index df5d26ec86..4e60523f01 100644 --- a/workspaces/translations/packages/cli/src/lib/i18n/validateFile.ts +++ b/workspaces/translations/packages/cli/src/lib/i18n/validateFile.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import path from 'path'; +import path from 'node:path'; import fs from 'fs-extra'; @@ -48,166 +48,189 @@ export async function validateTranslationFile( } /** - * Validate JSON translation file + * Validate file content (UTF-8 and null bytes) */ -async function validateJsonFile(filePath: string): Promise { +function validateFileContent(content: string): void { + if (!isValidUTF8(content)) { + throw new Error('File contains invalid UTF-8 sequences'); + } + + if (content.includes('\x00')) { + throw new Error( + String.raw`File contains null bytes (\x00) which are not valid in JSON`, + ); + } +} + +/** + * Parse JSON content and validate it's an object + */ +function parseJsonContent(content: string): Record { + let data: Record; try { - const content = await fs.readFile(filePath, 'utf-8'); + data = JSON.parse(content) as Record; + } catch (parseError) { + throw new Error( + `JSON parse error: ${ + parseError instanceof Error ? parseError.message : 'Unknown error' + }`, + ); + } - // Check for invalid unicode sequences - if (!isValidUTF8(content)) { - throw new Error('File contains invalid UTF-8 sequences'); - } + if (typeof data !== 'object' || data === null) { + throw new TypeError('Root element must be a JSON object'); + } + + return data; +} + +/** + * Type guard to check if object is nested structure + */ +function isNestedStructure( + obj: unknown, +): obj is Record }> { + if (typeof obj !== 'object' || obj === null) return false; + const firstKey = Object.keys(obj)[0]; + if (!firstKey) return false; + const firstValue = (obj as Record)[firstKey]; + return ( + typeof firstValue === 'object' && firstValue !== null && 'en' in firstValue + ); +} + +/** + * Validate a single translation value + */ +function validateTranslationValue(value: unknown, keyPath: string): void { + if (typeof value !== 'string') { + throw new TypeError( + `Translation value for "${keyPath}" must be a string, got ${typeof value}`, + ); + } - // Check for null bytes which are never valid in JSON strings - if (content.includes('\x00')) { - throw new Error( - 'File contains null bytes (\\x00) which are not valid in JSON', - ); + if (value.includes('\x00')) { + throw new Error(`Translation value for "${keyPath}" contains null byte`); + } + + const curlyApostrophe = /[\u2018\u2019]/; + const curlyQuotes = /[\u201C\u201D]/; + if (curlyApostrophe.test(value) || curlyQuotes.test(value)) { + console.warn( + `Warning: Translation value for "${keyPath}" contains Unicode curly quotes/apostrophes.`, + ); + console.warn(` Consider normalizing to standard quotes: ' โ†’ ' and " โ†’ "`); + } +} + +/** + * Validate nested structure and count keys + */ +function validateNestedStructure( + data: Record }>, +): number { + let totalKeys = 0; + + for (const [pluginName, pluginData] of Object.entries(data)) { + if (typeof pluginData !== 'object' || pluginData === null) { + throw new TypeError(`Plugin "${pluginName}" must be an object`); } - // Try to parse JSON - this will catch syntax errors - let data: Record; - try { - data = JSON.parse(content) as Record; - } catch (parseError) { - throw new Error( - `JSON parse error: ${ - parseError instanceof Error ? parseError.message : 'Unknown error' - }`, - ); + if (!('en' in pluginData)) { + throw new Error(`Plugin "${pluginName}" must have an "en" property`); } - // Check if it's a valid JSON object - if (typeof data !== 'object' || data === null) { - throw new Error('Root element must be a JSON object'); + const enData = pluginData.en; + if (typeof enData !== 'object' || enData === null) { + throw new TypeError(`Plugin "${pluginName}".en must be an object`); } - // Check if it's nested structure: { plugin: { en: { keys } } } - const isNested = ( - obj: unknown, - ): obj is Record }> => { - if (typeof obj !== 'object' || obj === null) return false; - const firstKey = Object.keys(obj)[0]; - if (!firstKey) return false; - const firstValue = (obj as Record)[firstKey]; - return ( - typeof firstValue === 'object' && - firstValue !== null && - 'en' in firstValue - ); - }; - - let totalKeys = 0; - - if (isNested(data)) { - // Nested structure: { plugin: { en: { key: value } } } - // Keys are flat dot-notation strings (e.g., "menuItem.home": "Home") - for (const [pluginName, pluginData] of Object.entries(data)) { - if (typeof pluginData !== 'object' || pluginData === null) { - throw new Error(`Plugin "${pluginName}" must be an object`); - } + for (const [key, value] of Object.entries(enData)) { + validateTranslationValue(value, `${pluginName}.en.${key}`); + totalKeys++; + } + } - if (!('en' in pluginData)) { - throw new Error(`Plugin "${pluginName}" must have an "en" property`); - } + return totalKeys; +} - const enData = pluginData.en; - if (typeof enData !== 'object' || enData === null) { - throw new Error(`Plugin "${pluginName}".en must be an object`); - } +/** + * Validate flat structure and count keys + */ +function validateFlatStructure(data: Record): number { + const translations = data.translations || data; - // Validate that all values are strings (keys are flat dot-notation) - for (const [key, value] of Object.entries(enData)) { - if (typeof value !== 'string') { - throw new Error( - `Translation value for "${pluginName}.en.${key}" must be a string, got ${typeof value}`, - ); - } - - // Check for null bytes - if (value.includes('\x00')) { - throw new Error( - `Translation value for "${pluginName}.en.${key}" contains null byte`, - ); - } - - // Check for Unicode curly quotes/apostrophes - const curlyApostrophe = /['']/; - const curlyQuotes = /[""]/; - if (curlyApostrophe.test(value) || curlyQuotes.test(value)) { - console.warn( - `Warning: Translation value for "${pluginName}.en.${key}" contains Unicode curly quotes/apostrophes.`, - ); - console.warn( - ` Consider normalizing to standard quotes: ' โ†’ ' and " โ†’ "`, - ); - } - - totalKeys++; - } - } - } else { - // Legacy structure: { translations: { key: value } } or flat { key: value } - const translations = data.translations || data; + if (typeof translations !== 'object' || translations === null) { + throw new TypeError('Translations must be an object'); + } - if (typeof translations !== 'object' || translations === null) { - throw new Error('Translations must be an object'); - } + let totalKeys = 0; + for (const [key, value] of Object.entries(translations)) { + validateTranslationValue(value, key); + totalKeys++; + } - // Validate that all values are strings - for (const [key, value] of Object.entries(translations)) { - if (typeof value !== 'string') { - throw new Error( - `Translation value for key "${key}" must be a string, got ${typeof value}`, - ); - } + return totalKeys; +} - // Check for null bytes - if (value.includes('\x00')) { - throw new Error( - `Translation value for key "${key}" contains null byte`, - ); - } +/** + * Count keys in nested structure + */ +function countNestedKeys( + data: Record }>, +): number { + let count = 0; + for (const pluginData of Object.values(data)) { + if (pluginData.en && typeof pluginData.en === 'object') { + count += Object.keys(pluginData.en).length; + } + } + return count; +} - // Check for Unicode curly quotes/apostrophes - const curlyApostrophe = /['']/; - const curlyQuotes = /[""]/; - if (curlyApostrophe.test(value) || curlyQuotes.test(value)) { - console.warn( - `Warning: Translation value for key "${key}" contains Unicode curly quotes/apostrophes.`, - ); - console.warn( - ` Consider normalizing to standard quotes: ' โ†’ ' and " โ†’ "`, - ); - } +/** + * Count keys in flat structure + */ +function countFlatKeys(data: Record): number { + const translations = data.translations || data; + return Object.keys(translations).length; +} - totalKeys++; - } - } +/** + * Validate round-trip JSON parsing + */ +function validateRoundTrip( + data: Record, + originalKeyCount: number, +): void { + const reStringified = JSON.stringify(data, null, 2); + const reparsed = JSON.parse(reStringified) as Record; + + const reparsedKeys = isNestedStructure(reparsed) + ? countNestedKeys(reparsed) + : countFlatKeys(reparsed); + + if (originalKeyCount !== reparsedKeys) { + throw new Error( + `Key count mismatch: original has ${originalKeyCount} keys, reparsed has ${reparsedKeys} keys`, + ); + } +} - // Verify the file can be re-stringified (round-trip test) - const reStringified = JSON.stringify(data, null, 2); - const reparsed = JSON.parse(reStringified); +/** + * Validate JSON translation file + */ +async function validateJsonFile(filePath: string): Promise { + try { + const content = await fs.readFile(filePath, 'utf-8'); + validateFileContent(content); - // Compare key counts to ensure nothing was lost - let reparsedKeys = 0; - if (isNested(reparsed)) { - for (const pluginData of Object.values(reparsed)) { - if (pluginData.en && typeof pluginData.en === 'object') { - reparsedKeys += Object.keys(pluginData.en).length; - } - } - } else { - const reparsedTranslations = reparsed.translations || reparsed; - reparsedKeys = Object.keys(reparsedTranslations).length; - } + const data = parseJsonContent(content); + const totalKeys = isNestedStructure(data) + ? validateNestedStructure(data) + : validateFlatStructure(data); - if (totalKeys !== reparsedKeys) { - throw new Error( - `Key count mismatch: original has ${totalKeys} keys, reparsed has ${reparsedKeys} keys`, - ); - } + validateRoundTrip(data, totalKeys); return true; } catch (error) { diff --git a/workspaces/translations/packages/cli/src/lib/paths.ts b/workspaces/translations/packages/cli/src/lib/paths.ts index a12ae854d3..922236bc24 100644 --- a/workspaces/translations/packages/cli/src/lib/paths.ts +++ b/workspaces/translations/packages/cli/src/lib/paths.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import path from 'path'; +import path from 'node:path'; import { fileURLToPath } from 'url'; const __filename = fileURLToPath(import.meta.url); @@ -24,7 +24,12 @@ const __dirname = path.dirname(__filename); // Simplified paths for translations-cli export const paths = { targetDir: process.cwd(), - // eslint-disable-next-line no-restricted-syntax resolveOwn: (relativePath: string) => - path.resolve(__dirname, '..', '..', relativePath), + path.resolve( + // eslint-disable-next-line no-restricted-syntax + __dirname, + '..', + '..', + relativePath, + ), }; diff --git a/workspaces/translations/packages/cli/src/lib/utils/exec.ts b/workspaces/translations/packages/cli/src/lib/utils/exec.ts index 912f9e49b1..4f2ff39f6b 100644 --- a/workspaces/translations/packages/cli/src/lib/utils/exec.ts +++ b/workspaces/translations/packages/cli/src/lib/utils/exec.ts @@ -15,7 +15,7 @@ */ import { spawnSync, SpawnSyncOptions } from 'child_process'; -import { platform } from 'os'; +import { platform } from 'node:os'; /** * Safely execute a command with arguments. diff --git a/workspaces/translations/packages/cli/src/lib/version.ts b/workspaces/translations/packages/cli/src/lib/version.ts index e18637c75c..f6cae77a2c 100644 --- a/workspaces/translations/packages/cli/src/lib/version.ts +++ b/workspaces/translations/packages/cli/src/lib/version.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import path from 'path'; +import path from 'node:path'; import { fileURLToPath } from 'url'; import fs from 'fs-extra'; diff --git a/workspaces/translations/packages/cli/test/generate.test.ts b/workspaces/translations/packages/cli/test/generate.test.ts index 3cf10fd20f..6e0e761f38 100644 --- a/workspaces/translations/packages/cli/test/generate.test.ts +++ b/workspaces/translations/packages/cli/test/generate.test.ts @@ -16,7 +16,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import fs from 'fs-extra'; -import path from 'path'; +import path from 'node:path'; import { createTestFixture, assertFileContains, runCLI } from './test-helpers'; describe('generate command', () => { diff --git a/workspaces/translations/packages/cli/test/test-helpers.ts b/workspaces/translations/packages/cli/test/test-helpers.ts index f370d5bb2a..e2923b0d27 100644 --- a/workspaces/translations/packages/cli/test/test-helpers.ts +++ b/workspaces/translations/packages/cli/test/test-helpers.ts @@ -15,8 +15,8 @@ */ import fs from 'fs-extra'; -import path from 'path'; -import { execSync } from 'child_process'; +import path from 'node:path'; +import { spawnSync } from 'child_process'; export interface TestFixture { path: string; @@ -87,6 +87,7 @@ export default createTranslationMessages({ /** * Run CLI command and return output + * Uses spawnSync with separate command and args to prevent command injection */ export function runCLI( command: string, @@ -98,13 +99,32 @@ export function runCLI( } { try { const binPath = path.join(process.cwd(), 'bin', 'translations-cli'); - const fullCommand = `${binPath} ${command}`; - const stdout = execSync(fullCommand, { + // Parse command string into arguments array + // Split by spaces but preserve quoted strings + const args = + command + .match(/(?:[^\s"]+|"[^"]*")+/g) + ?.map(arg => arg.replaceAll(/^"|"$/g, '')) || []; + + const result = spawnSync(binPath, args, { cwd: cwd || process.cwd(), encoding: 'utf-8', stdio: 'pipe', }); - return { stdout, stderr: '', exitCode: 0 }; + + const stdout = (result.stdout?.toString() || '').trim(); + const stderr = (result.stderr?.toString() || '').trim(); + const exitCode = result.status || 0; + + if (exitCode !== 0) { + return { + stdout, + stderr, + exitCode, + }; + } + + return { stdout, stderr, exitCode }; } catch (error: any) { return { stdout: error.stdout?.toString() || '', diff --git a/workspaces/translations/plugins/translations-test/src/translations/index.ts b/workspaces/translations/plugins/translations-test/src/translations/index.ts index ed1ca162a9..4f399827a2 100644 --- a/workspaces/translations/plugins/translations-test/src/translations/index.ts +++ b/workspaces/translations/plugins/translations-test/src/translations/index.ts @@ -26,6 +26,7 @@ export const translationsTestTranslations = createTranslationResource({ translations: { de: () => import('./de'), fr: () => import('./fr'), + ja: () => import('./ja'), }, }); diff --git a/workspaces/translations/plugins/translations-test/src/translations/it.ts b/workspaces/translations/plugins/translations-test/src/translations/it.ts new file mode 100644 index 0000000000..1fc5a250bd --- /dev/null +++ b/workspaces/translations/plugins/translations-test/src/translations/it.ts @@ -0,0 +1,60 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createTranslationMessages } from '@backstage/core-plugin-api/alpha'; +import { translationsTestTranslationRef } from './ref'; + +/** + * Italian translation for translations-test. + * @public + */ +const quickstartTranslationIt = createTranslationMessages({ + ref: translationsTestTranslationRef, + messages: { + 'page.title': 'Plugin di prova delle traduzioni', + 'page.subtitle': + 'Plugin per testare la funzionalitร  delle traduzioni e le caratteristiche di i18next', + 'essentials.key': 'valore della chiave', + 'essentials.look.deep': "valore dell'analisi approfondita", + 'interpolation.key': '{{what}} รจ {{how}}', + 'interpolation.nested.key': '{{what}} รจ {{how.value}}', + 'interpolation.complex.message': 'Ecco un {{link}}.', + 'interpolation.complex.linkText': 'link', + 'formatting.intlNumber': 'Alcuni {{val, number}}', + 'formatting.intlNumberWithOptions': + 'Alcuni {{val, number(minimumFractionDigits: 2)}}', + 'formatting.intlDateTime': 'Il {{val, datetime}}', + 'formatting.intlRelativeTime': 'Lorem {{val, relativetime}}', + 'formatting.intlRelativeTimeWithOptions': + 'Lorem {{val, relativetime(quarter)}}', + 'formatting.intlRelativeTimeWithOptionsExplicit': + 'Lorem {{val, relativetime(range: quarter; style: narrow;)}}', + 'plurals.key_zero': 'zero', + 'plurals.key_one': 'uno', + 'plurals.key_two': 'due', + 'plurals.key_few': 'pochi', + 'plurals.key_many': 'molti', + 'plurals.key_other': 'altro', + 'plurals.keyWithCount_one': '{{count}} elemento', + 'plurals.keyWithCount_other': '{{count}} elementi', + 'context.friend': 'Un amico', + 'context.friend_male': 'Un fidanzato', + 'context.friend_female': 'Una ragazza', + 'objects.tree.res': 'aggiunto {{something}}', + }, +}); + +export default quickstartTranslationIt; diff --git a/workspaces/translations/plugins/translations-test/src/translations/ja.ts b/workspaces/translations/plugins/translations-test/src/translations/ja.ts new file mode 100644 index 0000000000..026bbf17fa --- /dev/null +++ b/workspaces/translations/plugins/translations-test/src/translations/ja.ts @@ -0,0 +1,59 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createTranslationMessages } from '@backstage/core-plugin-api/alpha'; +import { translationsTestTranslationRef } from './ref'; + +/** + * Japanese translation for translations-test. + * @public + */ +const quickstartTranslationJa = createTranslationMessages({ + ref: translationsTestTranslationRef, + messages: { + 'page.title': '็ฟป่จณใƒ†ใ‚นใƒˆใƒ—ใƒฉใ‚ฐใ‚คใƒณ', + 'page.subtitle': '็ฟป่จณๆฉŸ่ƒฝใจ i18next ๆฉŸ่ƒฝใ‚’ใƒ†ใ‚นใƒˆใ™ใ‚‹ใŸใ‚ใฎใƒ—ใƒฉใ‚ฐใ‚คใƒณ', + 'essentials.key': 'ใ‚ญใƒผใฎๅ€ค', + 'essentials.look.deep': 'ๆทฑใ„้šŽๅฑคใฎๅ‚็…งใฎๅ€ค', + 'interpolation.key': '{{what}} ใฏ {{how}} ใงใ™', + 'interpolation.nested.key': '{{what}} ใฏ {{how.value}} ใงใ™', + 'interpolation.complex.message': 'ใ“ใ“ใซ {{link}} ใŒใ‚ใ‚Šใพใ™ใ€‚', + 'interpolation.complex.linkText': 'ใƒชใƒณใ‚ฏ', + 'formatting.intlNumber': 'ใ„ใใคใ‹ใฎ {{val, number}}', + 'formatting.intlNumberWithOptions': + 'ใ„ใใคใ‹ใฎ {{val, number(minimumFractionDigits: 2)}}', + 'formatting.intlDateTime': '{{val, datetime}} ใซ', + 'formatting.intlRelativeTime': 'Lorem {{val, relativetime}}', + 'formatting.intlRelativeTimeWithOptions': + 'Lorem {{val, relativetime(quarter)}}', + 'formatting.intlRelativeTimeWithOptionsExplicit': + 'Lorem {{val, relativetime(range: quarter; style: narrow;)}}', + 'plurals.key_zero': 'ใ‚ผใƒญ', + 'plurals.key_one': '1', + 'plurals.key_two': '2', + 'plurals.key_few': 'ๅฐ‘ๆ•ฐ', + 'plurals.key_many': 'ๅคšๆ•ฐ', + 'plurals.key_other': 'ใใฎไป–', + 'plurals.keyWithCount_one': '{{count}} ไปถใฎ้ …็›ฎ', + 'plurals.keyWithCount_other': '{{count}} ไปถใฎ้ …็›ฎ', + 'context.friend': 'ๅ‹้”', + 'context.friend_male': 'ๅฝผๆฐ', + 'context.friend_female': 'ๅฝผๅฅณ', + 'objects.tree.res': '{{something}} ใ‚’่ฟฝๅŠ ใ—ใพใ—ใŸ', + }, +}); + +export default quickstartTranslationJa; diff --git a/workspaces/translations/plugins/translations/src/translations/index.ts b/workspaces/translations/plugins/translations/src/translations/index.ts index 6025848765..4fa78f2f5a 100644 --- a/workspaces/translations/plugins/translations/src/translations/index.ts +++ b/workspaces/translations/plugins/translations/src/translations/index.ts @@ -27,6 +27,7 @@ export const translationsPluginTranslations = createTranslationResource({ fr: () => import('./fr'), it: () => import('./it'), es: () => import('./es'), + ja: () => import('./ja'), }, }); /** diff --git a/workspaces/translations/plugins/translations/src/translations/it.ts b/workspaces/translations/plugins/translations/src/translations/it.ts index 6d259105bd..ea07d62b48 100644 --- a/workspaces/translations/plugins/translations/src/translations/it.ts +++ b/workspaces/translations/plugins/translations/src/translations/it.ts @@ -13,23 +13,27 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import { createTranslationMessages } from '@backstage/core-plugin-api/alpha'; import { translationsPluginTranslationRef } from './ref'; +/** + * Italian translation for translations. + * @public + */ const translationsTranslationIt = createTranslationMessages({ ref: translationsPluginTranslationRef, messages: { - // CRITICAL: Use flat dot notation, not nested objects 'page.title': 'Traduzioni', - 'page.subtitle': 'Gestisci e visualizza le traduzioni caricate', + 'page.subtitle': 'Gestione e visualizzazione delle traduzioni caricate', 'table.title': 'Traduzioni caricate ({{count}})', 'table.headers.refId': 'ID di riferimento', 'table.headers.key': 'Chiave', 'table.options.pageSize': 'Elementi per pagina', 'table.options.pageSizeOptions': 'Mostra {{count}} elementi', 'export.title': 'Traduzioni', - 'export.downloadButton': 'Scarica traduzioni predefinite (Inglese)', - 'export.filename': 'traduzioni-{{timestamp}}.json', + 'export.downloadButton': 'Scarica le traduzioni predefinite (italiano)', + 'export.filename': 'translations-{{timestamp}}.json', 'common.loading': 'Caricamento...', 'common.error': 'Si รจ verificato un errore', 'common.noData': 'Nessun dato disponibile', diff --git a/workspaces/translations/plugins/translations/src/translations/ja.ts b/workspaces/translations/plugins/translations/src/translations/ja.ts new file mode 100644 index 0000000000..7575c1d088 --- /dev/null +++ b/workspaces/translations/plugins/translations/src/translations/ja.ts @@ -0,0 +1,45 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createTranslationMessages } from '@backstage/core-plugin-api/alpha'; +import { translationsPluginTranslationRef } from './ref'; + +/** + * Japanese translation for translations. + * @public + */ +const translationsTranslationJa = createTranslationMessages({ + ref: translationsPluginTranslationRef, + messages: { + 'page.title': '็ฟป่จณ', + 'page.subtitle': '่ชญใฟ่พผใพใ‚ŒใŸ็ฟป่จณใฎ็ฎก็†ใŠใ‚ˆใณ่กจ็คบ', + 'table.title': '่ชญใฟ่พผใพใ‚ŒใŸ็ฟป่จณ ({{count}})', + 'table.headers.refId': 'ๅ‚็…ง ID', + 'table.headers.key': 'ใ‚ญใƒผ', + 'table.options.pageSize': '1 ใƒšใƒผใ‚ธใฎ้ …็›ฎๆ•ฐ', + 'table.options.pageSizeOptions': '{{count}} ไปถใฎ้ …็›ฎใ‚’่กจ็คบ', + 'export.title': '็ฟป่จณ', + 'export.downloadButton': 'ใƒ‡ใƒ•ใ‚ฉใƒซใƒˆใฎ็ฟป่จณใฎใƒ€ใ‚ฆใƒณใƒญใƒผใƒ‰ (่‹ฑ่ชž)', + 'export.filename': 'translations-{{timestamp}}.json', + 'common.loading': '่ชญใฟ่พผใฟไธญ...', + 'common.error': 'ใ‚จใƒฉใƒผใŒ็™บ็”Ÿใ—ใพใ—ใŸ', + 'common.noData': 'ๅˆฉ็”จๅฏ่ƒฝใชใƒ‡ใƒผใ‚ฟใฏใ‚ใ‚Šใพใ›ใ‚“', + 'common.refresh': 'ๆ›ดๆ–ฐ', + 'language.displayFormat': '{{displayName}} ({{code}})', + }, +}); + +export default translationsTranslationJa;