From e3e2ec6e13b6f46c30f8f71b068dafba962fa6ac Mon Sep 17 00:00:00 2001 From: Yi Cai Date: Thu, 4 Dec 2025 16:16:45 -0500 Subject: [PATCH 01/30] 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 58ad456773..f77f073314 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 96224fd79f7772a4dc7360481d694de319f72c0e Mon Sep 17 00:00:00 2001 From: Yi Cai Date: Fri, 5 Dec 2025 16:50:27 -0500 Subject: [PATCH 02/30] 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 7b92c6720d4abe6117ae0d6edce15ba76de6e00d Mon Sep 17 00:00:00 2001 From: Yi Cai Date: Fri, 12 Dec 2025 15:06:36 -0500 Subject: [PATCH 03/30] 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 f77f073314..accb3d93ee 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 2705b2c451fb1bac1c4aeaf44a2dde5184411ca1 Mon Sep 17 00:00:00 2001 From: Yi Cai Date: Fri, 12 Dec 2025 15:40:18 -0500 Subject: [PATCH 04/30] 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 4dc1d0f4d7c869b7b5e58a61399dd6e788ee145f Mon Sep 17 00:00:00 2001 From: Yi Cai Date: Tue, 23 Dec 2025 18:21:44 -0500 Subject: [PATCH 05/30] 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 +- .../translations-test/src/translations/it.ts | 2 +- .../translations-test/src/translations/ja.ts | 2 +- .../translations/src/translations/it.ts | 10 +- .../translations/src/translations/ja.ts | 2 +- 33 files changed, 2486 insertions(+), 1967 deletions(-) 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/it.ts b/workspaces/translations/plugins/translations-test/src/translations/it.ts index 420cf79fbb..1fc5a250bd 100644 --- a/workspaces/translations/plugins/translations-test/src/translations/it.ts +++ b/workspaces/translations/plugins/translations-test/src/translations/it.ts @@ -18,7 +18,7 @@ import { createTranslationMessages } from '@backstage/core-plugin-api/alpha'; import { translationsTestTranslationRef } from './ref'; /** - * Italian translation for plugin.translations-test. + * Italian translation for translations-test. * @public */ const quickstartTranslationIt = createTranslationMessages({ diff --git a/workspaces/translations/plugins/translations-test/src/translations/ja.ts b/workspaces/translations/plugins/translations-test/src/translations/ja.ts index bcdc30c8b0..026bbf17fa 100644 --- a/workspaces/translations/plugins/translations-test/src/translations/ja.ts +++ b/workspaces/translations/plugins/translations-test/src/translations/ja.ts @@ -18,7 +18,7 @@ import { createTranslationMessages } from '@backstage/core-plugin-api/alpha'; import { translationsTestTranslationRef } from './ref'; /** - * Japanese translation for plugin.translations-test. + * Japanese translation for translations-test. * @public */ const quickstartTranslationJa = createTranslationMessages({ diff --git a/workspaces/translations/plugins/translations/src/translations/it.ts b/workspaces/translations/plugins/translations/src/translations/it.ts index 3d5882a009..ea07d62b48 100644 --- a/workspaces/translations/plugins/translations/src/translations/it.ts +++ b/workspaces/translations/plugins/translations/src/translations/it.ts @@ -13,27 +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 plugin.translations. + * 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 index 0bf034e8c1..7575c1d088 100644 --- a/workspaces/translations/plugins/translations/src/translations/ja.ts +++ b/workspaces/translations/plugins/translations/src/translations/ja.ts @@ -18,7 +18,7 @@ import { createTranslationMessages } from '@backstage/core-plugin-api/alpha'; import { translationsPluginTranslationRef } from './ref'; /** - * Japanese translation for plugin.translations. + * Japanese translation for translations. * @public */ const translationsTranslationJa = createTranslationMessages({ From 3c48663b40abb5b90d5d24cc2e63ad2d0f8e4ddc Mon Sep 17 00:00:00 2001 From: Yi Cai Date: Mon, 12 Jan 2026 22:46:53 -0500 Subject: [PATCH 06/30] updated the workflow to address comments Signed-off-by: Yi Cai --- .../packages/cli/WORKFLOW_VERIFICATION.md | 157 ++ .../packages/cli/docs/CI_COMPATIBILITY.md | 271 ++++ .../packages/cli/docs/i18n-commands.md | 103 +- .../translations/packages/cli/package.json | 1 + .../cli/scripts/deploy-translations.ts | 470 +++++- .../packages/cli/src/commands/deploy.ts | 29 +- .../packages/cli/src/commands/generate.ts | 1297 ++++++++++++++++- .../packages/cli/src/commands/index.ts | 28 +- .../cli/src/commands/setupMemsource.ts | 143 +- .../packages/cli/src/commands/sync.ts | 119 +- .../packages/cli/src/commands/upload.ts | 91 +- .../packages/cli/src/lib/i18n/config.ts | 1 + .../packages/cli/src/lib/i18n/extractKeys.ts | 202 ++- .../cli/src/lib/i18n/generateFiles.ts | 7 +- 14 files changed, 2732 insertions(+), 187 deletions(-) create mode 100644 workspaces/translations/packages/cli/WORKFLOW_VERIFICATION.md create mode 100644 workspaces/translations/packages/cli/docs/CI_COMPATIBILITY.md diff --git a/workspaces/translations/packages/cli/WORKFLOW_VERIFICATION.md b/workspaces/translations/packages/cli/WORKFLOW_VERIFICATION.md new file mode 100644 index 0000000000..2ad5af5d9f --- /dev/null +++ b/workspaces/translations/packages/cli/WORKFLOW_VERIFICATION.md @@ -0,0 +1,157 @@ +# Translation Workflow Verification Report + +## Workflow Overview + +The translation workflow consists of 4 main steps: + +1. **Generate** - Extract translation keys from source code โ†’ `{repo}-{sprint}.json` +2. **Upload** - Send reference file to TMS โ†’ Uploads as `{repo}-{sprint}.json` +3. **Download** - Get completed translations โ†’ Downloads as `{repo}-{sprint}-{lang}(-C).json` +4. **Deploy** - Update application locale files โ†’ Deploys to `{lang}.ts` files + +## Issues Fixed + +### โœ… Fixed: Sync Command Missing Sprint Parameter + +**Problem**: The `sync` command didn't pass the `sprint` parameter to `generateCommand`, causing failures since sprint is now required. + +**Solution**: + +- Added `--sprint` as required option to sync command +- Updated `stepGenerate` to accept and pass sprint parameter +- Added logic to track generated filename and pass it to upload step + +### โœ… Fixed: Sync Command Using Hardcoded Filename + +**Problem**: The `stepUpload` function hardcoded `reference.json` but the new naming is `{repo}-{sprint}.json`. + +**Solution**: + +- Updated `stepUpload` to accept generated filename from generate step +- Added fallback logic to construct filename from sprint if needed +- Properly passes the generated file path to upload command + +## Current Workflow Verification + +### Step 1: Generate โœ… + +- **Command**: `i18n generate --sprint s3285` +- **Output**: `{repo}-{sprint}.json` (e.g., `rhdh-s3285.json`) +- **Status**: โœ… Working - sprint is required, validates format, generates correct filename + +### Step 2: Upload โœ… + +- **Command**: `i18n upload --source-file i18n/rhdh-s3285.json` +- **Upload Filename**: `{repo}-{sprint}.json` (e.g., `rhdh-s3285.json`) +- **Status**: โœ… Working - extracts sprint from source filename, generates correct upload name + +### Step 3: Download โœ… + +- **Command**: `i18n download` +- **Downloaded Files**: `{repo}-{sprint}-{lang}(-C).json` (e.g., `rhdh-s3285-it-C.json`) +- **Status**: โœ… Working - TMS adds language code and -C suffix automatically + +### Step 4: Deploy โœ… + +- **Command**: `i18n deploy --source-dir i18n/downloads` +- **Detects Files**: Supports `{repo}-{sprint}-{lang}(-C).json` pattern +- **Status**: โœ… Working - deploy script updated to detect sprint-based pattern + +### Sync Command (All-in-One) โœ… + +- **Command**: `i18n sync --sprint s3285` +- **Status**: โœ… Fixed - Now properly passes sprint through all steps + +## File Naming Convention + +### Generated Files + +- Format: `{repo}-{sprint}.json` +- Example: `rhdh-s3285.json` + +### Uploaded Files (to TMS) + +- Format: `{repo}-{sprint}.json` +- Example: `rhdh-s3285.json` + +### Downloaded Files (from TMS) + +- Format: `{repo}-{sprint}-{lang}(-C).json` +- Examples: + - `rhdh-s3285-it-C.json` (with -C suffix from TMS) + - `rhdh-s3285-it.json` (without -C, also supported) + +### Deployed Files + +- Format: `{lang}.ts` in plugin translation directories +- Example: `workspaces/adoption-insights/plugins/adoption-insights/src/translations/it.ts` + +## Testing Checklist + +### โœ… Unit Tests Needed + +- [ ] Sprint validation (format: s3285 or 3285) +- [ ] Filename generation with sprint +- [ ] Sprint extraction from filenames +- [ ] Deploy script pattern matching for sprint-based files + +### โœ… Integration Tests Needed + +- [ ] Complete workflow: generate โ†’ upload โ†’ download โ†’ deploy +- [ ] Sync command with all steps +- [ ] Sync command with skipped steps +- [ ] Error handling when sprint is missing + +### โœ… Manual Testing Steps + +1. **Test Generate Command**: + + ```bash + translations-cli i18n generate --sprint s3285 + # Verify: rhdh-s3285.json is created + ``` + +2. **Test Upload Command**: + + ```bash + translations-cli i18n upload --source-file i18n/rhdh-s3285.json + # Verify: Uploads as rhdh-s3285.json to TMS + ``` + +3. **Test Download Command**: + + ```bash + translations-cli i18n download + # Verify: Downloads rhdh-s3285-it-C.json, rhdh-s3285-ja-C.json, etc. + ``` + +4. **Test Deploy Command**: + + ```bash + translations-cli i18n deploy --source-dir i18n/downloads + # Verify: Deploys to it.ts, ja.ts files in plugin directories + ``` + +5. **Test Sync Command**: + ```bash + translations-cli i18n sync --sprint s3285 --dry-run + # Verify: All steps execute correctly + ``` + +## Potential Issues to Watch + +1. **Sprint Format Consistency**: Ensure sprint is always normalized (s3285 format) +2. **Filename Matching**: Deploy script must correctly match sprint-based patterns +3. **Backward Compatibility**: Old date-based files should still work during transition +4. **Error Messages**: Clear errors when sprint is missing or invalid + +## Recommendations + +1. โœ… **Add validation tests** for sprint format +2. โœ… **Update documentation** with new naming convention +3. โœ… **Add examples** showing complete workflow with sprint +4. โš ๏ธ **Consider migration guide** for teams using old date-based naming + +## Status: โœ… READY FOR TESTING + +All critical issues have been fixed. The workflow should now work correctly with the new sprint-based naming convention. diff --git a/workspaces/translations/packages/cli/docs/CI_COMPATIBILITY.md b/workspaces/translations/packages/cli/docs/CI_COMPATIBILITY.md new file mode 100644 index 0000000000..77c797972c --- /dev/null +++ b/workspaces/translations/packages/cli/docs/CI_COMPATIBILITY.md @@ -0,0 +1,271 @@ +# CI/CD Compatibility Analysis + +This document analyzes the compatibility of the translation workflow with CI/CD pipelines and identifies what's needed for full CI support. + +## Current CI Compatibility Status + +### โœ… **Already CI-Compatible Features** + +1. **Non-Interactive Mode Support** + + - `--no-input` flag available for all commands that require user input + - Automatic detection of non-interactive terminals (`isTTY` checks) + - Commands fail gracefully with clear error messages when input is required but not provided + +2. **Environment Variable Support** + + - All credentials can be provided via environment variables: + - `MEMSOURCE_TOKEN` or `I18N_TMS_TOKEN` + - `MEMSOURCE_USERNAME` or `I18N_TMS_USERNAME` + - `MEMSOURCE_PASSWORD` or `I18N_TMS_PASSWORD` + - `MEMSOURCE_URL` or `I18N_TMS_URL` + - `I18N_TMS_PROJECT_ID` + - `I18N_LANGUAGES` + - No need for interactive prompts in CI + +3. **Configuration File Support** + + - Project config: `.i18n.config.json` (can be committed) + - Auth config: `~/.i18n.auth.json` (fallback, not recommended for CI) + - All settings can be provided via environment variables + +4. **Command-Line Options** + - All paths and settings can be provided via CLI flags + - No hardcoded user-specific paths (after recent fixes) + +### โš ๏ธ **Potential CI Blockers** + +1. **Memsource CLI Dependency** + + - **Issue**: The workflow requires the `memsource` CLI command to be installed + - **Current**: Assumes memsource CLI is installed in a Python virtual environment + - **CI Impact**: CI runners need to: + - Install Python + - Install memsource-cli-client package + - Set up virtual environment + - Make `memsource` command available in PATH + - **Workaround**: Use environment variables for token (bypasses CLI for some operations) + +2. **Home Directory File Access** + + - **Issue**: Some commands read/write to `~/.memsourcerc` and `~/.i18n.auth.json` + - **Current**: Uses `os.homedir()` which works in CI but may not be ideal + - **CI Impact**: + - CI runners have home directories, so this works + - But credentials stored in home directory may not persist across jobs + - **Recommendation**: Use environment variables or CI secrets instead + +3. **Virtual Environment Path** + + - **Issue**: Memsource CLI requires a Python virtual environment + - **Current**: Auto-detects or prompts for path (not CI-friendly if detection fails) + - **CI Impact**: Need to provide `--memsource-venv` flag or ensure it's in PATH + - **Solution**: Use `--memsource-venv` flag or install memsource CLI globally + +4. **File System Assumptions** + - **Issue**: Some commands assume certain directory structures exist + - **CI Impact**: May need to create directories or adjust paths + - **Solution**: Commands create directories as needed, but paths should be configurable + +## Recommended CI Setup + +### Option 1: Full CI Integration (Recommended for Production) + +```yaml +# Example GitHub Actions workflow +name: Translation Workflow + +on: + push: + branches: [main] + workflow_dispatch: + +jobs: + translations: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.11' + + - name: Install memsource CLI + run: | + pip install memsource-cli-client + # Or use virtual environment + python -m venv .memsource + source .memsource/bin/activate + pip install memsource-cli-client + echo "$(pwd)/.memsource/bin" >> $GITHUB_PATH + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install dependencies + run: yarn install + + - name: Generate translation keys + run: | + yarn translations-cli i18n generate \ + --source-dir src \ + --output-dir i18n + env: + # Optional: Use config file or env vars + I18N_TMS_URL: ${{ secrets.MEMSOURCE_URL }} + I18N_TMS_PROJECT_ID: ${{ secrets.MEMSOURCE_PROJECT_ID }} + + - name: Upload to TMS + run: | + yarn translations-cli i18n upload \ + --source-file i18n/reference.json \ + --target-languages fr,it,ja \ + --no-input + env: + MEMSOURCE_TOKEN: ${{ secrets.MEMSOURCE_TOKEN }} + MEMSOURCE_URL: ${{ secrets.MEMSOURCE_URL }} + I18N_TMS_PROJECT_ID: ${{ secrets.MEMSOURCE_PROJECT_ID }} + + - name: Download translations + run: | + yarn translations-cli i18n download \ + --project-id ${{ secrets.MEMSOURCE_PROJECT_ID }} \ + --output-dir i18n/downloads \ + --no-input + env: + MEMSOURCE_TOKEN: ${{ secrets.MEMSOURCE_TOKEN }} + MEMSOURCE_URL: ${{ secrets.MEMSOURCE_URL }} + + - name: Deploy translations + run: | + yarn translations-cli i18n deploy \ + --download-dir i18n/downloads \ + --no-input +``` + +### Option 2: Minimal CI (Generate Only) + +For CI that only needs to generate translation keys (no TMS interaction): + +```yaml +- name: Generate translation keys + run: | + yarn translations-cli i18n generate \ + --source-dir src \ + --output-dir i18n \ + --dry-run # Optional: validate without writing files +``` + +### Option 3: Using Docker Image + +If memsource CLI installation is complex, use a pre-built Docker image: + +```yaml +- name: Run translation workflow + run: | + docker run \ + -v $PWD:/workspace \ + -e MEMSOURCE_TOKEN=${{ secrets.MEMSOURCE_TOKEN }} \ + -e MEMSOURCE_URL=${{ secrets.MEMSOURCE_URL }} \ + your-org/translations-cli:latest \ + i18n sync --no-input +``` + +## Required Improvements for Full CI Support + +### High Priority + +1. **โœ… DONE**: Remove hardcoded paths (memsource venv path) +2. **โœ… DONE**: Add `--no-input` flag support +3. **โœ… DONE**: Environment variable support for all credentials + +### Medium Priority + +1. **Make home directory paths configurable** + + - Add `--config-dir` or `--auth-file` options + - Allow overriding default paths via environment variables + - Example: `I18N_AUTH_FILE=/path/to/auth.json` + +2. **Improve memsource CLI detection** + + - Better error messages when memsource CLI is not found + - Option to skip memsource CLI requirement for generate-only workflows + - Support for memsource CLI installed via different methods (pip, npm, etc.) + +3. **Add CI-specific documentation** + - Examples for common CI platforms (GitHub Actions, GitLab CI, Jenkins) + - Best practices for secret management + - Troubleshooting guide for CI environments + +### Low Priority + +1. **Add dry-run mode for all commands** + + - Validate configuration without executing + - Useful for CI validation steps + +2. **Support for alternative authentication methods** + + - API keys instead of username/password + - Service account tokens + - OAuth2 flows + +3. **CI-specific optimizations** + - Caching for generated files + - Parallel execution where possible + - Better logging for CI environments + +## Testing CI Compatibility + +To test if your workflow is CI-compatible: + +```bash +# Test non-interactive mode +CI=true translations-cli i18n generate --no-input + +# Test with environment variables only +MEMSOURCE_TOKEN=test-token \ +MEMSOURCE_URL=https://cloud.memsource.com/web \ +I18N_TMS_PROJECT_ID=test-project \ +translations-cli i18n upload --source-file test.json --no-input +``` + +## Current Limitations + +1. **Memsource CLI is required** for upload/download operations + + - Cannot be fully bypassed + - Must be installed in CI environment + +2. **Python virtual environment** may be needed + + - Depends on how memsource CLI is installed + - Can be worked around with global installation + +3. **Home directory access** for config files + - Works but not ideal for CI + - Should use environment variables or project config files instead + +## Conclusion + +**Current Status**: The workflow is **mostly CI-compatible** with some limitations. + +**For CI use today**: + +- โœ… Can generate translation keys +- โœ… Can upload/download with proper setup (memsource CLI + env vars) +- โœ… All interactive prompts can be bypassed +- โš ๏ธ Requires memsource CLI installation in CI +- โš ๏ธ Home directory file access works but not ideal + +**For full CI support**: + +- Make config file paths configurable +- Improve memsource CLI installation documentation +- Add CI-specific examples and best practices + +The workflow is **ready for CI integration** with proper setup, but some improvements would make it more CI-friendly. diff --git a/workspaces/translations/packages/cli/docs/i18n-commands.md b/workspaces/translations/packages/cli/docs/i18n-commands.md index 75b95ce68d..f63fe6efbc 100644 --- a/workspaces/translations/packages/cli/docs/i18n-commands.md +++ b/workspaces/translations/packages/cli/docs/i18n-commands.md @@ -37,7 +37,28 @@ For detailed installation instructions, see: https://github.com/unofficial-memso ### 3. Configure Memsource Client -Create `~/.memsourcerc` file in your home directory with your account credentials: +You have two options to configure Memsource authentication: + +#### Option A: Automated Setup (Recommended) + +Use the CLI's built-in setup command to automatically create the configuration file: + +```bash +# Interactive setup (will prompt for credentials) +npx translations-cli i18n setup-memsource + +# Or provide credentials directly +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. + +#### Option B: Manual Setup + +Create `~/.memsourcerc` file in your home directory manually: ```bash vi ~/.memsourcerc @@ -72,43 +93,57 @@ This sets up the Memsource environment and generates the authentication token au --- +**๐Ÿ“ Note:** The `i18n setup-memsource` command is a **one-time setup utility** and is not part of the regular translation workflow. After initial setup, you'll primarily use the workflow commands listed below. + +--- + ## Available Commands -### 1. `i18n init` - Initialize Configuration +### Workflow Commands + +These are the commands you'll use regularly in your translation workflow: + +#### 1. `i18n init` - Initialize Configuration Creates a default configuration file (`.i18n.config.json`) in your project root. -### 2. `i18n generate` - Extract Translation Keys +#### 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 +#### 3. `i18n upload` - Upload to TMS Uploads the reference translation file to your Translation Management System (TMS) for translation. -### 4. `i18n download` - Download Translations +#### 4. `i18n download` - Download Translations Downloads completed translations from your TMS. -### 5. `i18n deploy` - Deploy to Application +#### 5. `i18n deploy` - Deploy to Application Deploys downloaded translations back to your application's locale files. -### 6. `i18n status` - Check Status +#### 6. `i18n status` - Check Status Shows translation completion status and statistics across all languages. -### 7. `i18n clean` - Cleanup +#### 7. `i18n clean` - Cleanup Removes temporary files, caches, and backup directories. -### 8. `i18n sync` - All-in-One Workflow +#### 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 +### Setup/Utility Commands + +These commands are for one-time setup or maintenance tasks: + +#### `i18n setup-memsource` - Set Up Memsource Configuration โš™๏ธ **One-Time Setup** -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. +Creates `.memsourcerc` file following the localization team's instructions format. This is a **prerequisite setup command** that should be run once before using the workflow commands. + +**Note:** This command is documented in detail in the [Prerequisites](#prerequisites) section above. It's listed here for reference, but you should complete the setup before using the workflow commands. --- @@ -417,33 +452,11 @@ npx translations-cli i18n init 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 +**For Memsource Users:** -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) -``` +If you haven't completed the Memsource setup yet, see the [Prerequisites](#prerequisites) section above for detailed setup instructions. -**Important**: After creating `.memsourcerc`, you must source it before using CLI commands: +**Important**: After creating `.memsourcerc` (via `i18n setup-memsource` or manually), you must source it before using CLI commands: ```bash # Source the file to set MEMSOURCE_TOKEN in your environment @@ -648,20 +661,28 @@ npx translations-cli i18n sync \ ### For a Typical Repository -#### 1. Initialize Configuration +#### 1. Complete Prerequisites (One-Time Setup) + +**For Memsource Users:** + +If you haven't completed the Memsource setup yet, follow the [Prerequisites](#prerequisites) section above: ```bash -# For Memsource users (recommended) +# Complete the one-time setup (see Prerequisites section for details) npx translations-cli i18n setup-memsource source ~/.memsourcerc +``` -# Or basic initialization +#### 2. Initialize Project Configuration + +```bash +# Initialize project configuration file npx translations-cli i18n init ``` -**For Memsource Users (Recommended Workflow):** +**For Memsource Users (Daily Workflow):** -1. **One-time setup**: +1. **One-time setup** (already completed in Prerequisites): ```bash npx translations-cli i18n setup-memsource diff --git a/workspaces/translations/packages/cli/package.json b/workspaces/translations/packages/cli/package.json index 20bf0722da..a5b731fed7 100644 --- a/workspaces/translations/packages/cli/package.json +++ b/workspaces/translations/packages/cli/package.json @@ -60,6 +60,7 @@ "chalk": "^4.1.2", "commander": "^12.0.0", "ts-node": "^10.9.2", + "tsx": "^4.21.0", "vitest": "^1.0.0" } } diff --git a/workspaces/translations/packages/cli/scripts/deploy-translations.ts b/workspaces/translations/packages/cli/scripts/deploy-translations.ts index 7c55b2b83b..c88d5486f3 100644 --- a/workspaces/translations/packages/cli/scripts/deploy-translations.ts +++ b/workspaces/translations/packages/cli/scripts/deploy-translations.ts @@ -17,6 +17,7 @@ import fs from 'fs-extra'; import path from 'node:path'; +import os from 'node:os'; import chalk from 'chalk'; interface TranslationData { @@ -172,9 +173,10 @@ function inferRefInfo( */ function detectRepoType( repoRoot: string, -): 'rhdh-plugins' | 'community-plugins' | 'rhdh' | 'unknown' { +): 'rhdh-plugins' | 'community-plugins' | 'rhdh' | 'backstage' | 'unknown' { const workspacesDir = path.join(repoRoot, 'workspaces'); const packagesDir = path.join(repoRoot, 'packages'); + const pluginsDir = path.join(repoRoot, 'plugins'); if (fs.existsSync(workspacesDir)) { // Check if it's rhdh-plugins or community-plugins @@ -195,6 +197,14 @@ function detectRepoType( } } + // Check if it's backstage repo (has plugins/ directory at root) + if (fs.existsSync(pluginsDir)) { + const repoName = path.basename(repoRoot); + if (repoName === 'backstage') { + return 'backstage'; + } + } + return 'unknown'; } @@ -210,13 +220,16 @@ function findPluginInWorkspaces( return null; } + // Strip "plugin." prefix if present (e.g., "plugin.adoption-insights" -> "adoption-insights") + const cleanPluginName = pluginName.replace(/^plugin\./, ''); + const workspaceDirs = fs.readdirSync(workspacesDir); for (const workspace of workspaceDirs) { const pluginsDir = path.join( workspacesDir, workspace, 'plugins', - pluginName, + cleanPluginName, 'src', 'translations', ); @@ -233,7 +246,7 @@ function findPluginInWorkspaces( * Find plugin translation directory in rhdh repo structure */ function findPluginInRhdh(pluginName: string, repoRoot: string): string | null { - // Try: packages/app/src/translations/{plugin}/ + // For rhdh repo, plugin overrides go to packages/app/src/translations/{plugin}/ const pluginDir = path.join( repoRoot, 'packages', @@ -243,30 +256,58 @@ function findPluginInRhdh(pluginName: string, repoRoot: string): string | null { pluginName, ); - if (fs.existsSync(pluginDir)) { - return pluginDir; - } + // Create directory if it doesn't exist (for new plugin overrides) + if (!fs.existsSync(pluginDir)) { + const translationsDir = path.join( + repoRoot, + 'packages', + 'app', + 'src', + 'translations', + ); - // Try: packages/app/src/translations/ (flat structure with {plugin}-{lang}.ts files) - const translationsDir = path.join( - repoRoot, - 'packages', - 'app', - 'src', - 'translations', - ); + // Only create if the parent translations directory exists + if (fs.existsSync(translationsDir)) { + fs.ensureDirSync(pluginDir); + return pluginDir; + } - 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 pluginDir; +} + +/** + * Get the target repository root for deployment + * For backstage and community-plugins, deploy to rhdh/translations + * For other repos, deploy to their own structure + */ +function getTargetRepoRoot(repoRoot: string, repoType: string): string { + // For backstage and community-plugins repos, deploy to rhdh/translations + if (repoType === 'backstage' || repoType === 'community-plugins') { + // Try to find rhdh repo - check common locations + const possibleRhdhPaths = [ + path.join(path.dirname(repoRoot), 'rhdh'), + // Fallback: Try environment variable or common development location + process.env.RHDH_REPO_PATH || path.join(os.homedir(), 'redhat', 'rhdh'), + ]; + + for (const rhdhPath of possibleRhdhPaths) { + if (fs.existsSync(rhdhPath)) { + return rhdhPath; + } + } + + // If rhdh repo not found, warn but continue with current repo + console.warn( + chalk.yellow( + `โš ๏ธ RHDH repo not found. Deploying to translations directory in current location.`, + ), + ); + } - return hasPluginFiles ? translationsDir : null; + return repoRoot; } /** @@ -275,14 +316,46 @@ function findPluginInRhdh(pluginName: string, repoRoot: string): string | null { function findPluginTranslationDir( pluginName: string, repoRoot: string, + repoType: string, ): string | null { - const repoType = detectRepoType(repoRoot); + // For backstage and community-plugins, deploy to rhdh/translations/{plugin}/ + if (repoType === 'backstage' || repoType === 'community-plugins') { + const targetRoot = getTargetRepoRoot(repoRoot, repoType); + const translationsDir = path.join(targetRoot, 'translations'); + + // Deploy to rhdh/translations/{plugin}/ + const pluginDir = path.join(translationsDir, pluginName); + if (!fs.existsSync(pluginDir)) { + // Create directory if it doesn't exist + fs.ensureDirSync(pluginDir); + } + return pluginDir; + } - if (repoType === 'rhdh-plugins' || repoType === 'community-plugins') { + if (repoType === 'rhdh-plugins') { + // Deploy to workspace-specific paths return findPluginInWorkspaces(pluginName, repoRoot); } if (repoType === 'rhdh') { + // For rhdh repo, check if it's the "rhdh" plugin (RHDH-specific keys) + // or a regular plugin override + if (pluginName === 'rhdh') { + // RHDH-specific keys go to packages/app/src/translations/rhdh/ + const rhdhDir = path.join( + repoRoot, + 'packages', + 'app', + 'src', + 'translations', + 'rhdh', + ); + if (!fs.existsSync(rhdhDir)) { + fs.ensureDirSync(rhdhDir); + } + return rhdhDir; + } + // Regular plugin overrides go to packages/app/src/translations/{plugin}/ return findPluginInRhdh(pluginName, repoRoot); } @@ -370,27 +443,68 @@ function detectDownloadedFiles( // 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 + // Supported patterns: + // 1. New sprint-based: {repo}-{sprint}-{lang}(-C).json (e.g., rhdh-s3285-it-C.json) + // 2. Date-based: {repo}-{date}-{lang}(-C).json (e.g., backstage-2026-01-08-fr-C.json) + // 3. Old reference pattern: {repo}-reference-{date}-{lang}(-C).json (backward compatibility) + // The -C suffix is added by TMS and is optional in matching for (const file of allFiles) { - // Match pattern: {repo}-reference-*-{lang}-C.json - const match = file.match(/^(.+)-reference-.+-(it|ja|fr|de|es)-C\.json$/); + // Language codes: it, ja, fr, de, es, and other common codes + const langCodes = '(it|ja|fr|de|es|ko|zh|pt|ru|ar|hi|nl|pl|sv)'; + + // Try new sprint-based pattern first: {repo}-{sprint}-{lang}(-C).json + // Sprint format: s followed by numbers (e.g., s3285) + // Examples: rhdh-s3285-it-C.json, rhdh-s3285-it.json + let match = file.match( + new RegExp(`^([a-z-]+)-(s\\d+)-${langCodes}(?:-C)?\\.json$`, 'i'), + ); + + // If no match, try date-based pattern: {repo}-{date}-{lang}(-C).json + if (!match) { + match = file.match( + new RegExp( + `^([a-z-]+)-(\\d{4}-\\d{2}-\\d{2})-${langCodes}(?:-C)?\\.json$`, + 'i', + ), + ); + } + + // If still no match, try old reference pattern: {repo}-reference-{date}-{lang}(-C).json + // (for backward compatibility) + if (!match) { + match = file.match( + new RegExp( + `^([a-z-]+)-reference-(\\d{4}-\\d{2}-\\d{2})-${langCodes}(?:-C)?\\.json$`, + 'i', + ), + ); + } + if (match) { const fileRepo = match[1]; - const lang = match[2]; + // For sprint pattern: match[2] is sprint (e.g., s3285), match[3] is lang + // For date pattern: match[2] is date (e.g., 2026-01-08), match[3] is lang + // For old reference pattern: match[2] is date, match[3] is lang + // All patterns have lang at index 3 + const lang = match[3]; // Only include files that match the current repo + // Support both old repo names and new ones (e.g., "backstage" for backstage repo) + // Note: backstage and community-plugins files are deployed to rhdh/translations if ( (repoType === 'rhdh-plugins' && fileRepo === 'rhdh-plugins') || (repoType === 'community-plugins' && fileRepo === 'community-plugins') || - (repoType === 'rhdh' && fileRepo === 'rhdh') + (repoType === 'rhdh' && fileRepo === 'rhdh') || + (repoType === 'backstage' && fileRepo === 'backstage') ) { - files[lang] = file; + // Store the original filename for reading, but use clean name for display + // The clean name removes -C suffix and -reference for cleaner naming + // Example: community-plugins-reference-2025-12-05-fr-C.json + // -> clean: community-plugins-2025-12-05-fr.json + // -> original: community-plugins-reference-2025-12-05-fr-C.json (for reading) + files[lang] = file; // Store original filename for reading } } } @@ -407,7 +521,20 @@ function determineTargetFile( repoType: string, translationDir: string, ): string { + // For backstage and community-plugins deploying to rhdh/translations/{plugin}/ + // Use {lang}.ts format + if (repoType === 'backstage' || repoType === 'community-plugins') { + return path.join(translationDir, `${lang}.ts`); + } + + // For rhdh repo if (repoType === 'rhdh') { + // For "rhdh" plugin (RHDH-specific keys), use {lang}.ts + if (pluginName === 'rhdh') { + return path.join(translationDir, `${lang}.ts`); + } + + // For plugin overrides, try {plugin}-{lang}.ts first, then {lang}.ts const pluginLangFile = path.join( translationDir, `${pluginName}-${lang}.ts`, @@ -420,9 +547,11 @@ function determineTargetFile( if (fs.existsSync(langFile)) { return langFile; } - return langFile; // Default to {lang}.ts for new files + // Default to {plugin}-{lang}.ts for new plugin override files + return pluginLangFile; } + // For rhdh-plugins, use {lang}.ts in workspace plugin directories return path.join(translationDir, `${lang}.ts`); } @@ -600,7 +729,11 @@ function processPluginTranslation( repoType: string, repoRoot: string, ): { updated: boolean; created: boolean } | null { - const translationDir = findPluginTranslationDir(pluginName, repoRoot); + const translationDir = findPluginTranslationDir( + pluginName, + repoRoot, + repoType, + ); if (!translationDir) { console.warn( @@ -609,6 +742,8 @@ function processPluginTranslation( return null; } + console.log(chalk.gray(` ๐Ÿ“ฆ Plugin: ${pluginName} โ†’ ${translationDir}`)); + const targetFile = determineTargetFile( pluginName, lang, @@ -666,7 +801,10 @@ function processLanguageTranslations( let created = 0; for (const [pluginName, pluginData] of Object.entries(data)) { - const translations = pluginData.en || {}; + // Use the language-specific translations (fr, it, ja, etc.) + // The translation file structure is: { plugin: { lang: { key: value } } } + const translations = + (pluginData as Record>)[lang] || {}; if (Object.keys(translations).length === 0) { continue; @@ -689,6 +827,210 @@ function processLanguageTranslations( return { updated, created }; } +/** + * Extract translation keys from a TypeScript translation file + * Language files use flat dot notation: 'key.name': 'value' + */ +function extractKeysFromTranslationFile(filePath: string): Set { + const keys = new Set(); + try { + const content = fs.readFileSync(filePath, 'utf-8'); + // Find the messages object in createTranslationMessages + const messagesMatch = content.match(/messages:\s*\{([\s\S]*?)\}\s*\}\)/); + if (messagesMatch) { + const messagesContent = messagesMatch[1]; + // Match keys in messages object: 'key.name': 'value', + // Look for pattern: 'quoted.key': 'value' or "quoted.key": "value" + const keyPattern = /['"]([^'"]+)['"]\s*:\s*['"]/g; + let match = keyPattern.exec(messagesContent); + while (match !== null) { + keys.add(match[1]); + match = keyPattern.exec(messagesContent); + } + } + } catch (error) { + // If file doesn't exist or can't be read, return empty set + } + return keys; +} + +/** + * Extract keys from reference file by parsing the nested messages object + * Ref files use nested structure: { page: { title: 'value' } } + * We need to flatten to dot notation: 'page.title' + */ +function extractKeysFromRefFile(refFilePath: string): Set { + const keys = new Set(); + try { + const content = fs.readFileSync(refFilePath, 'utf-8'); + // Find the messages object: export const xxxMessages = { ... } + const messagesMatch = content.match( + /export\s+const\s+\w+Messages\s*=\s*\{([\s\S]*?)\};/, + ); + if (!messagesMatch) { + return keys; + } + + const messagesContent = messagesMatch[1]; + + // Recursively extract nested keys and flatten to dot notation + const extractNestedKeys = ( + nestedContent: string, + prefix = '', + depth = 0, + maxDepth = 10, + ): void => { + if (depth > maxDepth) { + return; // Prevent infinite recursion + } + + // Match object properties: key: { ... } or key: 'value' + // Handle both identifier keys and string literal keys + const propertyPattern = /(?:(\w+)|['"]([^'"]+)['"])\s*:\s*([^,}]+)/g; + let match = propertyPattern.exec(nestedContent); + const processed = new Set(); + + while (match !== null) { + if (processed.has(match.index)) { + match = propertyPattern.exec(nestedContent); + continue; + } + processed.add(match.index); + + const key = match[1] || match[2]; // identifier or string literal + const value = match[3].trim(); + + const fullKey = prefix ? `${prefix}.${key}` : key; + + // Check if value is a nested object + if (value.startsWith('{')) { + // Find the matching closing brace + let braceCount = 0; + const endIndex = match.index + match[0].length; + for (let i = endIndex; i < nestedContent.length; i++) { + if (nestedContent[i] === '{') braceCount++; + if (nestedContent[i] === '}') { + braceCount--; + if (braceCount === 0) { + const nestedSubContent = nestedContent.substring( + endIndex, + i + 1, + ); + extractNestedKeys( + nestedSubContent, + fullKey, + depth + 1, + maxDepth, + ); + break; + } + } + } + } else if (value.match(/^['"]/)) { + // Leaf node - string value + keys.add(fullKey); + } + + match = propertyPattern.exec(nestedContent); + } + }; + + extractNestedKeys(messagesContent); + } catch (error) { + // If file doesn't exist or can't be read, return empty set + } + return keys; +} + +/** + * Validate that all translation files have matching keys with the reference file + */ +function validateTranslationKeys( + repoType: string, + repoRoot: string, +): { hasErrors: boolean; errors: string[] } { + const errors: string[] = []; + const supportedLangs = ['fr', 'it', 'ja', 'de', 'es']; + + // Find all plugin translation directories + const workspacesDir = path.join(repoRoot, 'workspaces'); + if (!fs.existsSync(workspacesDir)) { + return { hasErrors: false, errors: [] }; + } + + const workspaceDirs = fs.readdirSync(workspacesDir); + + for (const workspace of workspaceDirs) { + const pluginsDir = path.join(workspacesDir, workspace, 'plugins'); + if (!fs.existsSync(pluginsDir)) { + continue; + } + + const pluginDirs = fs.readdirSync(pluginsDir); + for (const plugin of pluginDirs) { + const translationDir = path.join( + pluginsDir, + plugin, + 'src', + 'translations', + ); + if (!fs.existsSync(translationDir)) { + continue; + } + + const refFile = path.join(translationDir, 'ref.ts'); + if (!fs.existsSync(refFile)) { + continue; + } + + // Extract keys from reference file + const refKeys = extractKeysFromRefFile(refFile); + if (refKeys.size === 0) { + // Skip if we can't extract keys (might be using a different format) + continue; + } + + // Check each language file + for (const lang of supportedLangs) { + const langFile = path.join(translationDir, `${lang}.ts`); + if (!fs.existsSync(langFile)) { + continue; + } + + const langKeys = extractKeysFromTranslationFile(langFile); + + // Find missing keys + const missingKeys = Array.from(refKeys).filter( + key => !langKeys.has(key), + ); + if (missingKeys.length > 0) { + errors.push( + `โŒ ${workspace}/plugins/${plugin}/src/translations/${lang}.ts is missing ${ + missingKeys.length + } key(s): ${missingKeys.slice(0, 5).join(', ')}${ + missingKeys.length > 5 ? '...' : '' + }`, + ); + } + + // Find extra keys (keys in lang file but not in ref) + const extraKeys = Array.from(langKeys).filter(key => !refKeys.has(key)); + if (extraKeys.length > 0) { + errors.push( + `โš ๏ธ ${workspace}/plugins/${plugin}/src/translations/${lang}.ts has ${ + extraKeys.length + } extra key(s) not in ref: ${extraKeys.slice(0, 5).join(', ')}${ + extraKeys.length > 5 ? '...' : '' + }`, + ); + } + } + } + } + + return { hasErrors: errors.length > 0, errors }; +} + /** * Deploy translations from downloaded JSON files */ @@ -703,7 +1045,7 @@ async function deployTranslations( if (repoType === 'unknown') { throw new Error( - 'Could not detect repository type. Expected: rhdh-plugins, community-plugins, or rhdh', + 'Could not detect repository type. Expected: rhdh-plugins, community-plugins, rhdh, or backstage', ); } @@ -717,7 +1059,7 @@ async function deployTranslations( ); console.warn( chalk.gray( - ` Expected files like: ${repoType}-reference-*-{lang}-C.json`, + ` Expected files like: ${repoType}-*-{lang}.json or ${repoType}-*-{lang}-C.json`, ), ); return; @@ -734,19 +1076,26 @@ async function deployTranslations( let totalUpdated = 0; let totalCreated = 0; - for (const [lang, filename] of Object.entries(repoFiles)) { - const filepath = path.join(downloadDir, filename); + for (const [lang, originalFilename] of Object.entries(repoFiles)) { + // Use the original filename to read the file (it may have -C suffix and -reference) + const filepath = path.join(downloadDir, originalFilename); if (!fs.existsSync(filepath)) { - console.warn(chalk.yellow(` โš ๏ธ File not found: ${filename}`)); + console.warn(chalk.yellow(` โš ๏ธ File not found: ${originalFilename}`)); continue; } + // Generate clean filename for display (remove -C suffix and -reference) + let displayFilename = originalFilename; + displayFilename = displayFilename.replace(/-C\.json$/, '.json'); + displayFilename = displayFilename.replace(/-reference-/, '-'); + const data: TranslationData = JSON.parse( fs.readFileSync(filepath, 'utf-8'), ); console.log(chalk.cyan(`\n ๐ŸŒ Language: ${lang.toUpperCase()}`)); + console.log(chalk.gray(` ๐Ÿ“„ Processing: ${displayFilename}`)); const { updated, created } = processLanguageTranslations( data, @@ -762,16 +1111,37 @@ async function deployTranslations( console.log(chalk.blue(`\n\n๐Ÿ“Š Summary:`)); console.log(chalk.green(` โœ… Updated: ${totalUpdated} files`)); console.log(chalk.green(` โœจ Created: ${totalCreated} files`)); + + // Validate translation keys after deployment + console.log(chalk.blue(`\n๐Ÿ” Validating translation keys...`)); + const validationResults = validateTranslationKeys(repoType, repoRoot); + + if (validationResults.hasErrors) { + console.log(chalk.red(`\nโŒ Validation found issues:`)); + validationResults.errors.forEach(error => { + console.log(chalk.red(` ${error}`)); + }); + console.log( + chalk.yellow( + `\nโš ๏ธ Please review and fix the missing keys before committing.`, + ), + ); + } else { + console.log(chalk.green(`\nโœ… All translation files have matching keys!`)); + } + console.log(chalk.blue(`\n๐ŸŽ‰ Deployment complete!`)); } // Main execution -const downloadDir = process.argv[2] || 'workspaces/i18n/downloads'; -const repoRoot = process.cwd(); - -try { - await deployTranslations(downloadDir, repoRoot); -} catch (error) { - console.error(chalk.red('โŒ Error:'), error); - process.exit(1); -} +(async () => { + const downloadDir = process.argv[2] || 'workspaces/i18n/downloads'; + const repoRoot = process.cwd(); + + 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/deploy.ts b/workspaces/translations/packages/cli/src/commands/deploy.ts index ace4e70144..5b6a7c8170 100644 --- a/workspaces/translations/packages/cli/src/commands/deploy.ts +++ b/workspaces/translations/packages/cli/src/commands/deploy.ts @@ -67,15 +67,36 @@ async function deployWithTypeScriptScript( } // Use tsx to run the TypeScript script + // Try to find tsx: check global, then try npx/yarn, then check local node_modules + let tsxCommand = 'tsx'; + let tsxArgs: string[] = [scriptPath, sourceDir]; + if (!commandExists('tsx')) { - throw new Error( - 'tsx not found. Please install it: npm install -g tsx, or yarn add -D tsx', - ); + // Try npx tsx (uses local or downloads if needed) + if (commandExists('npx')) { + tsxCommand = 'npx'; + tsxArgs = ['tsx', scriptPath, sourceDir]; + } else if (commandExists('yarn')) { + // Try yarn tsx (uses local installation) + tsxCommand = 'yarn'; + tsxArgs = ['tsx', scriptPath, sourceDir]; + } else { + // Check for local tsx in node_modules + const localTsxPath = path.resolve(repoRoot, 'node_modules/.bin/tsx'); + if (await fs.pathExists(localTsxPath)) { + tsxCommand = localTsxPath; + tsxArgs = [scriptPath, sourceDir]; + } else { + throw new Error( + 'tsx not found. Please install it: npm install -g tsx, or yarn add -D tsx', + ); + } + } } // Run the script with tsx // Note: scriptPath and sourceDir are validated paths, safe to use - safeExecSyncOrThrow('tsx', [scriptPath, sourceDir], { + safeExecSyncOrThrow(tsxCommand, tsxArgs, { stdio: 'inherit', cwd: repoRoot, env: { ...process.env }, diff --git a/workspaces/translations/packages/cli/src/commands/generate.ts b/workspaces/translations/packages/cli/src/commands/generate.ts index 0320f290a5..ffb5d81437 100644 --- a/workspaces/translations/packages/cli/src/commands/generate.ts +++ b/workspaces/translations/packages/cli/src/commands/generate.ts @@ -21,10 +21,51 @@ import chalk from 'chalk'; import fs from 'fs-extra'; import glob from 'glob'; -import { extractTranslationKeys } from '../lib/i18n/extractKeys'; +import { + extractTranslationKeys, + type ExtractResult, +} from '../lib/i18n/extractKeys'; import { generateTranslationFiles } from '../lib/i18n/generateFiles'; import { mergeTranslationFiles } from '../lib/i18n/mergeFiles'; import { loadI18nConfig, mergeConfigWithOptions } from '../lib/i18n/config'; +import { safeExecSyncOrThrow } from '../lib/utils/exec'; + +/** + * Detect repository name from git or directory + * Used for generating filenames in format: -.json + * @param repoPath - Optional path to repository. If not provided, uses current directory + */ +function detectRepoName(repoPath?: string): string { + const targetPath = repoPath || process.cwd(); + + try { + // Try to get repo name from git + const gitRepoUrl = safeExecSyncOrThrow( + 'git', + ['config', '--get', 'remote.origin.url'], + { + cwd: targetPath, + }, + ); + if (gitRepoUrl) { + // Extract repo name from URL (handles both https and ssh formats) + // 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 { + // Git not available or not a git repo + } + + // Fallback: use directory name + return path.basename(targetPath); +} // Helper to check if data is in nested structure function isNestedStructure( @@ -116,7 +157,7 @@ function isEnglishReferenceFile(filePath: string, content: string): boolean { } /** - * Find all English reference files + * Find all English reference files (TypeScript) */ async function findEnglishReferenceFiles( sourceDir: string, @@ -146,6 +187,700 @@ async function findEnglishReferenceFiles( return sourceFiles; } +/** + * Find all JSON translation files (for RHDH repo) + */ +async function findJsonTranslationFiles(sourceDir: string): Promise { + // Look for JSON files in translations directories + // Check both relative to sourceDir and at repo root level + const repoRoot = process.cwd(); + const jsonPatterns = [ + // Patterns relative to sourceDir + '**/translations/**/*.json', + 'packages/app/src/translations/**/*.json', + 'packages/app/translations/**/*.json', + // Patterns at repo root level (for RHDH repo) + 'translations/**/*-en.json', + 'translations/**/*en*.json', + ]; + + const jsonFiles: string[] = []; + for (const pattern of jsonPatterns) { + try { + // Try from sourceDir first + const filesFromSource = glob.sync(pattern, { + cwd: sourceDir, + ignore: [ + '**/node_modules/**', + '**/dist/**', + '**/build/**', + '**/test/**', + ], + absolute: true, + }); + jsonFiles.push(...filesFromSource); + + // Also try from repo root for root-level patterns + if (pattern.startsWith('translations/')) { + const filesFromRoot = glob.sync(pattern, { + cwd: repoRoot, + ignore: [ + '**/node_modules/**', + '**/dist/**', + '**/build/**', + '**/test/**', + ], + absolute: true, + }); + jsonFiles.push(...filesFromRoot); + } + } catch { + // Skip patterns that don't match + continue; + } + } + + // Remove duplicates + const uniqueFiles = Array.from(new Set(jsonFiles)); + + // Filter to only English reference files (exclude language-specific files) + const englishJsonFiles: string[] = []; + for (const filePath of uniqueFiles) { + const fileName = path.basename(filePath, '.json').toLowerCase(); + // Include files with "en" in name, exclude language-specific files + if (fileName.includes('en') || fileName.includes('reference')) { + // Double-check it's not a language file + if (!isLanguageFile(fileName.replace(/en|reference/gi, ''))) { + englishJsonFiles.push(filePath); + } + } + } + + return englishJsonFiles; +} + +/** + * Check if file path contains 'translations' or 'i18n' directory + * Matches original filtering: grep -E "(translations|i18n)" + * Also accepts files named translation.ts, ref.ts, etc. even if not in translations/ directory + * (for simpler Backstage plugin structures like plugins/home/src/translation.ts) + */ +function pathContainsTranslationsOrI18n(filePath: string): boolean { + // Check for translations or i18n directory + if (filePath.includes('/translations/') || filePath.includes('/i18n/')) { + return true; + } + + // Also accept files with translation-related names even if not in translations/ directory + // This handles simpler structures like plugins/home/src/translation.ts + const fileName = path.basename(filePath).toLowerCase(); + const translationFileNames = [ + 'translation.ts', + 'ref.ts', + 'translationref.ts', + 'messages.ts', + 'data.json', + 'alpha.ts', + ]; + if ( + translationFileNames.some( + name => fileName === name || fileName.endsWith(name), + ) + ) { + return true; + } + + return false; +} + +/** + * Check if file is a non-English language file that should be ignored + * Rules: ONLY extract English (en) messages - ignore all other language files + */ +function isNonEnglishLanguageFile(filePath: string): boolean { + // Common language codes to exclude (not exhaustive, but covers common cases) + const languageCodes = [ + 'de', // German + 'fr', // French + 'es', // Spanish + 'it', // Italian + 'ja', // Japanese + 'zh', // Chinese + 'pt', // Portuguese + 'ru', // Russian + 'ko', // Korean + 'nl', // Dutch + 'sv', // Swedish + 'pl', // Polish + 'cs', // Czech + 'tr', // Turkish + 'ar', // Arabic + 'he', // Hebrew + 'hi', // Hindi + ]; + + const fileName = filePath.toLowerCase(); + + // Check for language-specific file patterns: + // - translation.de.ts, translation.fr.ts, etc. + // - ref.de.ts, ref.fr.ts, etc. + // - messages.de.ts, messages.fr.ts, etc. + // - data.de.json, data.fr.json, etc. + // - translations/de/..., translations/fr/..., etc. + // - i18n/de/..., i18n/fr/..., etc. + + for (const lang of languageCodes) { + // Pattern: file.{lang}.ts or file.{lang}.json + if ( + fileName.includes(`.${lang}.`) || + fileName.includes(`-${lang}.`) || + fileName.includes(`_${lang}.`) + ) { + return true; + } + // Pattern: translations/{lang}/ or i18n/{lang}/ + if ( + fileName.includes(`/translations/${lang}/`) || + fileName.includes(`/i18n/${lang}/`) || + fileName.includes(`\\translations\\${lang}\\`) || + fileName.includes(`\\i18n\\${lang}\\`) + ) { + return true; + } + } + + return false; +} + +/** + * Find Backstage plugin translation ref files in Backstage repository + * Aligns with first release version: searches plugins/, packages/, workspaces/ + * These contain the English source translations for core Backstage plugins + */ +async function findBackstagePluginTranslationRefs( + backstageRepoPath: string, +): Promise { + if (!(await fs.pathExists(backstageRepoPath))) { + console.warn( + chalk.yellow( + `โš ๏ธ Backstage repository path does not exist: ${backstageRepoPath}`, + ), + ); + return []; + } + + // Look for translation ref files matching original patterns + // Files must be in paths containing 'translations' or 'i18n' + // Exact filenames: translation.ts, ref.ts, translationRef.ts, messages.ts, data.json + const patterns = [ + // plugins/ directory structure (simpler structure: plugins/{name}/src/translation.ts) + 'plugins/*/src/translation.ts', + 'plugins/*/src/ref.ts', + 'plugins/*/src/translationRef.ts', + 'plugins/*/src/messages.ts', + 'plugins/*/src/data.json', + 'plugins/*/src/**/translation.ts', + 'plugins/*/src/**/ref.ts', + 'plugins/*/src/**/translationRef.ts', + 'plugins/*/src/**/messages.ts', + 'plugins/*/src/**/data.json', + 'plugins/*/src/**/alpha.ts', + // plugins/ directory structure (packages subdirectory: plugins/*/packages/plugin-*/...) + 'plugins/*/packages/plugin-*/**/translation.ts', + 'plugins/*/packages/plugin-*/**/ref.ts', + 'plugins/*/packages/plugin-*/**/translationRef.ts', + 'plugins/*/packages/plugin-*/**/messages.ts', + 'plugins/*/packages/plugin-*/**/data.json', + 'plugins/*/packages/core-*/**/translation.ts', + 'plugins/*/packages/core-*/**/ref.ts', + 'plugins/*/packages/core-*/**/translationRef.ts', + 'plugins/*/packages/core-*/**/messages.ts', + 'plugins/*/packages/core-*/**/data.json', + // packages/ directory structure + 'packages/plugin-*/**/translation.ts', + 'packages/plugin-*/**/ref.ts', + 'packages/plugin-*/**/translationRef.ts', + 'packages/plugin-*/**/messages.ts', + 'packages/plugin-*/**/data.json', + 'packages/core-*/**/translation.ts', + 'packages/core-*/**/ref.ts', + 'packages/core-*/**/translationRef.ts', + 'packages/core-*/**/messages.ts', + 'packages/core-*/**/data.json', + // workspaces/ directory structure + 'workspaces/*/packages/plugin-*/**/translation.ts', + 'workspaces/*/packages/plugin-*/**/ref.ts', + 'workspaces/*/packages/plugin-*/**/translationRef.ts', + 'workspaces/*/packages/plugin-*/**/messages.ts', + 'workspaces/*/packages/plugin-*/**/data.json', + 'workspaces/*/packages/core-*/**/translation.ts', + 'workspaces/*/packages/core-*/**/ref.ts', + 'workspaces/*/packages/core-*/**/translationRef.ts', + 'workspaces/*/packages/core-*/**/messages.ts', + 'workspaces/*/packages/core-*/**/data.json', + // Also check for alpha.ts files (some plugins export refs from alpha.ts) + 'plugins/*/packages/plugin-*/**/alpha.ts', + 'plugins/*/packages/core-*/**/alpha.ts', + 'packages/plugin-*/**/alpha.ts', + 'packages/core-*/**/alpha.ts', + 'workspaces/*/packages/plugin-*/**/alpha.ts', + 'workspaces/*/packages/core-*/**/alpha.ts', + ]; + + const pluginRefFiles: string[] = []; + for (const pattern of patterns) { + try { + const files = glob.sync(pattern, { + cwd: backstageRepoPath, + ignore: [ + '**/build/**', + '**/dist/**', + '**/node_modules/**', + '**/*.test.ts', + '**/*.spec.ts', + '**/*.test.d.ts', + '**/*.spec.d.ts', + ], + absolute: true, + }); + // Filter files to only include those in paths containing 'translations' or 'i18n' + // This matches the original: grep -E "(translations|i18n)" + // Also filter out non-English language files (ONLY extract English messages) + const filteredFiles = files.filter( + file => + pathContainsTranslationsOrI18n(file) && + !isNonEnglishLanguageFile(file), + ); + pluginRefFiles.push(...filteredFiles); + } catch { + // Skip patterns that don't match + continue; + } + } + + // Remove duplicates + return Array.from(new Set(pluginRefFiles)); +} + +/** + * Extract plugin name from Backstage plugin package path + * Handles Backstage repository structure: plugins/, packages/, workspaces/ + * Also supports legacy node_modules structure for backward compatibility + */ +function extractBackstagePluginName(filePath: string): string | null { + // Pattern 0: plugins/{name}/src/... (simpler structure: plugins/home/src/translation.ts) + const simplePluginsMatch = /plugins\/([^/]+)\//.exec(filePath); + if (simplePluginsMatch && filePath.includes('/src/')) { + let pluginName = simplePluginsMatch[1]; + // Map React variants to base plugin names (they share translations) + if (pluginName.endsWith('-react')) { + pluginName = pluginName.replace(/-react$/, ''); + } + return pluginName; + } + + // Pattern 1: plugins/{name}/packages/plugin-{name}/... + const pluginsMatch = /plugins\/([^/]+)\/packages\/plugin-([^/]+)/.exec( + filePath, + ); + if (pluginsMatch) { + let pluginName = pluginsMatch[2]; + // Map React variants to base plugin names (they share translations) + if (pluginName.endsWith('-react')) { + pluginName = pluginName.replace(/-react$/, ''); + } + return pluginName; + } + + // Pattern 2: packages/plugin-{name}/... + const packagesPluginMatch = /packages\/plugin-([^/]+)/.exec(filePath); + if (packagesPluginMatch) { + let pluginName = packagesPluginMatch[1]; + if (pluginName.endsWith('-react')) { + pluginName = pluginName.replace(/-react$/, ''); + } + return pluginName; + } + + // Pattern 3: packages/core-{name}/... + const packagesCoreMatch = /packages\/core-([^/]+)/.exec(filePath); + if (packagesCoreMatch) { + return `core-${packagesCoreMatch[1]}`; + } + + // Pattern 4: workspaces/{name}/packages/plugin-{name}/... + const workspacesMatch = /workspaces\/([^/]+)\/packages\/plugin-([^/]+)/.exec( + filePath, + ); + if (workspacesMatch) { + let pluginName = workspacesMatch[2]; + if (pluginName.endsWith('-react')) { + pluginName = pluginName.replace(/-react$/, ''); + } + return pluginName; + } + + // Pattern 5: Legacy node_modules/@backstage/plugin-{name}/... (backward compatibility) + const nodeModulesPluginMatch = /@backstage\/plugin-([^/]+)/.exec(filePath); + if (nodeModulesPluginMatch) { + let pluginName = nodeModulesPluginMatch[1]; + if (pluginName.endsWith('-react')) { + pluginName = pluginName.replace(/-react$/, ''); + } + return pluginName; + } + + // Pattern 6: Legacy node_modules/@backstage/core-{name}/... (backward compatibility) + const nodeModulesCoreMatch = /@backstage\/core-([^/]+)/.exec(filePath); + if (nodeModulesCoreMatch) { + return `core-${nodeModulesCoreMatch[1]}`; + } + + return null; +} + +/** + * Check if a Backstage plugin is actually installed/used in the RHDH project + * Checks: + * 1. If plugin package exists in node_modules (already verified by finding ref files) + * 2. If plugin is imported/referenced in app source code + * 3. If plugin is listed in package.json dependencies + */ +async function isPluginUsedInRhdh( + pluginName: string, + repoRoot: string, +): Promise { + // Map plugin names to their package names + // e.g., "home" -> "@backstage/plugin-home" + // e.g., "catalog-graph" -> "@backstage/plugin-catalog-graph" + // e.g., "core-components" -> "@backstage/core-components" + let packageName: string; + if (pluginName.startsWith('core-')) { + packageName = `@backstage/${pluginName}`; + } else { + packageName = `@backstage/plugin-${pluginName}`; + } + + // Check if package exists in node_modules (basic check) + const nodeModulesPath = path.join(repoRoot, 'node_modules', packageName); + if (!(await fs.pathExists(nodeModulesPath))) { + return false; + } + + // Check if plugin is in package.json dependencies + const packageJsonPath = path.join(repoRoot, 'package.json'); + if (await fs.pathExists(packageJsonPath)) { + try { + const packageJson = await fs.readJson(packageJsonPath); + const allDeps = { + ...packageJson.dependencies, + ...packageJson.devDependencies, + ...packageJson.peerDependencies, + }; + if (packageName in allDeps) { + return true; + } + } catch { + // If we can't read package.json, continue with other checks + } + } + + // Check app package.json (for monorepo structure) + const appPackageJsonPath = path.join( + repoRoot, + 'packages', + 'app', + 'package.json', + ); + if (await fs.pathExists(appPackageJsonPath)) { + try { + const appPackageJson = await fs.readJson(appPackageJsonPath); + const allDeps = { + ...appPackageJson.dependencies, + ...appPackageJson.devDependencies, + ...appPackageJson.peerDependencies, + }; + if (packageName in allDeps) { + return true; + } + } catch { + // If we can't read app package.json, continue + } + } + + // Check dynamic-plugins.default.yaml for enabled plugins + // Plugins can be installed via dynamic plugins even if not in package.json + // Parse YAML file as text to check for enabled plugins (disabled: false or no disabled field) + const dynamicPluginsPath = path.join( + repoRoot, + 'dynamic-plugins.default.yaml', + ); + if (await fs.pathExists(dynamicPluginsPath)) { + try { + const content = await fs.readFile(dynamicPluginsPath, 'utf-8'); + const lines = content.split('\n'); + + // Patterns to match the plugin package + // Handle various package name formats: + // - OCI: oci://quay.io/rhdh/backstage-plugin-techdocs:... + // - Local: ./dynamic-plugins/dist/backstage-plugin-techdocs + // - Package: @backstage/plugin-techdocs + // - React variants: @backstage/plugin-techdocs-react (uses techdocs translations) + const packagePatterns = [ + packageName, + `backstage-plugin-${pluginName}`, // Matches: backstage-plugin-techdocs + `backstage/core-${pluginName.replace('core-', '')}`, + // Also check for React variants (techdocs-react uses techdocs translations) + `backstage-plugin-${pluginName}-react`, + `plugin-${pluginName}-react`, + // Handle package name without @backstage/ prefix (for dynamic-plugins format) + packageName.replace('@backstage/', ''), + ]; + + // Also check for plugin IDs in config (e.g., "backstage.plugin-home", "backstage.plugin-techdocs") + const pluginIdPatterns = [ + `backstage.plugin-${pluginName}`, + `backstage.core-${pluginName.replace('core-', '')}`, + ]; + + // Special case: home plugin might be referenced via dynamic-home-page or home page mount points + if (pluginName === 'home' || pluginName === 'home-react') { + packagePatterns.push('dynamic-home-page'); + packagePatterns.push('backstage-plugin-dynamic-home-page'); + } + + // Track all plugin entries and their disabled status + const pluginEntries: Array<{ + startLine: number; + disabled: boolean | null; + }> = []; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const trimmedLine = line.trim(); + + // Check if this line contains the package name (starts a new plugin entry) + // Handle both OCI format (oci://...) and local path format (./dynamic-plugins/...) + const matchesPackage = packagePatterns.some(pattern => { + if (line.includes('backend') || line.includes('module')) { + return false; + } + // Check for direct pattern match + if (line.includes(pattern)) { + return true; + } + // Check for local path format: ./dynamic-plugins/dist/backstage-plugin-{name} + if ( + line.includes(`./dynamic-plugins/dist/${pattern}`) || + line.includes(`dynamic-plugins/dist/${pattern}`) + ) { + return true; + } + return false; + }); + const matchesPluginId = pluginIdPatterns.some( + pattern => + trimmedLine.includes(`"${pattern}"`) || + trimmedLine.includes(`'${pattern}'`), + ); + + if ( + matchesPackage || + (matchesPluginId && trimmedLine.startsWith('backstage.')) + ) { + // Found a plugin entry - track it + pluginEntries.push({ startLine: i, disabled: null }); + } + + // For each tracked entry, check for disabled field in the following lines + for (const entry of pluginEntries) { + if (i >= entry.startLine && i < entry.startLine + 10) { + // Check within 10 lines of the package declaration + if (trimmedLine.startsWith('disabled:')) { + entry.disabled = trimmedLine.includes('disabled: true'); + // If we find disabled: false, this entry is enabled + if (trimmedLine.includes('disabled: false')) { + entry.disabled = false; + } + } + } + } + } + + // Check if any entry is enabled (disabled: false or no disabled field) + for (const entry of pluginEntries) { + if (entry.disabled === false || entry.disabled === null) { + return true; + } + } + } catch { + // Skip if we can't read/parse the file + } + } + + // Check if plugin is imported/referenced in app source code + // Look for imports like: from '@backstage/plugin-{name}' or '@backstage/core-{name}' + const searchPatterns = [ + `packages/app/src/**/*.{ts,tsx,js,jsx}`, + `packages/app/src/**/*.tsx`, + `src/**/*.{ts,tsx,js,jsx}`, + ]; + + for (const pattern of searchPatterns) { + try { + const files = glob.sync(pattern, { + cwd: repoRoot, + ignore: [ + '**/node_modules/**', + '**/dist/**', + '**/build/**', + '**/*.test.*', + '**/*.spec.*', + ], + absolute: true, + }); + + // Check first 50 files for performance (plugins are usually imported early) + for (const file of files.slice(0, 50)) { + try { + const content = await fs.readFile(file, 'utf-8'); + // Check for import statements + if ( + content.includes(`from '${packageName}'`) || + content.includes(`from "${packageName}"`) || + content.includes(`require('${packageName}')`) || + content.includes(`require("${packageName}")`) || + content.includes(`'${packageName}'`) || + content.includes(`"${packageName}"`) + ) { + return true; + } + } catch { + // Skip files that can't be read + continue; + } + } + } catch { + // Skip patterns that don't match + continue; + } + } + + // If we can't find evidence of usage, assume it's not used + // This is conservative - we'd rather exclude unused plugins than include them + return false; +} + +/** + * Extract keys from JSON translation file + * Handles both English files and translated files (extracts key structure) + * For core-plugins files, extracts all keys from any language to build the structure + */ +async function extractKeysFromJsonFile( + filePath: string, + isCorePlugins: boolean = false, +): Promise>> { + try { + const content = await fs.readFile(filePath, 'utf-8'); + const data = JSON.parse(content) as unknown; + + // Handle nested structure: { plugin: { en: { key: value } } } or { plugin: { fr: { key: value } } } + if (typeof data === 'object' && data !== null) { + const result: Record> = {}; + + for (const [pluginName, pluginData] of Object.entries(data)) { + if (typeof pluginData !== 'object' || pluginData === null) { + continue; + } + + // For core-plugins, extract from any language (they all have the same keys) + // For RHDH files, prefer 'en' but fall back to other languages + let languageData: Record | null = null; + + if ( + 'en' in pluginData && + typeof pluginData.en === 'object' && + pluginData.en !== null + ) { + languageData = pluginData.en as Record; + } else if (isCorePlugins) { + // For core-plugins, use first available language to extract key structure + for (const [lang, langData] of Object.entries(pluginData)) { + if (typeof langData === 'object' && langData !== null) { + languageData = langData as Record; + break; + } + } + } else { + // For RHDH files, fall back to any language if no 'en' + for (const [lang, langData] of Object.entries(pluginData)) { + if ( + typeof langData === 'object' && + langData !== null && + lang !== 'en' + ) { + languageData = langData as Record; + break; + } + } + } + + if (languageData) { + result[pluginName] = {}; + for (const [key, value] of Object.entries(languageData)) { + if (typeof value === 'string') { + // For core-plugins files, if we're extracting from a non-English file, + // use the key name as the English placeholder value + // This allows us to build the structure even from translated files + if (isCorePlugins && !('en' in pluginData)) { + // Use key as placeholder - the actual English will come from ref files if available + result[pluginName][key] = key; + } else { + // For RHDH files or files with 'en', use the actual value + result[pluginName][key] = value; + } + } + } + } + } + + if (Object.keys(result).length > 0) { + return result; + } + } + + // Handle flat structure: { key: value } or { translations: { key: value } } + const translations = + typeof data === 'object' && data !== null && 'translations' in data + ? (data as { translations: Record }).translations + : (data as Record); + + if (typeof translations !== 'object' || translations === null) { + return {}; + } + + // Try to detect plugin name from file path + const pluginName = detectPluginName(filePath) || 'translations'; + const result: Record> = {}; + result[pluginName] = {}; + + for (const [key, value] of Object.entries(translations)) { + if (typeof value === 'string') { + result[pluginName][key] = value; + } + } + + return result; + } catch (error) { + console.warn( + chalk.yellow( + `โš ๏ธ Warning: Could not extract keys from JSON file ${filePath}: ${error}`, + ), + ); + return {}; + } +} + /** * Detect plugin name from file path */ @@ -200,9 +935,11 @@ async function extractAndGroupKeys( for (const filePath of sourceFiles) { try { const content = await fs.readFile(filePath, 'utf-8'); - const keys = extractTranslationKeys(content, filePath); + const extractResult = extractTranslationKeys(content, filePath); + const keys = extractResult.keys; - const pluginName = detectPluginName(filePath); + // Use plugin ID from createTranslationRef if available, otherwise use file path + const pluginName = extractResult.pluginId || detectPluginName(filePath); if (!pluginName) { console.warn( @@ -235,13 +972,14 @@ async function extractAndGroupKeys( // Merge keys into plugin group (warn about overwrites) const overwrittenKeys: string[] = []; for (const [key, value] of Object.entries(keys)) { + const stringValue = String(value); if ( pluginGroups[pluginName][key] && - pluginGroups[pluginName][key] !== value + pluginGroups[pluginName][key] !== stringValue ) { overwrittenKeys.push(key); } - pluginGroups[pluginName][key] = value; + pluginGroups[pluginName][key] = stringValue; } if (overwrittenKeys.length > 0) { @@ -357,7 +1095,32 @@ function displaySummary( } export async function generateCommand(opts: OptionValues): Promise { - console.log(chalk.blue('๐ŸŒ Generating translation reference files...')); + // Handle --core-plugins flag (can be true, 'true', or the flag name) + const corePlugins = Boolean(opts.corePlugins) || opts.corePlugins === 'true'; + const outputFilename = opts.outputFilename as string | undefined; + const sprint = opts.sprint as string | undefined; + + // Validate sprint value if not using custom filename + if (!outputFilename && !sprint) { + throw new Error( + '--sprint is required. Please provide a sprint value (e.g., --sprint s3285)', + ); + } + + // Validate sprint format (should start with 's' followed by numbers, or just numbers) + if (sprint && !/^s?\d+$/i.test(sprint)) { + throw new Error( + `Invalid sprint format: "${sprint}". Sprint should be in format "s3285" or "3285"`, + ); + } + + if (corePlugins) { + console.log( + chalk.blue('๐ŸŒ Generating core-plugins translation reference file...'), + ); + } else { + console.log(chalk.blue('๐ŸŒ Generating RHDH translation reference file...')); + } const config = await loadI18nConfig(); const mergedOpts = await mergeConfigWithOptions(config, opts); @@ -383,34 +1146,450 @@ export async function generateCommand(opts: OptionValues): Promise { try { await fs.ensureDir(outputDir); - const translationKeys: - | Record - | Record }> = {}; + // Always use nested structure format: { plugin: { en: { key: value } } } + const translationKeys: Record }> = {}; + + // Get backstage repo path early so we can use it for filename generation + const backstageRepoPath = + (opts.backstageRepoPath as string | undefined) || + config.backstageRepoPath || + process.env.BACKSTAGE_REPO_PATH || + null; if (extractKeys) { - console.log( - chalk.yellow(`๐Ÿ“ Scanning ${sourceDir} for translation keys...`), - ); + const structuredData: Record }> = {}; + const repoRoot = process.cwd(); - const allSourceFiles = glob.sync(includePattern, { - cwd: sourceDir, - ignore: excludePattern, - absolute: true, - }); + if (corePlugins) { + // For core-plugins: Scan Backstage plugin packages in Backstage repository + // This is the primary source for English translation keys - const sourceFiles = await findEnglishReferenceFiles( - sourceDir, - includePattern, - excludePattern, - ); + if (!backstageRepoPath) { + console.error( + chalk.red( + 'โŒ Backstage repository path is required for --core-plugins mode', + ), + ); + console.error( + chalk.yellow( + ' Please provide one of:\n' + + ' 1. --backstage-repo-path \n' + + ' 2. Add "backstageRepoPath" to .i18n.config.json\n' + + ' 3. Set BACKSTAGE_REPO_PATH environment variable', + ), + ); + process.exit(1); + } - console.log( - chalk.gray( - `Found ${allSourceFiles.length} files, ${sourceFiles.length} are English reference files`, - ), - ); + console.log( + chalk.yellow( + `๐Ÿ“ Scanning Backstage plugin packages in: ${backstageRepoPath}`, + ), + ); + const pluginRefFiles = await findBackstagePluginTranslationRefs( + backstageRepoPath, + ); + console.log( + chalk.gray( + `Found ${pluginRefFiles.length} Backstage plugin translation ref files`, + ), + ); + + if (pluginRefFiles.length > 0) { + // For core-plugins mode, optionally filter by RHDH usage + // If RHDH repo path is not available, extract all plugins + const rhdhRepoPath = process.env.RHDH_REPO_PATH || null; + const shouldFilterByUsage = Boolean(rhdhRepoPath); + + const usedPlugins = new Set(); + const unusedPlugins = new Set(); + + if (shouldFilterByUsage) { + console.log( + chalk.yellow( + `๐Ÿ” Checking which plugins are actually used in RHDH...`, + ), + ); + + // First pass: collect all plugin names and check usage + for (const refFile of pluginRefFiles) { + try { + const pluginName = extractBackstagePluginName(refFile); + if (!pluginName) continue; + + let mappedPluginName = pluginName.replace(/^plugin-/, ''); + + // Handle special cases + if ( + pluginName === 'plugin-home-react' || + refFile.includes('home-react') + ) { + mappedPluginName = 'home-react'; + } else if (pluginName === 'plugin-home') { + mappedPluginName = 'home'; + } + + // Check if plugin is actually used in RHDH + if (rhdhRepoPath) { + const isUsed = await isPluginUsedInRhdh( + mappedPluginName, + rhdhRepoPath, + ); + if (isUsed) { + usedPlugins.add(mappedPluginName); + } else { + unusedPlugins.add(mappedPluginName); + } + } + } catch (error) { + // Skip if we can't determine usage + continue; + } + } + + if (unusedPlugins.size > 0) { + console.log( + chalk.gray( + ` Skipping ${ + unusedPlugins.size + } unused plugin(s): ${Array.from(unusedPlugins) + .slice(0, 5) + .join(', ')}${unusedPlugins.size > 5 ? '...' : ''}`, + ), + ); + } + console.log( + chalk.gray( + ` Found ${usedPlugins.size} plugin(s) actually used in RHDH`, + ), + ); + } else { + console.log( + chalk.yellow( + `๐Ÿ“ฆ Extracting all Backstage core plugins (RHDH_REPO_PATH not set, skipping usage filter)...`, + ), + ); + } + + // Second pass: extract keys from plugins (filtered by usage if enabled) + for (const refFile of pluginRefFiles) { + try { + let keys: Record = {}; + let pluginName: string | null = null; + + // Handle data.json files (compiled react-intl messages) + if (refFile.endsWith('data.json')) { + try { + const jsonContent = await fs.readJson(refFile); + // data.json typically has structure: { "key": { "defaultMessage": "value", "id": "key" } } + // or flat: { "key": "value" } + if (typeof jsonContent === 'object' && jsonContent !== null) { + for (const [key, value] of Object.entries(jsonContent)) { + if (typeof value === 'string') { + keys[key] = value; + } else if ( + typeof value === 'object' && + value !== null && + 'defaultMessage' in value + ) { + keys[key] = ( + value as { defaultMessage: string } + ).defaultMessage; + } + } + } + // Extract plugin name from file path for data.json files + pluginName = extractBackstagePluginName(refFile); + } catch (jsonError) { + console.warn( + chalk.yellow( + `โš ๏ธ Warning: Could not parse JSON from ${refFile}: ${jsonError}`, + ), + ); + continue; + } + } else { + // Handle TypeScript/JavaScript files + const content = await fs.readFile(refFile, 'utf-8'); + const extractResult = extractTranslationKeys(content, refFile); + keys = extractResult.keys; + + // Use plugin ID from createTranslationRef 'id' field if available + // Fall back to file path extraction if not found + if (extractResult.pluginId) { + pluginName = extractResult.pluginId; + } else { + pluginName = extractBackstagePluginName(refFile); + } + } + + if (pluginName && Object.keys(keys).length > 0) { + let mappedPluginName = pluginName.replace(/^plugin-/, ''); - const structuredData = await extractAndGroupKeys(sourceFiles); + // Handle special cases + if ( + pluginName === 'plugin-home-react' || + refFile.includes('home-react') + ) { + mappedPluginName = 'home-react'; + } else if (pluginName === 'plugin-home') { + mappedPluginName = 'home'; + } + + // Only process if plugin is actually used (if filtering is enabled) + if (shouldFilterByUsage && !usedPlugins.has(mappedPluginName)) { + continue; + } + + if (!structuredData[mappedPluginName]) { + structuredData[mappedPluginName] = { en: {} }; + } + + // Merge keys, prioritize English values from ref files + for (const [key, value] of Object.entries(keys)) { + if (!structuredData[mappedPluginName].en[key]) { + structuredData[mappedPluginName].en[key] = value; + } + } + } + } catch (error) { + console.warn( + chalk.yellow( + `โš ๏ธ Warning: Could not extract from ${refFile}: ${error}`, + ), + ); + } + } + console.log( + chalk.green( + `โœ… Extracted keys from ${usedPlugins.size} Backstage plugin packages used in RHDH`, + ), + ); + } + + // Also check for existing core-plugins translated JSON files to extract key structure + // This is a fallback/secondary source when node_modules doesn't have source .ts files + // Look in translations/ directory (where the example files are stored) + // These files help us identify all keys even if we can't find the English ref files + const corePluginsJsonPatterns = [ + 'translations/core-plugins*.json', + 'translations/**/core-plugins*.json', + ]; + + const translatedFiles: string[] = []; + const outputDirPath = path.resolve( + repoRoot, + String(outputDir || 'i18n'), + ); + const outputFileName = 'core-plugins-reference.json'; + + for (const pattern of corePluginsJsonPatterns) { + try { + const files = glob.sync(pattern, { + cwd: repoRoot, + ignore: [ + '**/node_modules/**', + '**/dist/**', + '**/build/**', + '**/test/**', + ], + absolute: true, + }); + // Filter out: + // 1. Files in the output directory (they might be our generated files) + // 2. Files with "reference" in the name (those are English reference files, not translated) + const filteredFiles = files.filter(file => { + const relativePath = path.relative(repoRoot, file); + const fileName = path.basename(file); + + // Exclude if it's in the output directory and is a reference file + if ( + file.startsWith(outputDirPath) && + fileName === outputFileName + ) { + return false; + } + + // Exclude reference files (we want translated files like -fr.json, -it.json, or -fr-C.json, -it-C.json, etc.) + if ( + fileName.includes('reference') || + fileName.includes('core-plugins-reference') + ) { + return false; + } + + // Include files that look like translated files (have language codes) + return true; + }); + translatedFiles.push(...filteredFiles); + } catch { + // Ignore if pattern doesn't match + } + } + + if (translatedFiles.length > 0) { + console.log( + chalk.yellow( + `๐Ÿ“ Scanning ${translatedFiles.length} existing core-plugins file(s) to extract translation keys...`, + ), + ); + for (const translatedFile of translatedFiles) { + console.log( + chalk.gray( + ` Processing: ${path.relative(repoRoot, translatedFile)}`, + ), + ); + const translatedKeys = await extractKeysFromJsonFile( + translatedFile, + true, + ); + + // Log what we extracted + const totalKeys = Object.values(translatedKeys).reduce( + (sum, keys) => sum + Object.keys(keys).length, + 0, + ); + const pluginCount = Object.keys(translatedKeys).length; + console.log( + chalk.gray( + ` Extracted ${totalKeys} keys from ${pluginCount} plugin${ + pluginCount !== 1 ? 's' : '' + }`, + ), + ); + + // Extract keys from translated files (they have the structure we need) + // For each plugin, extract all keys - use key name as placeholder if no English value + // This ensures we capture all keys even if ref files don't have them + for (const [pluginName, pluginData] of Object.entries( + translatedKeys, + )) { + if (!structuredData[pluginName]) { + structuredData[pluginName] = { en: {} }; + } + // pluginData is Record - these are the translation keys + // For core-plugins from translated files, the value is the key name (placeholder) + // Only add if we don't already have an English value from ref files + for (const [key, value] of Object.entries(pluginData)) { + if (!structuredData[pluginName].en[key]) { + // Use the value (should be key name as placeholder for core-plugins from non-English files) + // This ensures we have the key structure even if English values aren't available + structuredData[pluginName].en[key] = value; + } + } + } + } + console.log( + chalk.green( + `โœ… Extracted keys from ${translatedFiles.length} existing core-plugins translation file(s)`, + ), + ); + } + } else { + // For RHDH-specific: Only scan RHDH TypeScript files and JSON files (exclude Backstage core plugins) + console.log( + chalk.yellow(`๐Ÿ“ Scanning ${sourceDir} for translation keys...`), + ); + + const allSourceFiles = glob.sync(includePattern, { + cwd: sourceDir, + ignore: excludePattern, + absolute: true, + }); + + const sourceFiles = await findEnglishReferenceFiles( + sourceDir, + includePattern, + excludePattern, + ); + + console.log( + chalk.gray( + `Found ${allSourceFiles.length} files, ${sourceFiles.length} are English reference files`, + ), + ); + + const rhdhData = await extractAndGroupKeys(sourceFiles); + + // Also scan JSON translation files (for RHDH repo) + console.log(chalk.yellow(`๐Ÿ“ Scanning for JSON translation files...`)); + const jsonFiles = await findJsonTranslationFiles(sourceDir); + console.log( + chalk.gray(`Found ${jsonFiles.length} JSON translation files`), + ); + + if (jsonFiles.length > 0) { + for (const jsonFile of jsonFiles) { + const jsonKeys = await extractKeysFromJsonFile(jsonFile); + // Merge JSON keys into structured data + for (const [pluginName, keys] of Object.entries(jsonKeys)) { + if (!rhdhData[pluginName]) { + rhdhData[pluginName] = { en: {} }; + } + // Merge keys, only add if key doesn't exist (preserve English from TypeScript) + for (const [key, value] of Object.entries(keys)) { + if (!rhdhData[pluginName].en[key]) { + rhdhData[pluginName].en[key] = value; + } + } + } + } + console.log( + chalk.green( + `โœ… Merged keys from ${jsonFiles.length} JSON translation files`, + ), + ); + } + + // Filter to only include RHDH-specific plugins (exclude Backstage core plugins) + // RHDH-specific plugins: catalog (overrides), catalog-import, core-components (overrides), rhdh, scaffolder (overrides), search (overrides), user-settings + const backstageCorePlugins = new Set([ + 'home', + 'catalog-graph', + 'api-docs', + 'kubernetes', + 'kubernetes-cluster', + 'techdocs', + 'home-react', + 'catalog-react', + 'org', + 'search-react', + 'kubernetes-react', + 'scaffolder-react', + ]); + + for (const [pluginName, pluginData] of Object.entries(rhdhData)) { + // Only include if it's not a Backstage core plugin + // Or if it's a core plugin but has RHDH-specific overrides (like catalog, scaffolder, search) + if ( + !backstageCorePlugins.has(pluginName) || + pluginName === 'catalog' || + pluginName === 'scaffolder' || + pluginName === 'search' || + pluginName === 'core-components' || + pluginName === 'catalog-import' || + pluginName === 'user-settings' + ) { + structuredData[pluginName] = pluginData; + } + } + } + + // For core-plugins, we want to include ALL Backstage core plugins that are used/installed + // Even if they have RHDH overrides, the base Backstage translations should be in core-plugins-reference.json + // The RHDH-specific overrides go in reference.json, but base translations belong in core-plugins-reference.json + if (corePlugins) { + // Note: React plugins (catalog-react, search-react, scaffolder-react) have their own unique translations + // and should be included in the core-plugins-reference.json file. + // They are NOT just wrappers - they have distinct translation keys that are separate from base plugins. + console.log( + chalk.gray( + ` Including all ${ + Object.keys(structuredData).length + } core plugins (including React versions with unique translations)`, + ), + ); + } const totalKeys = Object.values(structuredData).reduce( (sum, pluginData) => sum + Object.keys(pluginData.en || {}).length, @@ -428,13 +1607,63 @@ export async function generateCommand(opts: OptionValues): Promise { } const formatStr = String(format || 'json'); - const outputPath = path.join( - String(outputDir || 'i18n'), - `reference.${formatStr}`, - ); + // Generate filename in format: -.json + // Examples: rhdh-s3285.json, backstage-s3285.json + let filename: string; + if (outputFilename) { + // Use custom filename if provided (overrides sprint-based naming) + filename = outputFilename.replace(/\.json$/, ''); + } else { + // Auto-generate: -.json + // Sprint is required, so it should be available here + if (!sprint) { + throw new Error( + 'Sprint value is required. Please provide --sprint option (e.g., --sprint s3285)', + ); + } + + // Normalize sprint value (ensure it starts with 's' if not already) + const normalizedSprint = + sprint.startsWith('s') || sprint.startsWith('S') + ? sprint.toLowerCase() + : `s${sprint}`; + + let repoName: string; + if (corePlugins) { + // For core-plugins mode, detect repo name from Backstage repository path + // Use the backstageRepoPath that was already determined earlier + if (backstageRepoPath) { + repoName = detectRepoName(backstageRepoPath); + } else { + // Fallback to "backstage" if path not available (shouldn't happen as we check earlier) + repoName = 'backstage'; + } + } else { + // For RHDH mode, detect repo name from current directory + repoName = detectRepoName(); + } + + filename = `${repoName.toLowerCase()}-${normalizedSprint}`; + } + + // For core-plugins mode, output to the backstage repo's i18n directory + let finalOutputDir: string; + if (corePlugins && backstageRepoPath) { + // Use the backstage repo's i18n directory + finalOutputDir = path.join(backstageRepoPath, 'i18n'); + // Ensure the directory exists + await fs.ensureDir(finalOutputDir); + } else { + // For RHDH mode, use the configured outputDir (relative to current working directory) + finalOutputDir = String(outputDir || 'i18n'); + } + + const outputPath = path.join(finalOutputDir, `${filename}.${formatStr}`); + + // Always pass as nested structure to match reference.json format await generateOrMergeFiles( - translationKeys, + translationKeys as Record }>, outputPath, formatStr, mergeExisting, diff --git a/workspaces/translations/packages/cli/src/commands/index.ts b/workspaces/translations/packages/cli/src/commands/index.ts index 03ebc3dbb4..4cd756c92e 100644 --- a/workspaces/translations/packages/cli/src/commands/index.ts +++ b/workspaces/translations/packages/cli/src/commands/index.ts @@ -39,6 +39,10 @@ export function registerCommands(program: Command) { command .command('generate') .description('Generate translation reference files from source code') + .requiredOption( + '--sprint ', + 'Sprint value for filename (e.g., s3285). Format: -reference-.json', + ) .option( '--source-dir ', 'Source directory to scan for translatable strings', @@ -62,6 +66,18 @@ export function registerCommands(program: Command) { ) .option('--extract-keys', 'Extract translation keys from source code', true) .option('--merge-existing', 'Merge with existing translation files', false) + .option( + '--core-plugins', + 'Generate core-plugins reference (Backstage plugins only) instead of RHDH-specific reference', + ) + .option( + '--output-filename ', + 'Custom output filename (overrides sprint-based naming)', + ) + .option( + '--backstage-repo-path ', + 'Path to Backstage repository root (for core-plugins mode). Defaults to checking BACKSTAGE_REPO_PATH env var or config', + ) .action(wrapCommand(generateCommand)); // Upload command - upload translation reference files to TMS @@ -76,7 +92,7 @@ export function registerCommands(program: Command) { .option('--source-file ', 'Source translation file to upload') .option( '--upload-filename ', - 'Custom filename for TMS upload (default: {repo-name}-reference-{date}.json)', + 'Custom filename for TMS upload (default: {repo-name}-{sprint}.json, extracts sprint from source filename)', ) .option( '--target-languages ', @@ -154,6 +170,10 @@ export function registerCommands(program: Command) { .description( 'Complete i18n workflow: generate โ†’ upload โ†’ download โ†’ deploy', ) + .requiredOption( + '--sprint ', + 'Sprint value for filename (e.g., s3285). Required for generate step.', + ) .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') @@ -181,8 +201,7 @@ export function registerCommands(program: Command) { ) .option( '--memsource-venv ', - 'Path to Memsource CLI virtual environment', - '${HOME}/git/memsource-cli-client/.memsource/bin/activate', + 'Path to Memsource CLI virtual environment (will auto-detect or prompt if not provided)', ) .action(wrapCommand(initCommand)); @@ -194,8 +213,7 @@ export function registerCommands(program: Command) { ) .option( '--memsource-venv ', - 'Path to Memsource CLI virtual environment', - '${HOME}/git/memsource-cli-client/.memsource/bin/activate', + 'Path to Memsource CLI virtual environment (will auto-detect or prompt if not provided)', ) .option( '--memsource-url ', diff --git a/workspaces/translations/packages/cli/src/commands/setupMemsource.ts b/workspaces/translations/packages/cli/src/commands/setupMemsource.ts index 7eca566674..53959677eb 100644 --- a/workspaces/translations/packages/cli/src/commands/setupMemsource.ts +++ b/workspaces/translations/packages/cli/src/commands/setupMemsource.ts @@ -235,6 +235,131 @@ function displaySetupInstructions(memsourceRcPath: string): void { ); } +/** + * Try to detect common memsource CLI virtual environment locations + */ +async function detectMemsourceVenv(): Promise { + const homeDir = os.homedir(); + const commonPaths = [ + // Common installation locations + path.join( + homeDir, + 'git', + 'memsource-cli-client', + '.memsource', + 'bin', + 'activate', + ), + path.join(homeDir, 'memsource-cli-client', '.memsource', 'bin', 'activate'), + path.join(homeDir, '.memsource', 'bin', 'activate'), + path.join( + homeDir, + '.local', + 'memsource-cli-client', + '.memsource', + 'bin', + 'activate', + ), + ]; + + for (const venvPath of commonPaths) { + if (venvPath && (await fs.pathExists(venvPath))) { + return venvPath; + } + } + + // If memsource command exists, user might have it installed differently + // We'll let them specify the path manually + return null; +} + +/** + * Prompt for memsource virtual environment path + */ +async function promptMemsourceVenv(rl: readline.Interface): Promise { + const question = (query: string): Promise => { + return new Promise(resolve => { + rl.question(query, resolve); + }); + }; + + console.log(chalk.yellow('\n๐Ÿ“ Memsource CLI Virtual Environment Path')); + console.log( + chalk.gray(' The memsource CLI requires a Python virtual environment.'), + ); + console.log(chalk.gray(' Common locations:')); + console.log( + chalk.gray(' - ~/git/memsource-cli-client/.memsource/bin/activate'), + ); + console.log( + chalk.gray(' - ~/memsource-cli-client/.memsource/bin/activate'), + ); + console.log(chalk.gray(' - ~/.memsource/bin/activate')); + console.log( + chalk.gray( + ' Or wherever you installed the memsource-cli-client repository.\n', + ), + ); + + const venvPath = await question( + chalk.yellow('Enter path to memsource venv activate script: '), + ); + if (!venvPath || venvPath.trim() === '') { + rl.close(); + throw new Error('Virtual environment path is required'); + } + return venvPath.trim(); +} + +/** + * Get memsource venv path from options, detection, or prompt + */ +async function getMemsourceVenv( + providedPath: string | undefined, + isInteractive: boolean, + noInput: boolean, +): Promise { + // If provided via option, use it + if (providedPath) { + return providedPath; + } + + // Try to detect common locations + const detectedPath = await detectMemsourceVenv(); + if (detectedPath) { + console.log( + chalk.gray( + ` Detected memsource venv at: ${detectedPath.replace( + os.homedir(), + '~', + )}`, + ), + ); + return detectedPath; + } + + // If interactive, prompt for it + if (isInteractive && !noInput) { + const rl = readline.createInterface({ + input: stdin, + output: stdout, + }); + + try { + return await promptMemsourceVenv(rl); + } finally { + rl.close(); + } + } + + // If not interactive or no-input, throw error + throw new Error( + 'Memsource virtual environment path is required. ' + + 'Provide it via --memsource-venv option, ' + + 'or run in an interactive terminal to be prompted.', + ); +} + /** * Check and warn about virtual environment path */ @@ -254,6 +379,11 @@ async function checkVirtualEnvironment(memsourceVenv: string): Promise { ' Please update the path in ~/.memsourcerc if your venv is located elsewhere.', ), ); + console.log( + chalk.gray( + ' You can edit ~/.memsourcerc and update the "source" line with the correct path.', + ), + ); } } @@ -266,7 +396,7 @@ export async function setupMemsourceCommand(opts: OptionValues): Promise { ); const { - memsourceVenv = '${HOME}/git/memsource-cli-client/.memsource/bin/activate', + memsourceVenv, memsourceUrl = 'https://cloud.memsource.com/web', username, password, @@ -276,11 +406,18 @@ export async function setupMemsourceCommand(opts: OptionValues): Promise { const isInteractive = isInteractiveTerminal(); const noInput = opts.noInput === true; + // Get memsource venv path (detect, prompt, or use provided) + const finalMemsourceVenv = await getMemsourceVenv( + memsourceVenv, + isInteractive, + noInput, + ); + const { username: finalUsername, password: finalPassword } = await getCredentials(isInteractive, noInput, username, password); const memsourceRcContent = generateMemsourceRcContent( - memsourceVenv, + finalMemsourceVenv, memsourceUrl, finalUsername, finalPassword, @@ -290,7 +427,7 @@ export async function setupMemsourceCommand(opts: OptionValues): Promise { await fs.writeFile(memsourceRcPath, memsourceRcContent, { mode: 0o600 }); displaySetupInstructions(memsourceRcPath); - await checkVirtualEnvironment(memsourceVenv); + await checkVirtualEnvironment(finalMemsourceVenv); } 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 fb1a86a2eb..7b591e84e0 100644 --- a/workspaces/translations/packages/cli/src/commands/sync.ts +++ b/workspaces/translations/packages/cli/src/commands/sync.ts @@ -14,10 +14,13 @@ * limitations under the License. */ +import path from 'node:path'; + import { OptionValues } from 'commander'; import chalk from 'chalk'; import { loadI18nConfig, mergeConfigWithOptions } from '../lib/i18n/config'; +import { safeExecSyncOrThrow } from '../lib/utils/exec'; import { generateCommand } from './generate'; import { uploadCommand } from './upload'; @@ -28,6 +31,7 @@ interface SyncOptions { sourceDir: string; outputDir: string; localesDir: string; + sprint?: string; tmsUrl?: string; tmsToken?: string; projectId?: string; @@ -72,35 +76,88 @@ function simulateStep(stepName: string): void { async function stepGenerate( sourceDir: string, outputDir: string, + sprint: string | undefined, dryRun: boolean, -): Promise { +): Promise<{ step: string; generatedFile?: string }> { console.log( chalk.blue('\n๐Ÿ“ Step 1: Generating translation reference files...'), ); 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 { step: 'Generate' }; + } + let generatedFile: string | undefined; + await executeStep('generate translation files', async () => { + if (!sprint) { + throw new Error( + '--sprint is required for generate command. Please provide --sprint option (e.g., --sprint s3285)', + ); + } + await generateCommand({ + sourceDir, + outputDir, + sprint, + format: 'json', + includePattern: '**/*.{ts,tsx,js,jsx}', + excludePattern: '**/node_modules/**', + extractKeys: true, + mergeExisting: false, }); + + // Try to determine the generated filename + // Format: {repo}-{sprint}.json + const repoName = detectRepoName(); + const normalizedSprint = + sprint.startsWith('s') || sprint.startsWith('S') + ? sprint.toLowerCase() + : `s${sprint}`; + generatedFile = `${repoName.toLowerCase()}-${normalizedSprint}.json`; + }); + return { step: 'Generate', generatedFile }; +} + +/** + * Detect repository name from git or directory + */ +function detectRepoName(repoPath?: string): string { + const targetPath = repoPath || process.cwd(); + + try { + // Try to get repo name from git + const gitRepoUrl = safeExecSyncOrThrow( + 'git', + ['config', '--get', 'remote.origin.url'], + { + cwd: targetPath, + }, + ); + if (gitRepoUrl) { + // Extract repo name from URL (handles both https and ssh formats) + let repoName = gitRepoUrl.replace(/\.git$/, ''); + const lastSlashIndex = repoName.lastIndexOf('/'); + if (lastSlashIndex >= 0) { + repoName = repoName.substring(lastSlashIndex + 1); + } + if (repoName) { + return repoName; + } + } + } catch { + // Git not available or not a git repo } - return 'Generate'; + // Fallback: use directory name + return path.basename(targetPath); } /** * Step 2: Upload to TMS */ -async function stepUpload(options: SyncOptions): Promise { +async function stepUpload( + options: SyncOptions, + generatedFile?: string, +): Promise { if (options.skipUpload) { console.log(chalk.yellow('โญ๏ธ Skipping upload: --skip-upload specified')); return null; @@ -117,6 +174,27 @@ async function stepUpload(options: SyncOptions): Promise { console.log(chalk.blue('\n๐Ÿ“ค Step 2: Uploading to TMS...')); + // Determine source file path + // Use generated file if available, otherwise try to construct from sprint + let sourceFile: string; + if (generatedFile) { + sourceFile = `${options.outputDir}/${generatedFile}`; + } else if (options.sprint) { + // Fallback: construct filename from sprint + const repoName = detectRepoName(); + const normalizedSprint = + options.sprint.startsWith('s') || options.sprint.startsWith('S') + ? options.sprint.toLowerCase() + : `s${options.sprint}`; + sourceFile = `${ + options.outputDir + }/${repoName.toLowerCase()}-${normalizedSprint}.json`; + } else { + throw new Error( + 'Cannot determine source file for upload. Please provide --sprint option or ensure generate step completed successfully.', + ); + } + if (options.dryRun) { simulateStep('upload to TMS'); } else { @@ -125,7 +203,7 @@ async function stepUpload(options: SyncOptions): Promise { tmsUrl, tmsToken, projectId, - sourceFile: `${options.outputDir}/reference.json`, + sourceFile, targetLanguages: options.languages, dryRun: false, }); @@ -236,6 +314,10 @@ export async function syncCommand(opts: OptionValues): Promise { sourceDir: String(mergedOpts.sourceDir || 'src'), outputDir: String(mergedOpts.outputDir || 'i18n'), localesDir: String(mergedOpts.localesDir || 'src/locales'), + sprint: + mergedOpts.sprint && typeof mergedOpts.sprint === 'string' + ? mergedOpts.sprint + : undefined, tmsUrl: mergedOpts.tmsUrl && typeof mergedOpts.tmsUrl === 'string' ? mergedOpts.tmsUrl @@ -259,15 +341,16 @@ export async function syncCommand(opts: OptionValues): Promise { }; try { - const generateStep = await stepGenerate( + const generateResult = await stepGenerate( options.sourceDir, options.outputDir, + options.sprint, options.dryRun, ); const allSteps = [ - generateStep, - await stepUpload(options), + generateResult.step, + await stepUpload(options, generateResult.generatedFile), await stepDownload(options), await stepDeploy(options), ]; diff --git a/workspaces/translations/packages/cli/src/commands/upload.ts b/workspaces/translations/packages/cli/src/commands/upload.ts index 2120d09805..d1284b05a7 100644 --- a/workspaces/translations/packages/cli/src/commands/upload.ts +++ b/workspaces/translations/packages/cli/src/commands/upload.ts @@ -33,14 +33,18 @@ import { countTranslationKeys } from '../lib/utils/translationUtils'; /** * Detect repository name from git or directory */ -function detectRepoName(): string { +function detectRepoName(repoPath?: string): string { + const targetPath = repoPath || process.cwd(); + try { // Try to get repo name from git - const gitRepoUrl = safeExecSyncOrThrow('git', [ - 'config', - '--get', - 'remote.origin.url', - ]); + const gitRepoUrl = safeExecSyncOrThrow( + 'git', + ['config', '--get', 'remote.origin.url'], + { + cwd: targetPath, + }, + ); if (gitRepoUrl) { // Extract repo name from URL (handles both https and ssh formats) // Use a safer regex pattern to avoid ReDoS vulnerability @@ -58,16 +62,18 @@ function detectRepoName(): string { // Git not available or not a git repo } - // Fallback: use current directory name - return path.basename(process.cwd()); + // Fallback: use directory name + return path.basename(targetPath); } /** - * Generate upload filename: {repo-name}-reference-{YYYY-MM-DD}.json + * Generate upload filename: {repo-name}-{sprint}.json + * Tries to extract sprint from source filename, or uses date as fallback */ function generateUploadFileName( sourceFile: string, customName?: string, + sprint?: string, ): string { if (customName) { // Use custom name if provided, ensure it has the right extension @@ -75,11 +81,60 @@ function generateUploadFileName( 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 + // Try to extract sprint from source filename if not provided + let sprintValue = sprint; + if (!sprintValue) { + const sourceBasename = path.basename(sourceFile, path.extname(sourceFile)); + // Try to match pattern: - or -reference- + let sprintMatch = sourceBasename.match(/^[a-z-]+-(s?\d+)$/i); + if (!sprintMatch) { + // Fallback: try old pattern with "reference" + sprintMatch = sourceBasename.match(/-reference-(s?\d+)$/i); + } + if (sprintMatch) { + sprintValue = sprintMatch[1]; + } + } + + // Auto-generate: {repo-name}-{sprint}.json or {repo-name}-{date}.json (fallback) + // Try to detect repo name from the source file's git root + // This handles cases where we're uploading files from a different repo + const sourceFileAbs = path.resolve(sourceFile); + const sourceDir = path.dirname(sourceFileAbs); + + // Try to find git root by walking up from source file directory + let repoRoot: string | undefined; + let currentDir = sourceDir; + while (currentDir !== path.dirname(currentDir)) { + const gitDir = path.join(currentDir, '.git'); + try { + if (fs.statSync(gitDir).isDirectory()) { + repoRoot = currentDir; + break; + } + } catch { + // .git doesn't exist, continue walking up + } + currentDir = path.dirname(currentDir); + } + + // Use detected repo root, or fallback to current directory + const repoName = repoRoot ? detectRepoName(repoRoot) : detectRepoName(); + + // Use sprint if available, otherwise fall back to date + let identifier: string; + if (sprintValue) { + if (sprintValue.startsWith('s') || sprintValue.startsWith('S')) { + identifier = sprintValue.toLowerCase(); + } else { + identifier = `s${sprintValue}`; + } + } else { + identifier = new Date().toISOString().split('T')[0]; // YYYY-MM-DD fallback + } + const ext = path.extname(sourceFile); - return `${repoName}-reference-${date}${ext}`; + return `${repoName}-${identifier}${ext}`; } /** @@ -467,6 +522,7 @@ export async function uploadCommand(opts: OptionValues): Promise { sourceFile, targetLanguages, uploadFileName, + uploadFilename, // Commander.js converts --upload-filename to uploadFilename dryRun = false, force = false, } = mergedOpts as { @@ -476,10 +532,14 @@ export async function uploadCommand(opts: OptionValues): Promise { sourceFile?: string; targetLanguages?: string; uploadFileName?: string; + uploadFilename?: string; // Commander.js camelCase conversion dryRun?: boolean; force?: boolean; }; + // Use uploadFilename (from CLI) or uploadFileName (from config), preferring CLI + const finalUploadFileNameOption = uploadFilename || uploadFileName; + const tmsConfig = validateTmsConfig(tmsUrl, tmsToken, projectId); if (!tmsConfig) { const tmsUrlStr = extractStringOption(tmsUrl); @@ -498,9 +558,10 @@ export async function uploadCommand(opts: OptionValues): Promise { try { await validateSourceFile(sourceFileStr); + // Try to extract sprint from source filename or use provided upload filename const finalUploadFileName = - uploadFileName && typeof uploadFileName === 'string' - ? generateUploadFileName(sourceFileStr, uploadFileName) + finalUploadFileNameOption && typeof finalUploadFileNameOption === 'string' + ? generateUploadFileName(sourceFileStr, finalUploadFileNameOption) : generateUploadFileName(sourceFileStr); const cachedEntry = await getCachedUpload( diff --git a/workspaces/translations/packages/cli/src/lib/i18n/config.ts b/workspaces/translations/packages/cli/src/lib/i18n/config.ts index ff70eb31ba..10dc3c3bba 100644 --- a/workspaces/translations/packages/cli/src/lib/i18n/config.ts +++ b/workspaces/translations/packages/cli/src/lib/i18n/config.ts @@ -41,6 +41,7 @@ export interface I18nProjectConfig { include?: string; exclude?: string; }; + backstageRepoPath?: string; } /** diff --git a/workspaces/translations/packages/cli/src/lib/i18n/extractKeys.ts b/workspaces/translations/packages/cli/src/lib/i18n/extractKeys.ts index d5467a04a7..59250991cd 100644 --- a/workspaces/translations/packages/cli/src/lib/i18n/extractKeys.ts +++ b/workspaces/translations/packages/cli/src/lib/i18n/extractKeys.ts @@ -24,14 +24,26 @@ export interface TranslationKey { column: number; } +export interface ExtractResult { + keys: Record; + pluginId: string | null; +} + /** * Extract translation keys from TypeScript/JavaScript source code */ export function extractTranslationKeys( content: string, filePath: string, -): Record { +): ExtractResult { const keys: Record = {}; + let pluginId: string | null = null; + + // For .d.ts files, use regex extraction as they have declaration syntax + // that's harder to parse with AST + if (filePath.endsWith('.d.ts')) { + return { keys: extractKeysWithRegex(content), pluginId: null }; + } try { // Parse the source code @@ -42,6 +54,9 @@ export function extractTranslationKeys( true, ); + // Track invalid keys for warning + const invalidKeys: string[] = []; + // Extract from exported object literals (Backstage translation ref pattern) // Pattern: export const messages = { key: 'value', nested: { key: 'value' } } // Also handles type assertions: { ... } as any @@ -68,8 +83,27 @@ export function extractTranslationKeys( return null; }; + /** + * Validate that a key is a valid dot-notation identifier + * Pattern: validIdentifier(.validIdentifier)* + * Each segment must be a valid JavaScript identifier + */ + const isValidKey = (key: string): boolean => { + if (!key || key.trim() === '') { + return false; + } + + // Check if key is valid dot-notation identifier + // Each segment must be a valid JavaScript identifier: [a-zA-Z_$][a-zA-Z0-9_$]* + // Segments separated by dots + return /^[a-zA-Z_$][a-zA-Z0-9_$]*(?:\.[a-zA-Z_$][a-zA-Z0-9_$]*)*$/.test( + key, + ); + }; + /** * Extract translation keys from an object literal expression + * Validates keys during extraction for better performance */ const extractFromObjectLiteral = (node: ts.Node, prefix = ''): void => { const objectNode = unwrapTypeAssertion(node); @@ -89,6 +123,16 @@ export function extractTranslationKeys( } const fullKey = prefix ? `${prefix}.${keyName}` : keyName; + + // Validate key before processing (inline validation for better performance) + if (!isValidKey(fullKey)) { + // Track invalid keys for warning (avoid duplicate warnings) + if (!invalidKeys.includes(fullKey)) { + invalidKeys.push(fullKey); + } + continue; + } + const initializer = property.initializer; if (!initializer) { continue; @@ -99,6 +143,24 @@ export function extractTranslationKeys( if (ts.isStringLiteral(unwrappedInitializer)) { // Leaf node - this is a translation value keys[fullKey] = unwrappedInitializer.text; + } else if (ts.isTemplateExpression(unwrappedInitializer)) { + // Template literal with substitutions (backticks with ${variable}) + // Extract the text content + let templateText = ''; + for (const part of unwrappedInitializer.templateSpans) { + if (part.literal) { + templateText += part.literal.text; + } + } + // Also include the head text if present + if (unwrappedInitializer.head) { + templateText = unwrappedInitializer.head.text + templateText; + } + keys[fullKey] = templateText; + } else if (ts.isNoSubstitutionTemplateLiteral(unwrappedInitializer)) { + // Simple template literal without substitutions (backticks like `{{orgName}} Catalog`) + // Extract the raw text content + keys[fullKey] = unwrappedInitializer.text; } else if (ts.isObjectLiteralExpression(unwrappedInitializer)) { // Nested object - recurse extractFromObjectLiteral(unwrappedInitializer, fullKey); @@ -113,11 +175,13 @@ export function extractTranslationKeys( property: ts.ObjectLiteralElementLike, propertyName: string, ): void => { - if ( - !ts.isPropertyAssignment(property) || - !ts.isIdentifier(property.name) || - property.name.text !== propertyName - ) { + if (!ts.isPropertyAssignment(property)) { + return; + } + + // Check if property name matches (can be identifier or string literal) + const propName = extractPropertyKeyName(property.name); + if (propName !== propertyName) { return; } @@ -129,17 +193,39 @@ export function extractTranslationKeys( /** * Extract from createTranslationRef calls - * Pattern: createTranslationRef({ id: '...', messages: { key: 'value' } }) + * Pattern: createTranslationRef({ id: 'plugin-id', messages: { key: 'value' } }) + * Returns the plugin ID from the 'id' field and extracts messages */ - const extractFromCreateTranslationRef = (node: ts.CallExpression): void => { + const extractFromCreateTranslationRef = ( + node: ts.CallExpression, + ): string | null => { const args = node.arguments; if (args.length === 0 || !ts.isObjectLiteralExpression(args[0])) { - return; + return null; } + let extractedPluginId: string | null = null; + let foundMessages = false; + for (const property of args[0].properties) { - extractMessagesFromProperty(property, 'messages'); + // Extract plugin ID from 'id' field + if ( + ts.isPropertyAssignment(property) && + ts.isIdentifier(property.name) && + property.name.text === 'id' && + ts.isStringLiteral(property.initializer) + ) { + extractedPluginId = property.initializer.text; + } + // Extract messages + const propName = extractPropertyKeyName(property.name); + if (propName === 'messages') { + foundMessages = true; + extractMessagesFromProperty(property, 'messages'); + } } + + return extractedPluginId; }; /** @@ -236,6 +322,15 @@ export function extractTranslationKeys( } const key = args[0].text; + + // Validate key before storing (inline validation for better performance) + if (!isValidKey(key)) { + if (!invalidKeys.includes(key)) { + invalidKeys.push(key); + } + return; + } + const value = args.length > 1 && ts.isStringLiteral(args[1]) ? args[1].text : key; keys[key] = value; @@ -356,13 +451,57 @@ export function extractTranslationKeys( // Visit all nodes in the AST const visit = (node: ts.Node) => { if (isCallExpressionWithName(node, 'createTranslationRef')) { - extractFromCreateTranslationRef(node); + const extractedPluginId = extractFromCreateTranslationRef(node); + // Use the first plugin ID found (most files have one createTranslationRef) + if (extractedPluginId && !pluginId) { + pluginId = extractedPluginId; + } } else if (isCallExpressionWithName(node, 'createTranslationResource')) { extractFromCreateTranslationResource(node); } else if (isCallExpressionWithName(node, 'createTranslationMessages')) { extractFromCreateTranslationMessages(node); + } else if (isCallExpressionWithName(node, 'defineMessages')) { + extractFromDefineMessages(node); } else if (ts.isVariableStatement(node)) { extractFromVariableStatement(node); + // Also check for TranslationRef type declarations in variable declarations + // Pattern: declare const X: TranslationRef<"id", { readonly "key": "value" }> + for (const decl of node.declarationList.declarations) { + if (decl.type && ts.isTypeReference(decl.type)) { + const typeRef = decl.type; + if ( + ts.isIdentifier(typeRef.typeName) && + typeRef.typeName.text === 'TranslationRef' && + typeRef.typeArguments && + typeRef.typeArguments.length >= 2 + ) { + // Second type argument is the messages object type + const messagesType = typeRef.typeArguments[1]; + if (ts.isTypeLiteralNode(messagesType)) { + // Extract keys from the type literal + for (const member of messagesType.members) { + if (ts.isPropertySignature(member) && member.name) { + const keyName = extractPropertyKeyName(member.name); + if ( + keyName && + member.type && + ts.isStringLiteralType(member.type) + ) { + // Validate key before storing (inline validation for better performance) + if (isValidKey(keyName)) { + keys[keyName] = member.type.text; + } else { + if (!invalidKeys.includes(keyName)) { + invalidKeys.push(keyName); + } + } + } + } + } + } + } + } + } } else if (isCallExpressionWithName(node, 't')) { extractFromTFunction(node); } else if (ts.isCallExpression(node) && isI18nTCall(node)) { @@ -378,12 +517,28 @@ export function extractTranslationKeys( }; visit(sourceFile); - } catch { + + // Log warnings for invalid keys if any were found + if (invalidKeys.length > 0) { + // Use console.warn instead of chalk to avoid dependency + // The calling code can format these warnings if needed + console.warn( + `โš ๏ธ Skipped ${ + invalidKeys.length + } invalid key(s) in ${filePath}: ${invalidKeys.slice(0, 5).join(', ')}${ + invalidKeys.length > 5 ? '...' : '' + }`, + ); + } + } catch (error) { // If TypeScript parsing fails, fall back to regex-based extraction - return extractKeysWithRegex(content); + console.warn( + `โš ๏ธ Warning: AST parsing failed for ${filePath}, falling back to regex: ${error}`, + ); + return { keys: extractKeysWithRegex(content), pluginId: null }; } - return keys; + return { keys, pluginId }; } /** @@ -397,6 +552,9 @@ function extractKeysWithRegex(content: string): Record { // 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 = [ + // TranslationRef type declarations: readonly "key": "value" + // Pattern from .d.ts files: readonly "starredEntities.noStarredEntitiesMessage": "Click the star..." + /readonly\s+["']([^"']+)["']\s*:\s*["']([^"']*?)["']/g, // t('key', 'value') - with second parameter /t\s*\(\s*['"`]([^'"`]+?)['"`]\s*,\s*['"`]([^'"`]*?)['"`]\s*\)/g, // t('key') - without second parameter @@ -417,9 +575,23 @@ function extractKeysWithRegex(content: string): Record { let match; // eslint-disable-next-line no-cond-assign while ((match = pattern.exec(content)) !== null) { + // For readonly pattern, match[1] is key and match[2] is value + // For other patterns, match[1] is key and match[2] is optional value const key = match[1]; + + // Validate key before storing (inline validation for better performance) + if (!isValidKeyRegex(key)) { + continue; // Skip invalid keys + } + const value = match[2] || key; - keys[key] = value; + // Only add if we have a meaningful value (not just the key repeated) + if (value && value !== key) { + keys[key] = value; + } else if (!keys[key]) { + // If no value provided, use key as placeholder + keys[key] = key; + } } } diff --git a/workspaces/translations/packages/cli/src/lib/i18n/generateFiles.ts b/workspaces/translations/packages/cli/src/lib/i18n/generateFiles.ts index 8e16b5a715..dac6958f6a 100644 --- a/workspaces/translations/packages/cli/src/lib/i18n/generateFiles.ts +++ b/workspaces/translations/packages/cli/src/lib/i18n/generateFiles.ts @@ -61,9 +61,12 @@ export async function generateTranslationFiles( function isNestedStructure( data: Record | NestedTranslationData, ): data is NestedTranslationData { + // Empty object should be treated as nested structure (matches reference.json format) + const keys = Object.keys(data); + if (keys.length === 0) return true; + // Check if it's nested: { plugin: { en: { ... } } } - const firstKey = Object.keys(data)[0]; - if (!firstKey) return false; + const firstKey = keys[0]; const firstValue = data[firstKey]; return ( typeof firstValue === 'object' && firstValue !== null && 'en' in firstValue From f0b316575cf5b39543495d22cd2a1076d9eddb7f Mon Sep 17 00:00:00 2001 From: Yi Cai Date: Tue, 13 Jan 2026 12:04:20 -0500 Subject: [PATCH 07/30] chore: update yarn.lock to sync with package.json --- workspaces/translations/yarn.lock | 299 +++++++++++++++++++++++++++++- 1 file changed, 298 insertions(+), 1 deletion(-) diff --git a/workspaces/translations/yarn.lock b/workspaces/translations/yarn.lock index accb3d93ee..74c26c07db 100644 --- a/workspaces/translations/yarn.lock +++ b/workspaces/translations/yarn.lock @@ -2039,7 +2039,7 @@ __metadata: languageName: node linkType: hard -"@backstage/cli@npm:^0.34.5": +"@backstage/cli@npm:^0.34.4, @backstage/cli@npm:^0.34.5": version: 0.34.5 resolution: "@backstage/cli@npm:0.34.5" dependencies: @@ -4880,6 +4880,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/aix-ppc64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/aix-ppc64@npm:0.27.2" + conditions: os=aix & cpu=ppc64 + languageName: node + linkType: hard + "@esbuild/android-arm64@npm:0.21.5": version: 0.21.5 resolution: "@esbuild/android-arm64@npm:0.21.5" @@ -4894,6 +4901,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/android-arm64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/android-arm64@npm:0.27.2" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/android-arm@npm:0.21.5": version: 0.21.5 resolution: "@esbuild/android-arm@npm:0.21.5" @@ -4908,6 +4922,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/android-arm@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/android-arm@npm:0.27.2" + conditions: os=android & cpu=arm + languageName: node + linkType: hard + "@esbuild/android-x64@npm:0.21.5": version: 0.21.5 resolution: "@esbuild/android-x64@npm:0.21.5" @@ -4922,6 +4943,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/android-x64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/android-x64@npm:0.27.2" + conditions: os=android & cpu=x64 + languageName: node + linkType: hard + "@esbuild/darwin-arm64@npm:0.21.5": version: 0.21.5 resolution: "@esbuild/darwin-arm64@npm:0.21.5" @@ -4936,6 +4964,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/darwin-arm64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/darwin-arm64@npm:0.27.2" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/darwin-x64@npm:0.21.5": version: 0.21.5 resolution: "@esbuild/darwin-x64@npm:0.21.5" @@ -4950,6 +4985,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/darwin-x64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/darwin-x64@npm:0.27.2" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + "@esbuild/freebsd-arm64@npm:0.21.5": version: 0.21.5 resolution: "@esbuild/freebsd-arm64@npm:0.21.5" @@ -4964,6 +5006,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/freebsd-arm64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/freebsd-arm64@npm:0.27.2" + conditions: os=freebsd & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/freebsd-x64@npm:0.21.5": version: 0.21.5 resolution: "@esbuild/freebsd-x64@npm:0.21.5" @@ -4978,6 +5027,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/freebsd-x64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/freebsd-x64@npm:0.27.2" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + "@esbuild/linux-arm64@npm:0.21.5": version: 0.21.5 resolution: "@esbuild/linux-arm64@npm:0.21.5" @@ -4992,6 +5048,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-arm64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/linux-arm64@npm:0.27.2" + conditions: os=linux & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/linux-arm@npm:0.21.5": version: 0.21.5 resolution: "@esbuild/linux-arm@npm:0.21.5" @@ -5006,6 +5069,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-arm@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/linux-arm@npm:0.27.2" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + "@esbuild/linux-ia32@npm:0.21.5": version: 0.21.5 resolution: "@esbuild/linux-ia32@npm:0.21.5" @@ -5020,6 +5090,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-ia32@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/linux-ia32@npm:0.27.2" + conditions: os=linux & cpu=ia32 + languageName: node + linkType: hard + "@esbuild/linux-loong64@npm:0.21.5": version: 0.21.5 resolution: "@esbuild/linux-loong64@npm:0.21.5" @@ -5034,6 +5111,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-loong64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/linux-loong64@npm:0.27.2" + conditions: os=linux & cpu=loong64 + languageName: node + linkType: hard + "@esbuild/linux-mips64el@npm:0.21.5": version: 0.21.5 resolution: "@esbuild/linux-mips64el@npm:0.21.5" @@ -5048,6 +5132,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-mips64el@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/linux-mips64el@npm:0.27.2" + conditions: os=linux & cpu=mips64el + languageName: node + linkType: hard + "@esbuild/linux-ppc64@npm:0.21.5": version: 0.21.5 resolution: "@esbuild/linux-ppc64@npm:0.21.5" @@ -5062,6 +5153,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-ppc64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/linux-ppc64@npm:0.27.2" + conditions: os=linux & cpu=ppc64 + languageName: node + linkType: hard + "@esbuild/linux-riscv64@npm:0.21.5": version: 0.21.5 resolution: "@esbuild/linux-riscv64@npm:0.21.5" @@ -5076,6 +5174,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-riscv64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/linux-riscv64@npm:0.27.2" + conditions: os=linux & cpu=riscv64 + languageName: node + linkType: hard + "@esbuild/linux-s390x@npm:0.21.5": version: 0.21.5 resolution: "@esbuild/linux-s390x@npm:0.21.5" @@ -5090,6 +5195,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-s390x@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/linux-s390x@npm:0.27.2" + conditions: os=linux & cpu=s390x + languageName: node + linkType: hard + "@esbuild/linux-x64@npm:0.21.5": version: 0.21.5 resolution: "@esbuild/linux-x64@npm:0.21.5" @@ -5104,6 +5216,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-x64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/linux-x64@npm:0.27.2" + conditions: os=linux & cpu=x64 + languageName: node + linkType: hard + "@esbuild/netbsd-arm64@npm:0.25.9": version: 0.25.9 resolution: "@esbuild/netbsd-arm64@npm:0.25.9" @@ -5111,6 +5230,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/netbsd-arm64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/netbsd-arm64@npm:0.27.2" + conditions: os=netbsd & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/netbsd-x64@npm:0.21.5": version: 0.21.5 resolution: "@esbuild/netbsd-x64@npm:0.21.5" @@ -5125,6 +5251,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/netbsd-x64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/netbsd-x64@npm:0.27.2" + conditions: os=netbsd & cpu=x64 + languageName: node + linkType: hard + "@esbuild/openbsd-arm64@npm:0.25.9": version: 0.25.9 resolution: "@esbuild/openbsd-arm64@npm:0.25.9" @@ -5132,6 +5265,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/openbsd-arm64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/openbsd-arm64@npm:0.27.2" + conditions: os=openbsd & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/openbsd-x64@npm:0.21.5": version: 0.21.5 resolution: "@esbuild/openbsd-x64@npm:0.21.5" @@ -5146,6 +5286,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/openbsd-x64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/openbsd-x64@npm:0.27.2" + conditions: os=openbsd & cpu=x64 + languageName: node + linkType: hard + "@esbuild/openharmony-arm64@npm:0.25.9": version: 0.25.9 resolution: "@esbuild/openharmony-arm64@npm:0.25.9" @@ -5153,6 +5300,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/openharmony-arm64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/openharmony-arm64@npm:0.27.2" + conditions: os=openharmony & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/sunos-x64@npm:0.21.5": version: 0.21.5 resolution: "@esbuild/sunos-x64@npm:0.21.5" @@ -5167,6 +5321,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/sunos-x64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/sunos-x64@npm:0.27.2" + conditions: os=sunos & cpu=x64 + languageName: node + linkType: hard + "@esbuild/win32-arm64@npm:0.21.5": version: 0.21.5 resolution: "@esbuild/win32-arm64@npm:0.21.5" @@ -5181,6 +5342,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/win32-arm64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/win32-arm64@npm:0.27.2" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/win32-ia32@npm:0.21.5": version: 0.21.5 resolution: "@esbuild/win32-ia32@npm:0.21.5" @@ -5195,6 +5363,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/win32-ia32@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/win32-ia32@npm:0.27.2" + conditions: os=win32 & cpu=ia32 + languageName: node + linkType: hard + "@esbuild/win32-x64@npm:0.21.5": version: 0.21.5 resolution: "@esbuild/win32-x64@npm:0.21.5" @@ -5209,6 +5384,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/win32-x64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/win32-x64@npm:0.27.2" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + "@eslint-community/eslint-utils@npm:^4.2.0, @eslint-community/eslint-utils@npm:^4.4.0, @eslint-community/eslint-utils@npm:^4.7.0": version: 4.9.0 resolution: "@eslint-community/eslint-utils@npm:4.9.0" @@ -10600,6 +10782,7 @@ __metadata: fs-extra: ^10.1.0 glob: ^8.0.0 ts-node: ^10.9.2 + tsx: ^4.21.0 vitest: ^1.0.0 bin: translations-cli: bin/translations-cli @@ -19201,6 +19384,95 @@ __metadata: languageName: node linkType: hard +"esbuild@npm:~0.27.0": + version: 0.27.2 + resolution: "esbuild@npm:0.27.2" + dependencies: + "@esbuild/aix-ppc64": 0.27.2 + "@esbuild/android-arm": 0.27.2 + "@esbuild/android-arm64": 0.27.2 + "@esbuild/android-x64": 0.27.2 + "@esbuild/darwin-arm64": 0.27.2 + "@esbuild/darwin-x64": 0.27.2 + "@esbuild/freebsd-arm64": 0.27.2 + "@esbuild/freebsd-x64": 0.27.2 + "@esbuild/linux-arm": 0.27.2 + "@esbuild/linux-arm64": 0.27.2 + "@esbuild/linux-ia32": 0.27.2 + "@esbuild/linux-loong64": 0.27.2 + "@esbuild/linux-mips64el": 0.27.2 + "@esbuild/linux-ppc64": 0.27.2 + "@esbuild/linux-riscv64": 0.27.2 + "@esbuild/linux-s390x": 0.27.2 + "@esbuild/linux-x64": 0.27.2 + "@esbuild/netbsd-arm64": 0.27.2 + "@esbuild/netbsd-x64": 0.27.2 + "@esbuild/openbsd-arm64": 0.27.2 + "@esbuild/openbsd-x64": 0.27.2 + "@esbuild/openharmony-arm64": 0.27.2 + "@esbuild/sunos-x64": 0.27.2 + "@esbuild/win32-arm64": 0.27.2 + "@esbuild/win32-ia32": 0.27.2 + "@esbuild/win32-x64": 0.27.2 + 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-arm64": + optional: true + "@esbuild/netbsd-x64": + optional: true + "@esbuild/openbsd-arm64": + optional: true + "@esbuild/openbsd-x64": + optional: true + "@esbuild/openharmony-arm64": + 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: 62ec92f8f40ad19922ae7d8dbf0427e41744120a77cc95abdf099dfb484d65fbe3c70cc55b8eccb7f6cb0d14e871ff1f2f76376d476915c2a6d2b800269261b2 + languageName: node + linkType: hard + "escalade@npm:^3.1.1, escalade@npm:^3.2.0": version: 3.2.0 resolution: "escalade@npm:3.2.0" @@ -20878,6 +21150,15 @@ __metadata: languageName: node linkType: hard +"get-tsconfig@npm:^4.7.5": + version: 4.13.0 + resolution: "get-tsconfig@npm:4.13.0" + dependencies: + resolve-pkg-maps: ^1.0.0 + checksum: b3cfa1316dd8842e038f6a3dc02ae87d9f3a227f14b79ac4b1c81bf6fc75de4dfc3355c4117612e183f5147dad49c8132841c7fdd7a4508531d820a9b90acc51 + languageName: node + linkType: hard + "get-uri@npm:^6.0.1": version: 6.0.5 resolution: "get-uri@npm:6.0.5" @@ -33131,6 +33412,22 @@ __metadata: languageName: node linkType: hard +"tsx@npm:^4.21.0": + version: 4.21.0 + resolution: "tsx@npm:4.21.0" + dependencies: + esbuild: ~0.27.0 + fsevents: ~2.3.3 + get-tsconfig: ^4.7.5 + dependenciesMeta: + fsevents: + optional: true + bin: + tsx: dist/cli.mjs + checksum: 50c98e4b6e66d1c30f72925c8e5e7be1a02377574de7cd367d7e7a6d4af43ca8ff659f91c654e7628b25a5498015e32f090529b92c679b0342811e1cf682e8cf + languageName: node + linkType: hard + "tty-browserify@npm:0.0.1": version: 0.0.1 resolution: "tty-browserify@npm:0.0.1" From f70b3ceeefaa9508e569374f70b0ba1b40ec3322 Mon Sep 17 00:00:00 2001 From: Yi Cai Date: Tue, 13 Jan 2026 19:14:30 -0500 Subject: [PATCH 08/30] resolve failed ci checks Signed-off-by: Yi Cai --- .../packages/cli/src/commands/download.ts | 2 - .../packages/cli/src/commands/generate.ts | 8 +- .../packages/cli/src/commands/sync.ts | 2 +- .../packages/cli/src/commands/upload.ts | 4 +- .../packages/cli/src/lib/i18n/extractKeys.ts | 98 +++++++++++++++---- 5 files changed, 86 insertions(+), 28 deletions(-) diff --git a/workspaces/translations/packages/cli/src/commands/download.ts b/workspaces/translations/packages/cli/src/commands/download.ts index c54d7d415d..77c7ee5a68 100644 --- a/workspaces/translations/packages/cli/src/commands/download.ts +++ b/workspaces/translations/packages/cli/src/commands/download.ts @@ -14,8 +14,6 @@ * limitations under the License. */ -import path from 'node:path'; - import { OptionValues } from 'commander'; import chalk from 'chalk'; import fs from 'fs-extra'; diff --git a/workspaces/translations/packages/cli/src/commands/generate.ts b/workspaces/translations/packages/cli/src/commands/generate.ts index ffb5d81437..55724759d6 100644 --- a/workspaces/translations/packages/cli/src/commands/generate.ts +++ b/workspaces/translations/packages/cli/src/commands/generate.ts @@ -21,10 +21,7 @@ import chalk from 'chalk'; import fs from 'fs-extra'; import glob from 'glob'; -import { - extractTranslationKeys, - type ExtractResult, -} from '../lib/i18n/extractKeys'; +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'; @@ -805,7 +802,7 @@ async function extractKeysFromJsonFile( languageData = pluginData.en as Record; } else if (isCorePlugins) { // For core-plugins, use first available language to extract key structure - for (const [lang, langData] of Object.entries(pluginData)) { + for (const [, langData] of Object.entries(pluginData)) { if (typeof langData === 'object' && langData !== null) { languageData = langData as Record; break; @@ -1399,7 +1396,6 @@ export async function generateCommand(opts: OptionValues): Promise { // 1. Files in the output directory (they might be our generated files) // 2. Files with "reference" in the name (those are English reference files, not translated) const filteredFiles = files.filter(file => { - const relativePath = path.relative(repoRoot, file); const fileName = path.basename(file); // Exclude if it's in the output directory and is a reference file diff --git a/workspaces/translations/packages/cli/src/commands/sync.ts b/workspaces/translations/packages/cli/src/commands/sync.ts index 7b591e84e0..eb26d48441 100644 --- a/workspaces/translations/packages/cli/src/commands/sync.ts +++ b/workspaces/translations/packages/cli/src/commands/sync.ts @@ -57,7 +57,7 @@ function hasTmsConfig( * Execute a step (actually perform the action) */ async function executeStep( - stepName: string, + _stepName: string, action: () => Promise, ): Promise { await action(); diff --git a/workspaces/translations/packages/cli/src/commands/upload.ts b/workspaces/translations/packages/cli/src/commands/upload.ts index d1284b05a7..2ed80e7ecc 100644 --- a/workspaces/translations/packages/cli/src/commands/upload.ts +++ b/workspaces/translations/packages/cli/src/commands/upload.ts @@ -268,7 +268,7 @@ async function cleanupTempFile(tempFile: string): Promise { */ async function executeMemsourceUpload( args: string[], - fileToUpload: string, + _fileToUpload: string, ): Promise { const output = safeExecSyncOrThrow('memsource', args, { encoding: 'utf-8', @@ -654,7 +654,7 @@ async function performUpload( sourceFile: string, uploadFileName: string, targetLanguages: string | undefined, - force: boolean, + _force: boolean, ): Promise { // Check if MEMSOURCE_TOKEN is available (should be set from ~/.memsourcerc) if (!process.env.MEMSOURCE_TOKEN && !tmsToken) { diff --git a/workspaces/translations/packages/cli/src/lib/i18n/extractKeys.ts b/workspaces/translations/packages/cli/src/lib/i18n/extractKeys.ts index 59250991cd..87a79cad73 100644 --- a/workspaces/translations/packages/cli/src/lib/i18n/extractKeys.ts +++ b/workspaces/translations/packages/cli/src/lib/i18n/extractKeys.ts @@ -205,7 +205,6 @@ export function extractTranslationKeys( } let extractedPluginId: string | null = null; - let foundMessages = false; for (const property of args[0].properties) { // Extract plugin ID from 'id' field @@ -218,10 +217,11 @@ export function extractTranslationKeys( extractedPluginId = property.initializer.text; } // Extract messages - const propName = extractPropertyKeyName(property.name); - if (propName === 'messages') { - foundMessages = true; - extractMessagesFromProperty(property, 'messages'); + if (property.name) { + const propName = extractPropertyKeyName(property.name); + if (propName === 'messages') { + extractMessagesFromProperty(property, 'messages'); + } } } @@ -269,6 +269,52 @@ export function extractTranslationKeys( } }; + /** + * Extract from defineMessages calls + * Pattern: defineMessages({ key: { id: 'key', defaultMessage: 'value' } }) + */ + const extractFromDefineMessages = (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) && + property.initializer && + ts.isObjectLiteralExpression(property.initializer) + ) { + // Extract the key name + const keyName = extractPropertyKeyName(property.name); + if (!keyName) { + continue; + } + + // Look for 'defaultMessage' or 'id' property in the message object + for (const msgProperty of property.initializer.properties) { + if ( + ts.isPropertyAssignment(msgProperty) && + msgProperty.name && + ts.isIdentifier(msgProperty.name) + ) { + const propName = msgProperty.name.text; + if ( + (propName === 'defaultMessage' || propName === 'id') && + ts.isStringLiteral(msgProperty.initializer) + ) { + const value = msgProperty.initializer.text; + if (isValidKey(keyName)) { + keys[keyName] = value; + } + break; + } + } + } + } + } + }; + /** * Check if variable name suggests it's a messages object */ @@ -467,7 +513,7 @@ export function extractTranslationKeys( // Also check for TranslationRef type declarations in variable declarations // Pattern: declare const X: TranslationRef<"id", { readonly "key": "value" }> for (const decl of node.declarationList.declarations) { - if (decl.type && ts.isTypeReference(decl.type)) { + if (decl.type && ts.isTypeReferenceNode(decl.type)) { const typeRef = decl.type; if ( ts.isIdentifier(typeRef.typeName) && @@ -482,17 +528,24 @@ export function extractTranslationKeys( for (const member of messagesType.members) { if (ts.isPropertySignature(member) && member.name) { const keyName = extractPropertyKeyName(member.name); - if ( - keyName && - member.type && - ts.isStringLiteralType(member.type) - ) { - // Validate key before storing (inline validation for better performance) - if (isValidKey(keyName)) { - keys[keyName] = member.type.text; - } else { - if (!invalidKeys.includes(keyName)) { - invalidKeys.push(keyName); + if (keyName && member.type) { + // Check if it's a string literal type + // String literal types are represented as LiteralTypeNode with StringLiteral + if (ts.isLiteralTypeNode(member.type)) { + const literalType = member.type; + if ( + literalType.literal && + ts.isStringLiteral(literalType.literal) + ) { + const stringValue = literalType.literal.text; + // Validate key before storing (inline validation for better performance) + if (isValidKey(keyName)) { + keys[keyName] = stringValue; + } else { + if (!invalidKeys.includes(keyName)) { + invalidKeys.push(keyName); + } + } } } } @@ -541,6 +594,17 @@ export function extractTranslationKeys( return { keys, pluginId }; } +/** + * Validate that a key is a valid dot-notation identifier (for regex fallback) + */ +function isValidKeyRegex(key: string): boolean { + if (!key || key.trim() === '') { + return false; + } + // Check if key is valid dot-notation identifier + return /^[a-zA-Z_$][a-zA-Z0-9_$]*(?:\.[a-zA-Z_$][a-zA-Z0-9_$]*)*$/.test(key); +} + /** * Fallback regex-based key extraction for non-TypeScript files */ From 975345b47e08fd6e6d239adaee6f3963415ce040 Mon Sep 17 00:00:00 2001 From: Yi Cai Date: Tue, 13 Jan 2026 20:06:37 -0500 Subject: [PATCH 09/30] resolve failed ci checks Signed-off-by: Yi Cai --- .../translations/packages/cli/cli-report.md | 169 ++++++++++++++++++ .../packages/cli/src/lib/paths.ts | 19 +- .../plugins/translations-test/report.api.md | 12 +- .../plugins/translations/report-alpha.api.md | 18 +- 4 files changed, 190 insertions(+), 28 deletions(-) create mode 100644 workspaces/translations/packages/cli/cli-report.md diff --git a/workspaces/translations/packages/cli/cli-report.md b/workspaces/translations/packages/cli/cli-report.md new file mode 100644 index 0000000000..388db03138 --- /dev/null +++ b/workspaces/translations/packages/cli/cli-report.md @@ -0,0 +1,169 @@ +## CLI Report file for "@red-hat-developer-hub/translations-cli" + +> Do not edit this file. It is a report generated by `yarn build:api-reports` + +### `translations-cli` + +``` +Usage: translations-cli [options] [command] + +Options: + -V, --version + -h, --help + +Commands: + help [command] + i18n [command] +``` + +### `translations-cli i18n` + +``` +Usage: translations-cli i18n [options] [command] [command] + +Options: + -h, --help + +Commands: + clean [options] + deploy [options] + download [options] + generate [options] + help [command] + init [options] + setup-memsource [options] + status [options] + sync [options] + upload [options] +``` + +### `translations-cli i18n clean` + +``` +Usage: translations-cli i18n clean [options] + +Options: + --backup-dir + --cache-dir + --force + --i18n-dir + -h, --help +``` + +### `translations-cli i18n deploy` + +``` +Usage: translations-cli i18n deploy [options] + +Options: + --source-dir + -h, --help +``` + +### `translations-cli i18n download` + +``` +Usage: translations-cli i18n download [options] + +Options: + --job-ids + --languages + --output-dir + --project-id + -h, --help +``` + +### `translations-cli i18n generate` + +``` +Usage: translations-cli i18n generate [options] + +Options: + --backstage-repo-path + --core-plugins + --exclude-pattern + --extract-keys + --format + --include-pattern + --merge-existing + --output-dir + --output-filename + --source-dir + -h, --help +``` + +### `translations-cli i18n init` + +``` +Usage: translations-cli i18n init [options] + +Options: + --memsource-venv + --setup-memsource + -h, --help +``` + +### `translations-cli i18n setup-memsource` + +``` +Usage: translations-cli i18n setup-memsource [options] + +Options: + --memsource-url + --memsource-venv + --no-input + --password + --username + -h, --help +``` + +### `translations-cli i18n status` + +``` +Usage: translations-cli i18n status [options] + +Options: + --format + --i18n-dir + --include-stats + --locales-dir + --source-dir + -h, --help +``` + +### `translations-cli i18n sync` + +``` +Usage: translations-cli i18n sync [options] + +Options: + --dry-run + --languages + --locales-dir + --output-dir + --project-id + --skip-deploy + --skip-download + --skip-upload + --source-dir + --tms-token + --tms-url + -h, --help +``` + +### `translations-cli i18n upload` + +``` +Usage: translations-cli i18n upload [options] + +Options: + --dry-run + --force + --project-id + --source-file + --target-languages + --tms-token + --tms-url + --upload-filename + -h, --help +``` diff --git a/workspaces/translations/packages/cli/src/lib/paths.ts b/workspaces/translations/packages/cli/src/lib/paths.ts index 922236bc24..0013c4356b 100644 --- a/workspaces/translations/packages/cli/src/lib/paths.ts +++ b/workspaces/translations/packages/cli/src/lib/paths.ts @@ -15,21 +15,14 @@ */ import path from 'node: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 +// Note: resolveOwn is not currently used, but kept for potential future use export const paths = { targetDir: process.cwd(), - resolveOwn: (relativePath: string) => - path.resolve( - // eslint-disable-next-line no-restricted-syntax - __dirname, - '..', - '..', - relativePath, - ), + resolveOwn: (relativePath: string) => { + // Use process.cwd() as base since we're typically running from the package root + // This avoids issues with import.meta.url in API report generation + return path.resolve(process.cwd(), relativePath); + }, }; diff --git a/workspaces/translations/plugins/translations-test/report.api.md b/workspaces/translations/plugins/translations-test/report.api.md index a4ef14424b..7be7a14939 100644 --- a/workspaces/translations/plugins/translations-test/report.api.md +++ b/workspaces/translations/plugins/translations-test/report.api.md @@ -25,13 +25,16 @@ export const translationsTestPlugin: BackstagePlugin< export const translationsTestTranslationRef: TranslationRef< 'plugin.translations-test', { + readonly 'page.title': string; + readonly 'page.subtitle': string; + readonly 'objects.tree.res': string; + readonly 'context.friend': string; + readonly 'context.friend_male': string; + readonly 'context.friend_female': string; readonly 'interpolation.key': string; readonly 'interpolation.nested.key': string; readonly 'interpolation.complex.message': string; readonly 'interpolation.complex.linkText': string; - readonly 'page.title': string; - readonly 'page.subtitle': string; - readonly 'objects.tree.res': string; readonly 'essentials.key': string; readonly 'essentials.look.deep': string; readonly 'formatting.intlNumber': string; @@ -48,9 +51,6 @@ export const translationsTestTranslationRef: TranslationRef< readonly 'plurals.key_other': string; readonly 'plurals.keyWithCount_one': string; readonly 'plurals.keyWithCount_other': string; - readonly 'context.friend': string; - readonly 'context.friend_male': string; - readonly 'context.friend_female': string; readonly 'arrays.array': string; } >; diff --git a/workspaces/translations/plugins/translations/report-alpha.api.md b/workspaces/translations/plugins/translations/report-alpha.api.md index d2d9ea26e6..74b8c23706 100644 --- a/workspaces/translations/plugins/translations/report-alpha.api.md +++ b/workspaces/translations/plugins/translations/report-alpha.api.md @@ -10,21 +10,21 @@ import { TranslationResource } from '@backstage/core-plugin-api/alpha'; export const translationsPluginTranslationRef: TranslationRef< 'plugin.translations', { + readonly 'page.title': string; + readonly 'page.subtitle': string; readonly 'table.title': string; - readonly 'table.headers.refId': string; - readonly 'table.headers.key': string; readonly 'table.options.pageSize': string; readonly 'table.options.pageSizeOptions': string; + readonly 'table.headers.key': string; + readonly 'table.headers.refId': string; readonly 'language.displayFormat': string; - readonly 'page.title': string; - readonly 'page.subtitle': string; - readonly 'export.title': string; - readonly 'export.downloadButton': string; - readonly 'export.filename': string; - readonly 'common.loading': string; readonly 'common.error': string; - readonly 'common.noData': string; + readonly 'common.loading': string; readonly 'common.refresh': string; + readonly 'common.noData': string; + readonly 'export.filename': string; + readonly 'export.title': string; + readonly 'export.downloadButton': string; } >; From 36da4b54750191149a018024d4662ea6532e576f Mon Sep 17 00:00:00 2001 From: Yi Cai Date: Tue, 13 Jan 2026 20:19:56 -0500 Subject: [PATCH 10/30] resolve failed ci checks Signed-off-by: Yi Cai --- .../translations/packages/cli/cli-report.md | 2 ++ .../plugins/translations-test/report.api.md | 12 ++++++------ .../plugins/translations/report-alpha.api.md | 18 +++++++++--------- 3 files changed, 17 insertions(+), 15 deletions(-) diff --git a/workspaces/translations/packages/cli/cli-report.md b/workspaces/translations/packages/cli/cli-report.md index 388db03138..1d274af39d 100644 --- a/workspaces/translations/packages/cli/cli-report.md +++ b/workspaces/translations/packages/cli/cli-report.md @@ -89,6 +89,7 @@ Options: --output-dir --output-filename --source-dir + --sprint -h, --help ``` @@ -146,6 +147,7 @@ Options: --skip-download --skip-upload --source-dir + --sprint --tms-token --tms-url -h, --help diff --git a/workspaces/translations/plugins/translations-test/report.api.md b/workspaces/translations/plugins/translations-test/report.api.md index 7be7a14939..a4ef14424b 100644 --- a/workspaces/translations/plugins/translations-test/report.api.md +++ b/workspaces/translations/plugins/translations-test/report.api.md @@ -25,16 +25,13 @@ export const translationsTestPlugin: BackstagePlugin< export const translationsTestTranslationRef: TranslationRef< 'plugin.translations-test', { - readonly 'page.title': string; - readonly 'page.subtitle': string; - readonly 'objects.tree.res': string; - readonly 'context.friend': string; - readonly 'context.friend_male': string; - readonly 'context.friend_female': string; readonly 'interpolation.key': string; readonly 'interpolation.nested.key': string; readonly 'interpolation.complex.message': string; readonly 'interpolation.complex.linkText': string; + readonly 'page.title': string; + readonly 'page.subtitle': string; + readonly 'objects.tree.res': string; readonly 'essentials.key': string; readonly 'essentials.look.deep': string; readonly 'formatting.intlNumber': string; @@ -51,6 +48,9 @@ export const translationsTestTranslationRef: TranslationRef< readonly 'plurals.key_other': string; readonly 'plurals.keyWithCount_one': string; readonly 'plurals.keyWithCount_other': string; + readonly 'context.friend': string; + readonly 'context.friend_male': string; + readonly 'context.friend_female': string; readonly 'arrays.array': string; } >; diff --git a/workspaces/translations/plugins/translations/report-alpha.api.md b/workspaces/translations/plugins/translations/report-alpha.api.md index 74b8c23706..d2d9ea26e6 100644 --- a/workspaces/translations/plugins/translations/report-alpha.api.md +++ b/workspaces/translations/plugins/translations/report-alpha.api.md @@ -10,21 +10,21 @@ import { TranslationResource } from '@backstage/core-plugin-api/alpha'; export const translationsPluginTranslationRef: TranslationRef< 'plugin.translations', { - readonly 'page.title': string; - readonly 'page.subtitle': string; readonly 'table.title': string; + readonly 'table.headers.refId': string; + readonly 'table.headers.key': string; readonly 'table.options.pageSize': string; readonly 'table.options.pageSizeOptions': string; - readonly 'table.headers.key': string; - readonly 'table.headers.refId': string; readonly 'language.displayFormat': string; - readonly 'common.error': string; - readonly 'common.loading': string; - readonly 'common.refresh': string; - readonly 'common.noData': string; - readonly 'export.filename': string; + readonly 'page.title': string; + readonly 'page.subtitle': string; readonly 'export.title': string; readonly 'export.downloadButton': string; + readonly 'export.filename': string; + readonly 'common.loading': string; + readonly 'common.error': string; + readonly 'common.noData': string; + readonly 'common.refresh': string; } >; From 4b4a9c56d1ea1e84bfc6547b8b37aab3bd18170f Mon Sep 17 00:00:00 2001 From: Yi Cai Date: Tue, 13 Jan 2026 20:23:46 -0500 Subject: [PATCH 11/30] yarn dedupe Signed-off-by: Yi Cai --- workspaces/translations/yarn.lock | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/workspaces/translations/yarn.lock b/workspaces/translations/yarn.lock index 74c26c07db..1fdbc85e5a 100644 --- a/workspaces/translations/yarn.lock +++ b/workspaces/translations/yarn.lock @@ -21141,16 +21141,7 @@ __metadata: languageName: node linkType: hard -"get-tsconfig@npm:^4.10.0": - version: 4.10.1 - resolution: "get-tsconfig@npm:4.10.1" - dependencies: - resolve-pkg-maps: ^1.0.0 - checksum: 22925debda6bd0992171a44ee79a22c32642063ba79534372c4d744e0c9154abe2c031659da0fb86bc9e73fc56a3b76b053ea5d24ca3ac3da43d2e6f7d1c3c33 - languageName: node - linkType: hard - -"get-tsconfig@npm:^4.7.5": +"get-tsconfig@npm:^4.10.0, get-tsconfig@npm:^4.7.5": version: 4.13.0 resolution: "get-tsconfig@npm:4.13.0" dependencies: From 125dd9df94e1507cd4d6e75553446b3e839acddf Mon Sep 17 00:00:00 2001 From: Yi Cai Date: Tue, 13 Jan 2026 20:49:03 -0500 Subject: [PATCH 12/30] resolve failed ci checks Signed-off-by: Yi Cai --- .../packages/cli/src/commands/deploy.ts | 21 +++++----------- .../packages/cli/src/lib/version.ts | 25 ++++++++----------- 2 files changed, 17 insertions(+), 29 deletions(-) diff --git a/workspaces/translations/packages/cli/src/commands/deploy.ts b/workspaces/translations/packages/cli/src/commands/deploy.ts index 5b6a7c8170..f8611c9aaa 100644 --- a/workspaces/translations/packages/cli/src/commands/deploy.ts +++ b/workspaces/translations/packages/cli/src/commands/deploy.ts @@ -15,7 +15,6 @@ */ import path from 'node:path'; -import { fileURLToPath } from 'url'; import { OptionValues } from 'commander'; import chalk from 'chalk'; @@ -23,12 +22,6 @@ import fs from 'fs-extra'; import { commandExists, safeExecSyncOrThrow } from '../lib/utils/exec'; -// 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); - /** * Deploy translations using the TypeScript deployment script */ @@ -37,19 +30,17 @@ async function deployWithTypeScriptScript( repoRoot: string, ): Promise { // Find the deployment script - // Try multiple possible locations + // Try multiple possible locations relative to known package structure 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 + // From repo root (most reliable) path.resolve( repoRoot, 'workspaces/translations/packages/cli/scripts/deploy-translations.ts', ), + // From current working directory if we're in the package + path.resolve(process.cwd(), 'scripts/deploy-translations.ts'), + // From package root if cwd is in src + path.resolve(process.cwd(), '../scripts/deploy-translations.ts'), ]; let scriptPath: string | null = null; diff --git a/workspaces/translations/packages/cli/src/lib/version.ts b/workspaces/translations/packages/cli/src/lib/version.ts index f6cae77a2c..1f23c405c2 100644 --- a/workspaces/translations/packages/cli/src/lib/version.ts +++ b/workspaces/translations/packages/cli/src/lib/version.ts @@ -15,26 +15,23 @@ */ import path from 'node: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 + // Try to find package.json in multiple possible locations + // Use process.cwd() and known package structure to avoid import.meta.url issues 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 + // From package root (most common case) + path.resolve(process.cwd(), 'package.json'), + // From workspace root if running from translations workspace + path.resolve(process.cwd(), '..', '..', 'package.json'), + // From repo root if running from monorepo root + path.resolve( + process.cwd(), + 'workspaces/translations/packages/cli/package.json', + ), ]; for (const pkgPath of possiblePaths) { From 642319b9b49123491ea2485ed6528e9cfe11b253 Mon Sep 17 00:00:00 2001 From: Yi Cai Date: Wed, 14 Jan 2026 12:10:52 -0500 Subject: [PATCH 13/30] resolved sonarCloud issues Signed-off-by: Yi Cai --- .../cli/scripts/deploy-translations.ts | 138 +- .../packages/cli/src/commands/generate.ts | 1715 +++++++++-------- .../packages/cli/src/commands/upload.ts | 84 +- .../translations/packages/cli/src/index.ts | 10 +- .../packages/cli/src/lib/errors.ts | 14 +- .../packages/cli/src/lib/i18n/extractKeys.ts | 235 ++- .../packages/cli/src/lib/i18n/loadFile.ts | 129 +- .../packages/cli/src/lib/i18n/mergeFiles.ts | 245 +-- .../packages/cli/src/lib/i18n/saveFile.ts | 14 +- .../cli/src/lib/utils/translationUtils.ts | 26 + .../packages/cli/test/test-helpers.ts | 2 +- 11 files changed, 1445 insertions(+), 1167 deletions(-) diff --git a/workspaces/translations/packages/cli/scripts/deploy-translations.ts b/workspaces/translations/packages/cli/scripts/deploy-translations.ts index c88d5486f3..804fa700e9 100644 --- a/workspaces/translations/packages/cli/scripts/deploy-translations.ts +++ b/workspaces/translations/packages/cli/scripts/deploy-translations.ts @@ -884,54 +884,108 @@ function extractKeysFromRefFile(refFilePath: string): Set { return; // Prevent infinite recursion } - // Match object properties: key: { ... } or key: 'value' - // Handle both identifier keys and string literal keys - const propertyPattern = /(?:(\w+)|['"]([^'"]+)['"])\s*:\s*([^,}]+)/g; - let match = propertyPattern.exec(nestedContent); - const processed = new Set(); + // Helper: Extract matches from a regex pattern + const extractMatches = ( + pattern: RegExp, + textContent: string, + processed: Set, + ): Array<{ + key: string; + value: string; + index: number; + endIndex: number; + }> => { + const matches: Array<{ + key: string; + value: string; + index: number; + endIndex: number; + }> = []; + let match = pattern.exec(textContent); + while (match !== null) { + if (!processed.has(match.index)) { + processed.add(match.index); + matches.push({ + key: match[1], + value: match[2].trim(), + index: match.index, + endIndex: match.index + match[0].length, + }); + } + match = pattern.exec(textContent); + } + return matches; + }; - while (match !== null) { - if (processed.has(match.index)) { - match = propertyPattern.exec(nestedContent); - continue; + // Helper: Find matching closing brace for nested object + const findMatchingBrace = ( + textContent: string, + startIndex: number, + ): string | null => { + const valueStart = textContent.indexOf('{', startIndex); + if (valueStart === -1) { + return null; } - processed.add(match.index); - - const key = match[1] || match[2]; // identifier or string literal - const value = match[3].trim(); - - const fullKey = prefix ? `${prefix}.${key}` : key; - - // Check if value is a nested object - if (value.startsWith('{')) { - // Find the matching closing brace - let braceCount = 0; - const endIndex = match.index + match[0].length; - for (let i = endIndex; i < nestedContent.length; i++) { - if (nestedContent[i] === '{') braceCount++; - if (nestedContent[i] === '}') { - braceCount--; - if (braceCount === 0) { - const nestedSubContent = nestedContent.substring( - endIndex, - i + 1, - ); - extractNestedKeys( - nestedSubContent, - fullKey, - depth + 1, - maxDepth, - ); - break; - } + + let braceCount = 0; + for (let i = valueStart; i < textContent.length; i++) { + if (textContent[i] === '{') braceCount++; + if (textContent[i] === '}') { + braceCount--; + if (braceCount === 0) { + return textContent.substring(valueStart, i + 1); } } - } else if (value.match(/^['"]/)) { - // Leaf node - string value - keys.add(fullKey); } + return null; + }; + + // Pattern for identifier keys: word followed by colon and value + // Use non-greedy quantifier with reasonable limit to prevent ReDoS + // Limit value to 10000 chars to prevent DoS attacks + const identifierKeyPattern = + /(\w+)\s*:\s*([^,}]{0,10000}?)(?=\s*,|\s*\w+\s*:|$)/g; + + // Pattern for string literal keys: 'key' or "key" followed by colon and value + // Limit key to 1000 chars and value to 10000 chars to prevent DoS + const stringKeyPattern = + /['"]([^'"]{0,1000})['"]\s*:\s*([^,}]{0,10000}?)(?=\s*,|\s*(?:\w+|['"])\s*:|$)/g; - match = propertyPattern.exec(nestedContent); + const processed = new Set(); + const allMatches: Array<{ + key: string; + value: string; + index: number; + endIndex: number; + }> = []; + + // Collect matches from both patterns + allMatches.push( + ...extractMatches(identifierKeyPattern, nestedContent, processed), + ); + stringKeyPattern.lastIndex = 0; + allMatches.push( + ...extractMatches(stringKeyPattern, nestedContent, processed), + ); + + // Sort by index to process in order + allMatches.sort((a, b) => a.index - b.index); + + // Process each match + for (const matchData of allMatches) { + const fullKey = prefix ? `${prefix}.${matchData.key}` : matchData.key; + + if (matchData.value.startsWith('{')) { + const nestedSubContent = findMatchingBrace( + nestedContent, + matchData.index, + ); + if (nestedSubContent) { + extractNestedKeys(nestedSubContent, fullKey, depth + 1, maxDepth); + } + } else if (matchData.value.match(/^['"]/)) { + keys.add(fullKey); + } } }; diff --git a/workspaces/translations/packages/cli/src/commands/generate.ts b/workspaces/translations/packages/cli/src/commands/generate.ts index 55724759d6..cc4a3fb5ac 100644 --- a/workspaces/translations/packages/cli/src/commands/generate.ts +++ b/workspaces/translations/packages/cli/src/commands/generate.ts @@ -465,15 +465,15 @@ async function findBackstagePluginTranslationRefs( * Also supports legacy node_modules structure for backward compatibility */ function extractBackstagePluginName(filePath: string): string | null { + // Helper: Map React variants to base plugin names (they share translations) + const normalizePluginName = (name: string): string => { + return name.endsWith('-react') ? name.replace(/-react$/, '') : name; + }; + // Pattern 0: plugins/{name}/src/... (simpler structure: plugins/home/src/translation.ts) const simplePluginsMatch = /plugins\/([^/]+)\//.exec(filePath); if (simplePluginsMatch && filePath.includes('/src/')) { - let pluginName = simplePluginsMatch[1]; - // Map React variants to base plugin names (they share translations) - if (pluginName.endsWith('-react')) { - pluginName = pluginName.replace(/-react$/, ''); - } - return pluginName; + return normalizePluginName(simplePluginsMatch[1]); } // Pattern 1: plugins/{name}/packages/plugin-{name}/... @@ -481,22 +481,13 @@ function extractBackstagePluginName(filePath: string): string | null { filePath, ); if (pluginsMatch) { - let pluginName = pluginsMatch[2]; - // Map React variants to base plugin names (they share translations) - if (pluginName.endsWith('-react')) { - pluginName = pluginName.replace(/-react$/, ''); - } - return pluginName; + return normalizePluginName(pluginsMatch[2]); } // Pattern 2: packages/plugin-{name}/... const packagesPluginMatch = /packages\/plugin-([^/]+)/.exec(filePath); if (packagesPluginMatch) { - let pluginName = packagesPluginMatch[1]; - if (pluginName.endsWith('-react')) { - pluginName = pluginName.replace(/-react$/, ''); - } - return pluginName; + return normalizePluginName(packagesPluginMatch[1]); } // Pattern 3: packages/core-{name}/... @@ -510,21 +501,13 @@ function extractBackstagePluginName(filePath: string): string | null { filePath, ); if (workspacesMatch) { - let pluginName = workspacesMatch[2]; - if (pluginName.endsWith('-react')) { - pluginName = pluginName.replace(/-react$/, ''); - } - return pluginName; + return normalizePluginName(workspacesMatch[2]); } // Pattern 5: Legacy node_modules/@backstage/plugin-{name}/... (backward compatibility) const nodeModulesPluginMatch = /@backstage\/plugin-([^/]+)/.exec(filePath); if (nodeModulesPluginMatch) { - let pluginName = nodeModulesPluginMatch[1]; - if (pluginName.endsWith('-react')) { - pluginName = pluginName.replace(/-react$/, ''); - } - return pluginName; + return normalizePluginName(nodeModulesPluginMatch[1]); } // Pattern 6: Legacy node_modules/@backstage/core-{name}/... (backward compatibility) @@ -543,180 +526,165 @@ function extractBackstagePluginName(filePath: string): string | null { * 2. If plugin is imported/referenced in app source code * 3. If plugin is listed in package.json dependencies */ -async function isPluginUsedInRhdh( - pluginName: string, - repoRoot: string, -): Promise { +function buildPackageName(pluginName: string): string { // Map plugin names to their package names // e.g., "home" -> "@backstage/plugin-home" // e.g., "catalog-graph" -> "@backstage/plugin-catalog-graph" // e.g., "core-components" -> "@backstage/core-components" - let packageName: string; - if (pluginName.startsWith('core-')) { - packageName = `@backstage/${pluginName}`; - } else { - packageName = `@backstage/plugin-${pluginName}`; + return pluginName.startsWith('core-') + ? `@backstage/${pluginName}` + : `@backstage/plugin-${pluginName}`; +} + +async function checkPackageJsonDependencies( + packageJsonPath: string, + packageName: string, +): Promise { + if (!(await fs.pathExists(packageJsonPath))) { + return false; } - // Check if package exists in node_modules (basic check) - const nodeModulesPath = path.join(repoRoot, 'node_modules', packageName); - if (!(await fs.pathExists(nodeModulesPath))) { + try { + const packageJson = await fs.readJson(packageJsonPath); + const allDeps = { + ...packageJson.dependencies, + ...packageJson.devDependencies, + ...packageJson.peerDependencies, + }; + return packageName in allDeps; + } catch { return false; } +} - // Check if plugin is in package.json dependencies - const packageJsonPath = path.join(repoRoot, 'package.json'); - if (await fs.pathExists(packageJsonPath)) { - try { - const packageJson = await fs.readJson(packageJsonPath); - const allDeps = { - ...packageJson.dependencies, - ...packageJson.devDependencies, - ...packageJson.peerDependencies, - }; - if (packageName in allDeps) { - return true; - } - } catch { - // If we can't read package.json, continue with other checks - } +function buildPackagePatterns( + pluginName: string, + packageName: string, +): string[] { + const patterns = [ + packageName, + `backstage-plugin-${pluginName}`, + `backstage/core-${pluginName.replace('core-', '')}`, + `backstage-plugin-${pluginName}-react`, + `plugin-${pluginName}-react`, + packageName.replace('@backstage/', ''), + ]; + + // Special case: home plugin might be referenced via dynamic-home-page + if (pluginName === 'home' || pluginName === 'home-react') { + patterns.push('dynamic-home-page'); + patterns.push('backstage-plugin-dynamic-home-page'); } - // Check app package.json (for monorepo structure) - const appPackageJsonPath = path.join( - repoRoot, - 'packages', - 'app', - 'package.json', + return patterns; +} + +function buildPluginIdPatterns(pluginName: string): string[] { + return [ + `backstage.plugin-${pluginName}`, + `backstage.core-${pluginName.replace('core-', '')}`, + ]; +} + +function matchesPackagePattern(line: string, pattern: string): boolean { + if (line.includes('backend') || line.includes('module')) { + return false; + } + + if (line.includes(pattern)) { + return true; + } + + // Check for local path format: ./dynamic-plugins/dist/backstage-plugin-{name} + return ( + line.includes(`./dynamic-plugins/dist/${pattern}`) || + line.includes(`dynamic-plugins/dist/${pattern}`) ); - if (await fs.pathExists(appPackageJsonPath)) { - try { - const appPackageJson = await fs.readJson(appPackageJsonPath); - const allDeps = { - ...appPackageJson.dependencies, - ...appPackageJson.devDependencies, - ...appPackageJson.peerDependencies, - }; - if (packageName in allDeps) { - return true; +} + +function matchesPluginIdPattern(trimmedLine: string, pattern: string): boolean { + return ( + trimmedLine.includes(`"${pattern}"`) || trimmedLine.includes(`'${pattern}'`) + ); +} + +interface PluginEntry { + startLine: number; + disabled: boolean | null; +} + +function parseDynamicPluginsFile( + content: string, + packagePatterns: string[], + pluginIdPatterns: string[], +): boolean { + const lines = content.split('\n'); + const pluginEntries: PluginEntry[] = []; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const trimmedLine = line.trim(); + + const matchesPackage = packagePatterns.some(pattern => + matchesPackagePattern(line, pattern), + ); + const matchesPluginId = pluginIdPatterns.some(pattern => + matchesPluginIdPattern(trimmedLine, pattern), + ); + + if ( + matchesPackage || + (matchesPluginId && trimmedLine.startsWith('backstage.')) + ) { + pluginEntries.push({ startLine: i, disabled: null }); + } + + // Update disabled status for tracked entries + for (const entry of pluginEntries) { + if (i >= entry.startLine && i < entry.startLine + 10) { + if (trimmedLine.startsWith('disabled:')) { + entry.disabled = trimmedLine.includes('disabled: false') + ? false + : trimmedLine.includes('disabled: true'); + } } - } catch { - // If we can't read app package.json, continue } } - // Check dynamic-plugins.default.yaml for enabled plugins - // Plugins can be installed via dynamic plugins even if not in package.json - // Parse YAML file as text to check for enabled plugins (disabled: false or no disabled field) + // Check if any entry is enabled (disabled: false or no disabled field) + return pluginEntries.some( + entry => entry.disabled === false || entry.disabled === null, + ); +} + +async function checkDynamicPlugins( + repoRoot: string, + pluginName: string, + packageName: string, +): Promise { const dynamicPluginsPath = path.join( repoRoot, 'dynamic-plugins.default.yaml', ); - if (await fs.pathExists(dynamicPluginsPath)) { - try { - const content = await fs.readFile(dynamicPluginsPath, 'utf-8'); - const lines = content.split('\n'); - - // Patterns to match the plugin package - // Handle various package name formats: - // - OCI: oci://quay.io/rhdh/backstage-plugin-techdocs:... - // - Local: ./dynamic-plugins/dist/backstage-plugin-techdocs - // - Package: @backstage/plugin-techdocs - // - React variants: @backstage/plugin-techdocs-react (uses techdocs translations) - const packagePatterns = [ - packageName, - `backstage-plugin-${pluginName}`, // Matches: backstage-plugin-techdocs - `backstage/core-${pluginName.replace('core-', '')}`, - // Also check for React variants (techdocs-react uses techdocs translations) - `backstage-plugin-${pluginName}-react`, - `plugin-${pluginName}-react`, - // Handle package name without @backstage/ prefix (for dynamic-plugins format) - packageName.replace('@backstage/', ''), - ]; - - // Also check for plugin IDs in config (e.g., "backstage.plugin-home", "backstage.plugin-techdocs") - const pluginIdPatterns = [ - `backstage.plugin-${pluginName}`, - `backstage.core-${pluginName.replace('core-', '')}`, - ]; - - // Special case: home plugin might be referenced via dynamic-home-page or home page mount points - if (pluginName === 'home' || pluginName === 'home-react') { - packagePatterns.push('dynamic-home-page'); - packagePatterns.push('backstage-plugin-dynamic-home-page'); - } - - // Track all plugin entries and their disabled status - const pluginEntries: Array<{ - startLine: number; - disabled: boolean | null; - }> = []; - - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - const trimmedLine = line.trim(); - - // Check if this line contains the package name (starts a new plugin entry) - // Handle both OCI format (oci://...) and local path format (./dynamic-plugins/...) - const matchesPackage = packagePatterns.some(pattern => { - if (line.includes('backend') || line.includes('module')) { - return false; - } - // Check for direct pattern match - if (line.includes(pattern)) { - return true; - } - // Check for local path format: ./dynamic-plugins/dist/backstage-plugin-{name} - if ( - line.includes(`./dynamic-plugins/dist/${pattern}`) || - line.includes(`dynamic-plugins/dist/${pattern}`) - ) { - return true; - } - return false; - }); - const matchesPluginId = pluginIdPatterns.some( - pattern => - trimmedLine.includes(`"${pattern}"`) || - trimmedLine.includes(`'${pattern}'`), - ); - - if ( - matchesPackage || - (matchesPluginId && trimmedLine.startsWith('backstage.')) - ) { - // Found a plugin entry - track it - pluginEntries.push({ startLine: i, disabled: null }); - } - // For each tracked entry, check for disabled field in the following lines - for (const entry of pluginEntries) { - if (i >= entry.startLine && i < entry.startLine + 10) { - // Check within 10 lines of the package declaration - if (trimmedLine.startsWith('disabled:')) { - entry.disabled = trimmedLine.includes('disabled: true'); - // If we find disabled: false, this entry is enabled - if (trimmedLine.includes('disabled: false')) { - entry.disabled = false; - } - } - } - } - } + if (!(await fs.pathExists(dynamicPluginsPath))) { + return false; + } - // Check if any entry is enabled (disabled: false or no disabled field) - for (const entry of pluginEntries) { - if (entry.disabled === false || entry.disabled === null) { - return true; - } - } - } catch { - // Skip if we can't read/parse the file - } + try { + const content = await fs.readFile(dynamicPluginsPath, 'utf-8'); + const packagePatterns = buildPackagePatterns(pluginName, packageName); + const pluginIdPatterns = buildPluginIdPatterns(pluginName); + return parseDynamicPluginsFile(content, packagePatterns, pluginIdPatterns); + } catch { + return false; } +} - // Check if plugin is imported/referenced in app source code - // Look for imports like: from '@backstage/plugin-{name}' or '@backstage/core-{name}' +async function checkSourceCodeImports( + repoRoot: string, + packageName: string, +): Promise { const searchPatterns = [ `packages/app/src/**/*.{ts,tsx,js,jsx}`, `packages/app/src/**/*.tsx`, @@ -737,11 +705,9 @@ async function isPluginUsedInRhdh( absolute: true, }); - // Check first 50 files for performance (plugins are usually imported early) for (const file of files.slice(0, 50)) { try { const content = await fs.readFile(file, 'utf-8'); - // Check for import statements if ( content.includes(`from '${packageName}'`) || content.includes(`from "${packageName}"`) || @@ -753,21 +719,165 @@ async function isPluginUsedInRhdh( return true; } } catch { - // Skip files that can't be read continue; } } } catch { - // Skip patterns that don't match continue; } } + return false; +} + +async function isPluginUsedInRhdh( + pluginName: string, + repoRoot: string, +): Promise { + const packageName = buildPackageName(pluginName); + + // Check if package exists in node_modules (basic check) + const nodeModulesPath = path.join(repoRoot, 'node_modules', packageName); + if (!(await fs.pathExists(nodeModulesPath))) { + return false; + } + + // Check if plugin is in package.json dependencies + const packageJsonPath = path.join(repoRoot, 'package.json'); + if (await checkPackageJsonDependencies(packageJsonPath, packageName)) { + return true; + } + + // Check app package.json (for monorepo structure) + const appPackageJsonPath = path.join( + repoRoot, + 'packages', + 'app', + 'package.json', + ); + if (await checkPackageJsonDependencies(appPackageJsonPath, packageName)) { + return true; + } + + // Check dynamic-plugins.default.yaml for enabled plugins + if (await checkDynamicPlugins(repoRoot, pluginName, packageName)) { + return true; + } + + // Check if plugin is imported/referenced in app source code + if (await checkSourceCodeImports(repoRoot, packageName)) { + return true; + } + // If we can't find evidence of usage, assume it's not used - // This is conservative - we'd rather exclude unused plugins than include them return false; } +function findLanguageData( + pluginData: Record, + isCorePlugins: boolean, +): Record | null { + // Prefer 'en' if available + if ( + 'en' in pluginData && + typeof pluginData.en === 'object' && + pluginData.en !== null + ) { + return pluginData.en as Record; + } + + if (isCorePlugins) { + // For core-plugins, use first available language to extract key structure + for (const [, langData] of Object.entries(pluginData)) { + if (typeof langData === 'object' && langData !== null) { + return langData as Record; + } + } + } else { + // For RHDH files, fall back to any language if no 'en' + for (const [lang, langData] of Object.entries(pluginData)) { + if (typeof langData === 'object' && langData !== null && lang !== 'en') { + return langData as Record; + } + } + } + + return null; +} + +function extractKeysFromLanguageData( + languageData: Record, + isCorePlugins: boolean, + hasEnglish: boolean, +): Record { + const keys: Record = {}; + + for (const [key, value] of Object.entries(languageData)) { + if (typeof value === 'string') { + // For core-plugins files, if we're extracting from a non-English file, + // use the key name as the English placeholder value + keys[key] = isCorePlugins && !hasEnglish ? key : value; + } + } + + return keys; +} + +function extractNestedStructure( + data: Record, + isCorePlugins: boolean, +): Record> { + const result: Record> = {}; + + for (const [pluginName, pluginData] of Object.entries(data)) { + if (typeof pluginData !== 'object' || pluginData === null) { + continue; + } + + const languageData = findLanguageData( + pluginData as Record, + isCorePlugins, + ); + + if (languageData) { + const hasEnglish = 'en' in pluginData; + result[pluginName] = extractKeysFromLanguageData( + languageData, + isCorePlugins, + hasEnglish, + ); + } + } + + return result; +} + +function extractFlatStructure( + data: unknown, + filePath: string, +): Record> { + const translations = + typeof data === 'object' && data !== null && 'translations' in data + ? (data as { translations: Record }).translations + : (data as Record); + + if (typeof translations !== 'object' || translations === null) { + return {}; + } + + const pluginName = detectPluginName(filePath) || 'translations'; + const result: Record> = {}; + result[pluginName] = {}; + + for (const [key, value] of Object.entries(translations)) { + if (typeof value === 'string') { + result[pluginName][key] = value; + } + } + + return result; +} + /** * Extract keys from JSON translation file * Handles both English files and translated files (extracts key structure) @@ -783,91 +893,17 @@ async function extractKeysFromJsonFile( // Handle nested structure: { plugin: { en: { key: value } } } or { plugin: { fr: { key: value } } } if (typeof data === 'object' && data !== null) { - const result: Record> = {}; - - for (const [pluginName, pluginData] of Object.entries(data)) { - if (typeof pluginData !== 'object' || pluginData === null) { - continue; - } - - // For core-plugins, extract from any language (they all have the same keys) - // For RHDH files, prefer 'en' but fall back to other languages - let languageData: Record | null = null; - - if ( - 'en' in pluginData && - typeof pluginData.en === 'object' && - pluginData.en !== null - ) { - languageData = pluginData.en as Record; - } else if (isCorePlugins) { - // For core-plugins, use first available language to extract key structure - for (const [, langData] of Object.entries(pluginData)) { - if (typeof langData === 'object' && langData !== null) { - languageData = langData as Record; - break; - } - } - } else { - // For RHDH files, fall back to any language if no 'en' - for (const [lang, langData] of Object.entries(pluginData)) { - if ( - typeof langData === 'object' && - langData !== null && - lang !== 'en' - ) { - languageData = langData as Record; - break; - } - } - } - - if (languageData) { - result[pluginName] = {}; - for (const [key, value] of Object.entries(languageData)) { - if (typeof value === 'string') { - // For core-plugins files, if we're extracting from a non-English file, - // use the key name as the English placeholder value - // This allows us to build the structure even from translated files - if (isCorePlugins && !('en' in pluginData)) { - // Use key as placeholder - the actual English will come from ref files if available - result[pluginName][key] = key; - } else { - // For RHDH files or files with 'en', use the actual value - result[pluginName][key] = value; - } - } - } - } - } - - if (Object.keys(result).length > 0) { - return result; + const nestedResult = extractNestedStructure( + data as Record, + isCorePlugins, + ); + if (Object.keys(nestedResult).length > 0) { + return nestedResult; } } // Handle flat structure: { key: value } or { translations: { key: value } } - const translations = - typeof data === 'object' && data !== null && 'translations' in data - ? (data as { translations: Record }).translations - : (data as Record); - - if (typeof translations !== 'object' || translations === null) { - return {}; - } - - // Try to detect plugin name from file path - const pluginName = detectPluginName(filePath) || 'translations'; - const result: Record> = {}; - result[pluginName] = {}; - - for (const [key, value] of Object.entries(translations)) { - if (typeof value === 'string') { - result[pluginName][key] = value; - } - } - - return result; + return extractFlatStructure(data, filePath); } catch (error) { console.warn( chalk.yellow( @@ -924,89 +960,119 @@ const INVALID_PLUGIN_NAMES = new Set([ /** * 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 extractResult = extractTranslationKeys(content, filePath); - const keys = extractResult.keys; - - // Use plugin ID from createTranslationRef if available, otherwise use file path - const pluginName = extractResult.pluginId || detectPluginName(filePath); - - if (!pluginName) { - console.warn( - chalk.yellow( - `โš ๏ธ Warning: Could not determine plugin name for ${path.relative( - process.cwd(), - filePath, - )}, skipping`, - ), - ); - continue; - } +function validatePluginName( + pluginName: string | null, + filePath: string, +): pluginName is string { + if (!pluginName) { + console.warn( + chalk.yellow( + `โš ๏ธ Warning: Could not determine plugin name for ${path.relative( + process.cwd(), + filePath, + )}, skipping`, + ), + ); + return false; + } - 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 (INVALID_PLUGIN_NAMES.has(pluginName.toLowerCase())) { + console.warn( + chalk.yellow( + `โš ๏ธ Warning: Skipping invalid plugin name "${pluginName}" from ${path.relative( + process.cwd(), + filePath, + )}`, + ), + ); + return false; + } - if (!pluginGroups[pluginName]) { - pluginGroups[pluginName] = {}; - } + return true; +} - // Merge keys into plugin group (warn about overwrites) - const overwrittenKeys: string[] = []; - for (const [key, value] of Object.entries(keys)) { - const stringValue = String(value); - if ( - pluginGroups[pluginName][key] && - pluginGroups[pluginName][key] !== stringValue - ) { - overwrittenKeys.push(key); - } - pluginGroups[pluginName][key] = stringValue; - } +function mergeKeysIntoPluginGroup( + pluginGroups: Record>, + pluginName: string, + keys: Record, + filePath: string, +): void { + if (!pluginGroups[pluginName]) { + pluginGroups[pluginName] = {}; + } - 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}`), - ); + const overwrittenKeys: string[] = []; + for (const [key, value] of Object.entries(keys)) { + const stringValue = String(value); + if ( + pluginGroups[pluginName][key] && + pluginGroups[pluginName][key] !== stringValue + ) { + overwrittenKeys.push(key); } + pluginGroups[pluginName][key] = stringValue; } - // Convert to nested structure: { plugin: { en: { keys } } } - const structuredData: Record }> = {}; + if (overwrittenKeys.length > 0) { + console.warn( + chalk.yellow( + `โš ๏ธ Warning: ${ + overwrittenKeys.length + } keys were overwritten in plugin "${pluginName}" from ${path.relative( + process.cwd(), + filePath, + )}`, + ), + ); + } +} + +async function processSourceFile( + filePath: string, + pluginGroups: Record>, +): Promise { + try { + const content = await fs.readFile(filePath, 'utf-8'); + const extractResult = extractTranslationKeys(content, filePath); + const keys = extractResult.keys; + + // Use plugin ID from createTranslationRef if available, otherwise use file path + const pluginName = extractResult.pluginId || detectPluginName(filePath); + + if (!validatePluginName(pluginName, filePath)) { + return; + } + + mergeKeysIntoPluginGroup(pluginGroups, pluginName, keys, filePath); + } catch (error) { + console.warn( + chalk.yellow(`โš ๏ธ Warning: Could not process ${filePath}: ${error}`), + ); + } +} + +function convertToStructuredData( + pluginGroups: Record>, +): Record }> { + const structuredData: Record }> = {}; for (const [pluginName, keys] of Object.entries(pluginGroups)) { structuredData[pluginName] = { en: keys }; } - return structuredData; } +async function extractAndGroupKeys( + sourceFiles: string[], +): Promise }>> { + const pluginGroups: Record> = {}; + + for (const filePath of sourceFiles) { + await processSourceFile(filePath, pluginGroups); + } + + return convertToStructuredData(pluginGroups); +} + /** * Generate or merge translation files */ @@ -1091,25 +1157,516 @@ function displaySummary( console.log(''); } -export async function generateCommand(opts: OptionValues): Promise { - // Handle --core-plugins flag (can be true, 'true', or the flag name) - const corePlugins = Boolean(opts.corePlugins) || opts.corePlugins === 'true'; - const outputFilename = opts.outputFilename as string | undefined; - const sprint = opts.sprint as string | undefined; - - // Validate sprint value if not using custom filename +function validateSprintOptions( + outputFilename: string | undefined, + sprint: string | undefined, +): void { if (!outputFilename && !sprint) { throw new Error( '--sprint is required. Please provide a sprint value (e.g., --sprint s3285)', ); } - // Validate sprint format (should start with 's' followed by numbers, or just numbers) if (sprint && !/^s?\d+$/i.test(sprint)) { throw new Error( `Invalid sprint format: "${sprint}". Sprint should be in format "s3285" or "3285"`, ); } +} + +function getBackstageRepoPath( + opts: OptionValues, + config: Awaited>, +): string | null { + return ( + (opts.backstageRepoPath as string | undefined) || + config.backstageRepoPath || + process.env.BACKSTAGE_REPO_PATH || + null + ); +} + +function mapPluginName(pluginName: string, refFile: string): string { + let mapped = pluginName.replace(/^plugin-/, ''); + + if (pluginName === 'plugin-home-react' || refFile.includes('home-react')) { + mapped = 'home-react'; + } else if (pluginName === 'plugin-home') { + mapped = 'home'; + } + + return mapped; +} + +async function extractKeysFromDataJson( + refFile: string, +): Promise<{ keys: Record; pluginName: string | null }> { + const keys: Record = {}; + const jsonContent = await fs.readJson(refFile); + + if (typeof jsonContent === 'object' && jsonContent !== null) { + for (const [key, value] of Object.entries(jsonContent)) { + if (typeof value === 'string') { + keys[key] = value; + } else if ( + typeof value === 'object' && + value !== null && + 'defaultMessage' in value + ) { + keys[key] = (value as { defaultMessage: string }).defaultMessage; + } + } + } + + const pluginName = extractBackstagePluginName(refFile); + return { keys, pluginName }; +} + +async function extractKeysFromRefFile( + refFile: string, +): Promise<{ keys: Record; pluginName: string | null }> { + if (refFile.endsWith('data.json')) { + return extractKeysFromDataJson(refFile); + } + + const content = await fs.readFile(refFile, 'utf-8'); + const extractResult = extractTranslationKeys(content, refFile); + const pluginName = + extractResult.pluginId || extractBackstagePluginName(refFile); + + return { keys: extractResult.keys, pluginName }; +} + +async function checkPluginUsage( + pluginRefFiles: string[], + rhdhRepoPath: string, +): Promise<{ usedPlugins: Set; unusedPlugins: Set }> { + const usedPlugins = new Set(); + const unusedPlugins = new Set(); + + for (const refFile of pluginRefFiles) { + try { + const pluginName = extractBackstagePluginName(refFile); + if (!pluginName) continue; + + const mappedPluginName = mapPluginName(pluginName, refFile); + const isUsed = await isPluginUsedInRhdh(mappedPluginName, rhdhRepoPath); + + if (isUsed) { + usedPlugins.add(mappedPluginName); + } else { + unusedPlugins.add(mappedPluginName); + } + } catch { + continue; + } + } + + return { usedPlugins, unusedPlugins }; +} + +async function extractKeysFromCorePluginRefs( + pluginRefFiles: string[], + shouldFilterByUsage: boolean, + usedPlugins: Set, +): Promise }>> { + const structuredData: Record }> = {}; + + for (const refFile of pluginRefFiles) { + try { + const { keys, pluginName } = await extractKeysFromRefFile(refFile); + + if (!pluginName || Object.keys(keys).length === 0) { + continue; + } + + const mappedPluginName = mapPluginName(pluginName, refFile); + + if (shouldFilterByUsage && !usedPlugins.has(mappedPluginName)) { + continue; + } + + if (!structuredData[mappedPluginName]) { + structuredData[mappedPluginName] = { en: {} }; + } + + for (const [key, value] of Object.entries(keys)) { + if (!structuredData[mappedPluginName].en[key]) { + structuredData[mappedPluginName].en[key] = value; + } + } + } catch (error) { + console.warn( + chalk.yellow( + `โš ๏ธ Warning: Could not extract from ${refFile}: ${error}`, + ), + ); + } + } + + return structuredData; +} + +function filterTranslatedFiles( + files: string[], + outputDirPath: string, + outputFileName: string, +): string[] { + return files.filter(file => { + const fileName = path.basename(file); + + if (file.startsWith(outputDirPath) && fileName === outputFileName) { + return false; + } + + if ( + fileName.includes('reference') || + fileName.includes('core-plugins-reference') + ) { + return false; + } + + return true; + }); +} + +async function findCorePluginsTranslatedFiles( + repoRoot: string, + outputDir: string, +): Promise { + const corePluginsJsonPatterns = [ + 'translations/core-plugins*.json', + 'translations/**/core-plugins*.json', + ]; + + const translatedFiles: string[] = []; + const outputDirPath = path.resolve(repoRoot, String(outputDir || 'i18n')); + const outputFileName = 'core-plugins-reference.json'; + + for (const pattern of corePluginsJsonPatterns) { + try { + const files = glob.sync(pattern, { + cwd: repoRoot, + ignore: [ + '**/node_modules/**', + '**/dist/**', + '**/build/**', + '**/test/**', + ], + absolute: true, + }); + + const filteredFiles = filterTranslatedFiles( + files, + outputDirPath, + outputFileName, + ); + translatedFiles.push(...filteredFiles); + } catch { + // Ignore if pattern doesn't match + } + } + + return translatedFiles; +} + +async function extractKeysFromTranslatedFiles( + translatedFiles: string[], + repoRoot: string, +): Promise>> { + const structuredData: Record> = {}; + + for (const translatedFile of translatedFiles) { + console.log( + chalk.gray(` Processing: ${path.relative(repoRoot, translatedFile)}`), + ); + + const translatedKeys = await extractKeysFromJsonFile(translatedFile, true); + + const totalKeys = Object.values(translatedKeys).reduce( + (sum, keys) => sum + Object.keys(keys).length, + 0, + ); + const pluginCount = Object.keys(translatedKeys).length; + console.log( + chalk.gray( + ` Extracted ${totalKeys} keys from ${pluginCount} plugin${ + pluginCount !== 1 ? 's' : '' + }`, + ), + ); + + for (const [pluginName, pluginKeys] of Object.entries(translatedKeys)) { + if (!structuredData[pluginName]) { + structuredData[pluginName] = {}; + } + + for (const [key, value] of Object.entries(pluginKeys)) { + if (!structuredData[pluginName][key]) { + structuredData[pluginName][key] = value; + } + } + } + } + + return structuredData; +} + +async function processCorePlugins( + backstageRepoPath: string, + repoRoot: string, + outputDir: string, +): Promise }>> { + console.log( + chalk.yellow( + `๐Ÿ“ Scanning Backstage plugin packages in: ${backstageRepoPath}`, + ), + ); + + const pluginRefFiles = await findBackstagePluginTranslationRefs( + backstageRepoPath, + ); + console.log( + chalk.gray( + `Found ${pluginRefFiles.length} Backstage plugin translation ref files`, + ), + ); + + const structuredData: Record }> = {}; + + if (pluginRefFiles.length === 0) { + return structuredData; + } + + const rhdhRepoPath = process.env.RHDH_REPO_PATH || null; + const shouldFilterByUsage = Boolean(rhdhRepoPath); + let usedPlugins = new Set(); + + if (shouldFilterByUsage) { + console.log( + chalk.yellow(`๐Ÿ” Checking which plugins are actually used in RHDH...`), + ); + + const { usedPlugins: checkedUsed, unusedPlugins } = await checkPluginUsage( + pluginRefFiles, + rhdhRepoPath!, + ); + + usedPlugins = checkedUsed; + + if (unusedPlugins.size > 0) { + console.log( + chalk.gray( + ` Skipping ${unusedPlugins.size} unused plugin(s): ${Array.from( + unusedPlugins, + ) + .slice(0, 5) + .join(', ')}${unusedPlugins.size > 5 ? '...' : ''}`, + ), + ); + } + console.log( + chalk.gray(` Found ${usedPlugins.size} plugin(s) actually used in RHDH`), + ); + } else { + console.log( + chalk.yellow( + `๐Ÿ“ฆ Extracting all Backstage core plugins (RHDH_REPO_PATH not set, skipping usage filter)...`, + ), + ); + } + + const refData = await extractKeysFromCorePluginRefs( + pluginRefFiles, + shouldFilterByUsage, + usedPlugins, + ); + + Object.assign(structuredData, refData); + + console.log( + chalk.green( + `โœ… Extracted keys from ${usedPlugins.size} Backstage plugin packages used in RHDH`, + ), + ); + + const translatedFiles = await findCorePluginsTranslatedFiles( + repoRoot, + outputDir, + ); + + if (translatedFiles.length > 0) { + console.log( + chalk.yellow( + `๐Ÿ“ Scanning ${translatedFiles.length} existing core-plugins file(s) to extract translation keys...`, + ), + ); + + const translatedData = await extractKeysFromTranslatedFiles( + translatedFiles, + repoRoot, + ); + + for (const [pluginName, pluginKeys] of Object.entries(translatedData)) { + if (!structuredData[pluginName]) { + structuredData[pluginName] = { en: {} }; + } + + for (const [key, value] of Object.entries(pluginKeys)) { + if (!structuredData[pluginName].en[key]) { + structuredData[pluginName].en[key] = value; + } + } + } + + console.log( + chalk.green( + `โœ… Extracted keys from ${translatedFiles.length} existing core-plugins translation file(s)`, + ), + ); + } + + return structuredData; +} + +function filterRhdhPlugins( + rhdhData: Record }>, +): Record }> { + const backstageCorePlugins = new Set([ + 'home', + 'catalog-graph', + 'api-docs', + 'kubernetes', + 'kubernetes-cluster', + 'techdocs', + 'home-react', + 'catalog-react', + 'org', + 'search-react', + 'kubernetes-react', + 'scaffolder-react', + ]); + + const rhdhPlugins: Record }> = {}; + + for (const [pluginName, pluginData] of Object.entries(rhdhData)) { + if ( + !backstageCorePlugins.has(pluginName) || + pluginName === 'catalog' || + pluginName === 'scaffolder' || + pluginName === 'search' || + pluginName === 'core-components' || + pluginName === 'catalog-import' || + pluginName === 'user-settings' + ) { + rhdhPlugins[pluginName] = pluginData; + } + } + + return rhdhPlugins; +} + +async function processRhdhPlugins( + sourceDir: string, + includePattern: string, + excludePattern: string, +): Promise }>> { + console.log(chalk.yellow(`๐Ÿ“ Scanning ${sourceDir} for translation keys...`)); + + const allSourceFiles = glob.sync(includePattern, { + cwd: sourceDir, + ignore: excludePattern, + absolute: true, + }); + + const sourceFiles = await findEnglishReferenceFiles( + sourceDir, + includePattern, + excludePattern, + ); + + console.log( + chalk.gray( + `Found ${allSourceFiles.length} files, ${sourceFiles.length} are English reference files`, + ), + ); + + const rhdhData = await extractAndGroupKeys(sourceFiles); + + console.log(chalk.yellow(`๐Ÿ“ Scanning for JSON translation files...`)); + const jsonFiles = await findJsonTranslationFiles(sourceDir); + console.log(chalk.gray(`Found ${jsonFiles.length} JSON translation files`)); + + if (jsonFiles.length > 0) { + for (const jsonFile of jsonFiles) { + const jsonKeys = await extractKeysFromJsonFile(jsonFile); + for (const [pluginName, keys] of Object.entries(jsonKeys)) { + if (!rhdhData[pluginName]) { + rhdhData[pluginName] = { en: {} }; + } + for (const [key, value] of Object.entries(keys)) { + if (!rhdhData[pluginName].en[key]) { + rhdhData[pluginName].en[key] = value; + } + } + } + } + console.log( + chalk.green( + `โœ… Merged keys from ${jsonFiles.length} JSON translation files`, + ), + ); + } + + return filterRhdhPlugins(rhdhData); +} + +function generateFilename( + outputFilename: string | undefined, + sprint: string | undefined, + corePlugins: boolean, + backstageRepoPath: string | null, +): string { + if (outputFilename) { + return outputFilename.replace(/\.json$/, ''); + } + + if (!sprint) { + throw new Error( + 'Sprint value is required. Please provide --sprint option (e.g., --sprint s3285)', + ); + } + + const normalizedSprint = + sprint.startsWith('s') || sprint.startsWith('S') + ? sprint.toLowerCase() + : `s${sprint}`; + + const repoName = + corePlugins && backstageRepoPath + ? detectRepoName(backstageRepoPath) + : detectRepoName(); + + return `${repoName.toLowerCase()}-${normalizedSprint}`; +} + +function getOutputDirectory( + corePlugins: boolean, + backstageRepoPath: string | null, + outputDir: string, +): string { + if (corePlugins && backstageRepoPath) { + return path.join(backstageRepoPath, 'i18n'); + } + return String(outputDir || 'i18n'); +} + +export async function generateCommand(opts: OptionValues): Promise { + const corePlugins = Boolean(opts.corePlugins) || opts.corePlugins === 'true'; + const outputFilename = opts.outputFilename as string | undefined; + const sprint = opts.sprint as string | undefined; + + validateSprintOptions(outputFilename, sprint); if (corePlugins) { console.log( @@ -1143,24 +1700,14 @@ export async function generateCommand(opts: OptionValues): Promise { try { await fs.ensureDir(outputDir); - // Always use nested structure format: { plugin: { en: { key: value } } } const translationKeys: Record }> = {}; - - // Get backstage repo path early so we can use it for filename generation - const backstageRepoPath = - (opts.backstageRepoPath as string | undefined) || - config.backstageRepoPath || - process.env.BACKSTAGE_REPO_PATH || - null; + const backstageRepoPath = getBackstageRepoPath(opts, config); if (extractKeys) { - const structuredData: Record }> = {}; const repoRoot = process.cwd(); + let structuredData: Record }>; if (corePlugins) { - // For core-plugins: Scan Backstage plugin packages in Backstage repository - // This is the primary source for English translation keys - if (!backstageRepoPath) { console.error( chalk.red( @@ -1178,406 +1725,12 @@ export async function generateCommand(opts: OptionValues): Promise { process.exit(1); } - console.log( - chalk.yellow( - `๐Ÿ“ Scanning Backstage plugin packages in: ${backstageRepoPath}`, - ), - ); - const pluginRefFiles = await findBackstagePluginTranslationRefs( + structuredData = await processCorePlugins( backstageRepoPath, - ); - console.log( - chalk.gray( - `Found ${pluginRefFiles.length} Backstage plugin translation ref files`, - ), - ); - - if (pluginRefFiles.length > 0) { - // For core-plugins mode, optionally filter by RHDH usage - // If RHDH repo path is not available, extract all plugins - const rhdhRepoPath = process.env.RHDH_REPO_PATH || null; - const shouldFilterByUsage = Boolean(rhdhRepoPath); - - const usedPlugins = new Set(); - const unusedPlugins = new Set(); - - if (shouldFilterByUsage) { - console.log( - chalk.yellow( - `๐Ÿ” Checking which plugins are actually used in RHDH...`, - ), - ); - - // First pass: collect all plugin names and check usage - for (const refFile of pluginRefFiles) { - try { - const pluginName = extractBackstagePluginName(refFile); - if (!pluginName) continue; - - let mappedPluginName = pluginName.replace(/^plugin-/, ''); - - // Handle special cases - if ( - pluginName === 'plugin-home-react' || - refFile.includes('home-react') - ) { - mappedPluginName = 'home-react'; - } else if (pluginName === 'plugin-home') { - mappedPluginName = 'home'; - } - - // Check if plugin is actually used in RHDH - if (rhdhRepoPath) { - const isUsed = await isPluginUsedInRhdh( - mappedPluginName, - rhdhRepoPath, - ); - if (isUsed) { - usedPlugins.add(mappedPluginName); - } else { - unusedPlugins.add(mappedPluginName); - } - } - } catch (error) { - // Skip if we can't determine usage - continue; - } - } - - if (unusedPlugins.size > 0) { - console.log( - chalk.gray( - ` Skipping ${ - unusedPlugins.size - } unused plugin(s): ${Array.from(unusedPlugins) - .slice(0, 5) - .join(', ')}${unusedPlugins.size > 5 ? '...' : ''}`, - ), - ); - } - console.log( - chalk.gray( - ` Found ${usedPlugins.size} plugin(s) actually used in RHDH`, - ), - ); - } else { - console.log( - chalk.yellow( - `๐Ÿ“ฆ Extracting all Backstage core plugins (RHDH_REPO_PATH not set, skipping usage filter)...`, - ), - ); - } - - // Second pass: extract keys from plugins (filtered by usage if enabled) - for (const refFile of pluginRefFiles) { - try { - let keys: Record = {}; - let pluginName: string | null = null; - - // Handle data.json files (compiled react-intl messages) - if (refFile.endsWith('data.json')) { - try { - const jsonContent = await fs.readJson(refFile); - // data.json typically has structure: { "key": { "defaultMessage": "value", "id": "key" } } - // or flat: { "key": "value" } - if (typeof jsonContent === 'object' && jsonContent !== null) { - for (const [key, value] of Object.entries(jsonContent)) { - if (typeof value === 'string') { - keys[key] = value; - } else if ( - typeof value === 'object' && - value !== null && - 'defaultMessage' in value - ) { - keys[key] = ( - value as { defaultMessage: string } - ).defaultMessage; - } - } - } - // Extract plugin name from file path for data.json files - pluginName = extractBackstagePluginName(refFile); - } catch (jsonError) { - console.warn( - chalk.yellow( - `โš ๏ธ Warning: Could not parse JSON from ${refFile}: ${jsonError}`, - ), - ); - continue; - } - } else { - // Handle TypeScript/JavaScript files - const content = await fs.readFile(refFile, 'utf-8'); - const extractResult = extractTranslationKeys(content, refFile); - keys = extractResult.keys; - - // Use plugin ID from createTranslationRef 'id' field if available - // Fall back to file path extraction if not found - if (extractResult.pluginId) { - pluginName = extractResult.pluginId; - } else { - pluginName = extractBackstagePluginName(refFile); - } - } - - if (pluginName && Object.keys(keys).length > 0) { - let mappedPluginName = pluginName.replace(/^plugin-/, ''); - - // Handle special cases - if ( - pluginName === 'plugin-home-react' || - refFile.includes('home-react') - ) { - mappedPluginName = 'home-react'; - } else if (pluginName === 'plugin-home') { - mappedPluginName = 'home'; - } - - // Only process if plugin is actually used (if filtering is enabled) - if (shouldFilterByUsage && !usedPlugins.has(mappedPluginName)) { - continue; - } - - if (!structuredData[mappedPluginName]) { - structuredData[mappedPluginName] = { en: {} }; - } - - // Merge keys, prioritize English values from ref files - for (const [key, value] of Object.entries(keys)) { - if (!structuredData[mappedPluginName].en[key]) { - structuredData[mappedPluginName].en[key] = value; - } - } - } - } catch (error) { - console.warn( - chalk.yellow( - `โš ๏ธ Warning: Could not extract from ${refFile}: ${error}`, - ), - ); - } - } - console.log( - chalk.green( - `โœ… Extracted keys from ${usedPlugins.size} Backstage plugin packages used in RHDH`, - ), - ); - } - - // Also check for existing core-plugins translated JSON files to extract key structure - // This is a fallback/secondary source when node_modules doesn't have source .ts files - // Look in translations/ directory (where the example files are stored) - // These files help us identify all keys even if we can't find the English ref files - const corePluginsJsonPatterns = [ - 'translations/core-plugins*.json', - 'translations/**/core-plugins*.json', - ]; - - const translatedFiles: string[] = []; - const outputDirPath = path.resolve( repoRoot, - String(outputDir || 'i18n'), - ); - const outputFileName = 'core-plugins-reference.json'; - - for (const pattern of corePluginsJsonPatterns) { - try { - const files = glob.sync(pattern, { - cwd: repoRoot, - ignore: [ - '**/node_modules/**', - '**/dist/**', - '**/build/**', - '**/test/**', - ], - absolute: true, - }); - // Filter out: - // 1. Files in the output directory (they might be our generated files) - // 2. Files with "reference" in the name (those are English reference files, not translated) - const filteredFiles = files.filter(file => { - const fileName = path.basename(file); - - // Exclude if it's in the output directory and is a reference file - if ( - file.startsWith(outputDirPath) && - fileName === outputFileName - ) { - return false; - } - - // Exclude reference files (we want translated files like -fr.json, -it.json, or -fr-C.json, -it-C.json, etc.) - if ( - fileName.includes('reference') || - fileName.includes('core-plugins-reference') - ) { - return false; - } - - // Include files that look like translated files (have language codes) - return true; - }); - translatedFiles.push(...filteredFiles); - } catch { - // Ignore if pattern doesn't match - } - } - - if (translatedFiles.length > 0) { - console.log( - chalk.yellow( - `๐Ÿ“ Scanning ${translatedFiles.length} existing core-plugins file(s) to extract translation keys...`, - ), - ); - for (const translatedFile of translatedFiles) { - console.log( - chalk.gray( - ` Processing: ${path.relative(repoRoot, translatedFile)}`, - ), - ); - const translatedKeys = await extractKeysFromJsonFile( - translatedFile, - true, - ); - - // Log what we extracted - const totalKeys = Object.values(translatedKeys).reduce( - (sum, keys) => sum + Object.keys(keys).length, - 0, - ); - const pluginCount = Object.keys(translatedKeys).length; - console.log( - chalk.gray( - ` Extracted ${totalKeys} keys from ${pluginCount} plugin${ - pluginCount !== 1 ? 's' : '' - }`, - ), - ); - - // Extract keys from translated files (they have the structure we need) - // For each plugin, extract all keys - use key name as placeholder if no English value - // This ensures we capture all keys even if ref files don't have them - for (const [pluginName, pluginData] of Object.entries( - translatedKeys, - )) { - if (!structuredData[pluginName]) { - structuredData[pluginName] = { en: {} }; - } - // pluginData is Record - these are the translation keys - // For core-plugins from translated files, the value is the key name (placeholder) - // Only add if we don't already have an English value from ref files - for (const [key, value] of Object.entries(pluginData)) { - if (!structuredData[pluginName].en[key]) { - // Use the value (should be key name as placeholder for core-plugins from non-English files) - // This ensures we have the key structure even if English values aren't available - structuredData[pluginName].en[key] = value; - } - } - } - } - console.log( - chalk.green( - `โœ… Extracted keys from ${translatedFiles.length} existing core-plugins translation file(s)`, - ), - ); - } - } else { - // For RHDH-specific: Only scan RHDH TypeScript files and JSON files (exclude Backstage core plugins) - console.log( - chalk.yellow(`๐Ÿ“ Scanning ${sourceDir} for translation keys...`), - ); - - const allSourceFiles = glob.sync(includePattern, { - cwd: sourceDir, - ignore: excludePattern, - absolute: true, - }); - - const sourceFiles = await findEnglishReferenceFiles( - sourceDir, - includePattern, - excludePattern, + outputDir, ); - console.log( - chalk.gray( - `Found ${allSourceFiles.length} files, ${sourceFiles.length} are English reference files`, - ), - ); - - const rhdhData = await extractAndGroupKeys(sourceFiles); - - // Also scan JSON translation files (for RHDH repo) - console.log(chalk.yellow(`๐Ÿ“ Scanning for JSON translation files...`)); - const jsonFiles = await findJsonTranslationFiles(sourceDir); - console.log( - chalk.gray(`Found ${jsonFiles.length} JSON translation files`), - ); - - if (jsonFiles.length > 0) { - for (const jsonFile of jsonFiles) { - const jsonKeys = await extractKeysFromJsonFile(jsonFile); - // Merge JSON keys into structured data - for (const [pluginName, keys] of Object.entries(jsonKeys)) { - if (!rhdhData[pluginName]) { - rhdhData[pluginName] = { en: {} }; - } - // Merge keys, only add if key doesn't exist (preserve English from TypeScript) - for (const [key, value] of Object.entries(keys)) { - if (!rhdhData[pluginName].en[key]) { - rhdhData[pluginName].en[key] = value; - } - } - } - } - console.log( - chalk.green( - `โœ… Merged keys from ${jsonFiles.length} JSON translation files`, - ), - ); - } - - // Filter to only include RHDH-specific plugins (exclude Backstage core plugins) - // RHDH-specific plugins: catalog (overrides), catalog-import, core-components (overrides), rhdh, scaffolder (overrides), search (overrides), user-settings - const backstageCorePlugins = new Set([ - 'home', - 'catalog-graph', - 'api-docs', - 'kubernetes', - 'kubernetes-cluster', - 'techdocs', - 'home-react', - 'catalog-react', - 'org', - 'search-react', - 'kubernetes-react', - 'scaffolder-react', - ]); - - for (const [pluginName, pluginData] of Object.entries(rhdhData)) { - // Only include if it's not a Backstage core plugin - // Or if it's a core plugin but has RHDH-specific overrides (like catalog, scaffolder, search) - if ( - !backstageCorePlugins.has(pluginName) || - pluginName === 'catalog' || - pluginName === 'scaffolder' || - pluginName === 'search' || - pluginName === 'core-components' || - pluginName === 'catalog-import' || - pluginName === 'user-settings' - ) { - structuredData[pluginName] = pluginData; - } - } - } - - // For core-plugins, we want to include ALL Backstage core plugins that are used/installed - // Even if they have RHDH overrides, the base Backstage translations should be in core-plugins-reference.json - // The RHDH-specific overrides go in reference.json, but base translations belong in core-plugins-reference.json - if (corePlugins) { - // Note: React plugins (catalog-react, search-react, scaffolder-react) have their own unique translations - // and should be included in the core-plugins-reference.json file. - // They are NOT just wrappers - they have distinct translation keys that are separate from base plugins. console.log( chalk.gray( ` Including all ${ @@ -1585,6 +1738,12 @@ export async function generateCommand(opts: OptionValues): Promise { } core plugins (including React versions with unique translations)`, ), ); + } else { + structuredData = await processRhdhPlugins( + sourceDir, + includePattern, + excludePattern, + ); } const totalKeys = Object.values(structuredData).reduce( @@ -1603,56 +1762,20 @@ export async function generateCommand(opts: OptionValues): Promise { } const formatStr = String(format || 'json'); + const filename = generateFilename( + outputFilename, + sprint, + corePlugins, + backstageRepoPath, + ); + const finalOutputDir = getOutputDirectory( + corePlugins, + backstageRepoPath, + outputDir, + ); - // Generate filename in format: -.json - // Examples: rhdh-s3285.json, backstage-s3285.json - let filename: string; - if (outputFilename) { - // Use custom filename if provided (overrides sprint-based naming) - filename = outputFilename.replace(/\.json$/, ''); - } else { - // Auto-generate: -.json - // Sprint is required, so it should be available here - if (!sprint) { - throw new Error( - 'Sprint value is required. Please provide --sprint option (e.g., --sprint s3285)', - ); - } - - // Normalize sprint value (ensure it starts with 's' if not already) - const normalizedSprint = - sprint.startsWith('s') || sprint.startsWith('S') - ? sprint.toLowerCase() - : `s${sprint}`; - - let repoName: string; - if (corePlugins) { - // For core-plugins mode, detect repo name from Backstage repository path - // Use the backstageRepoPath that was already determined earlier - if (backstageRepoPath) { - repoName = detectRepoName(backstageRepoPath); - } else { - // Fallback to "backstage" if path not available (shouldn't happen as we check earlier) - repoName = 'backstage'; - } - } else { - // For RHDH mode, detect repo name from current directory - repoName = detectRepoName(); - } - - filename = `${repoName.toLowerCase()}-${normalizedSprint}`; - } - - // For core-plugins mode, output to the backstage repo's i18n directory - let finalOutputDir: string; if (corePlugins && backstageRepoPath) { - // Use the backstage repo's i18n directory - finalOutputDir = path.join(backstageRepoPath, 'i18n'); - // Ensure the directory exists await fs.ensureDir(finalOutputDir); - } else { - // For RHDH mode, use the configured outputDir (relative to current working directory) - finalOutputDir = String(outputDir || 'i18n'); } const outputPath = path.join(finalOutputDir, `${filename}.${formatStr}`); diff --git a/workspaces/translations/packages/cli/src/commands/upload.ts b/workspaces/translations/packages/cli/src/commands/upload.ts index 2ed80e7ecc..34a6b8171e 100644 --- a/workspaces/translations/packages/cli/src/commands/upload.ts +++ b/workspaces/translations/packages/cli/src/commands/upload.ts @@ -66,6 +66,43 @@ function detectRepoName(repoPath?: string): string { return path.basename(targetPath); } +function extractSprintFromFilename(sourceFile: string): string | undefined { + const sourceBasename = path.basename(sourceFile, path.extname(sourceFile)); + // Try to match pattern: - or -reference- + let sprintMatch = sourceBasename.match(/^[a-z-]+-(s?\d+)$/i); + if (!sprintMatch) { + // Fallback: try old pattern with "reference" + sprintMatch = sourceBasename.match(/-reference-(s?\d+)$/i); + } + return sprintMatch ? sprintMatch[1] : undefined; +} + +function findGitRoot(sourceDir: string): string | undefined { + let currentDir = sourceDir; + while (currentDir !== path.dirname(currentDir)) { + const gitDir = path.join(currentDir, '.git'); + try { + if (fs.statSync(gitDir).isDirectory()) { + return currentDir; + } + } catch { + // .git doesn't exist, continue walking up + } + currentDir = path.dirname(currentDir); + } + return undefined; +} + +function formatIdentifier(sprintValue: string | undefined): string { + if (!sprintValue) { + return new Date().toISOString().split('T')[0]; // YYYY-MM-DD fallback + } + + return sprintValue.startsWith('s') || sprintValue.startsWith('S') + ? sprintValue.toLowerCase() + : `s${sprintValue}`; +} + /** * Generate upload filename: {repo-name}-{sprint}.json * Tries to extract sprint from source filename, or uses date as fallback @@ -82,57 +119,16 @@ function generateUploadFileName( } // Try to extract sprint from source filename if not provided - let sprintValue = sprint; - if (!sprintValue) { - const sourceBasename = path.basename(sourceFile, path.extname(sourceFile)); - // Try to match pattern: - or -reference- - let sprintMatch = sourceBasename.match(/^[a-z-]+-(s?\d+)$/i); - if (!sprintMatch) { - // Fallback: try old pattern with "reference" - sprintMatch = sourceBasename.match(/-reference-(s?\d+)$/i); - } - if (sprintMatch) { - sprintValue = sprintMatch[1]; - } - } + const sprintValue = sprint || extractSprintFromFilename(sourceFile); // Auto-generate: {repo-name}-{sprint}.json or {repo-name}-{date}.json (fallback) // Try to detect repo name from the source file's git root - // This handles cases where we're uploading files from a different repo const sourceFileAbs = path.resolve(sourceFile); const sourceDir = path.dirname(sourceFileAbs); - - // Try to find git root by walking up from source file directory - let repoRoot: string | undefined; - let currentDir = sourceDir; - while (currentDir !== path.dirname(currentDir)) { - const gitDir = path.join(currentDir, '.git'); - try { - if (fs.statSync(gitDir).isDirectory()) { - repoRoot = currentDir; - break; - } - } catch { - // .git doesn't exist, continue walking up - } - currentDir = path.dirname(currentDir); - } - - // Use detected repo root, or fallback to current directory + const repoRoot = findGitRoot(sourceDir); const repoName = repoRoot ? detectRepoName(repoRoot) : detectRepoName(); - // Use sprint if available, otherwise fall back to date - let identifier: string; - if (sprintValue) { - if (sprintValue.startsWith('s') || sprintValue.startsWith('S')) { - identifier = sprintValue.toLowerCase(); - } else { - identifier = `s${sprintValue}`; - } - } else { - identifier = new Date().toISOString().split('T')[0]; // YYYY-MM-DD fallback - } - + const identifier = formatIdentifier(sprintValue); const ext = path.extname(sourceFile); return `${repoName}-${identifier}${ext}`; } diff --git a/workspaces/translations/packages/cli/src/index.ts b/workspaces/translations/packages/cli/src/index.ts index 5dcafd4f94..f73f7c985c 100644 --- a/workspaces/translations/packages/cli/src/index.ts +++ b/workspaces/translations/packages/cli/src/index.ts @@ -44,11 +44,11 @@ const main = (argv: string[]) => { }; process.on('unhandledRejection', rejection => { - if (rejection instanceof Error) { - exitWithError(rejection); - } else { - exitWithError(new Error(`Unknown rejection: '${rejection}'`)); - } + const error = + rejection instanceof Error + ? rejection + : new Error(`Unknown rejection: '${rejection}'`); + exitWithError(error); }); main(process.argv); diff --git a/workspaces/translations/packages/cli/src/lib/errors.ts b/workspaces/translations/packages/cli/src/lib/errors.ts index 68cdce01e2..1ae900f045 100644 --- a/workspaces/translations/packages/cli/src/lib/errors.ts +++ b/workspaces/translations/packages/cli/src/lib/errors.ts @@ -36,12 +36,10 @@ export class ExitCodeError extends CustomError { } 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 { - const errorMessage = String(error); - process.stderr.write(`\n${chalk.red(errorMessage)}\n\n`); - process.exit(1); - } + const errorMessage = + error instanceof ExitCodeError ? error.message : String(error); + const exitCode = error instanceof ExitCodeError ? error.code : 1; + + process.stderr.write(`\n${chalk.red(errorMessage)}\n\n`); + process.exit(exitCode); } diff --git a/workspaces/translations/packages/cli/src/lib/i18n/extractKeys.ts b/workspaces/translations/packages/cli/src/lib/i18n/extractKeys.ts index 87a79cad73..adb6d5b073 100644 --- a/workspaces/translations/packages/cli/src/lib/i18n/extractKeys.ts +++ b/workspaces/translations/packages/cli/src/lib/i18n/extractKeys.ts @@ -101,6 +101,54 @@ export function extractTranslationKeys( ); }; + /** + * Track invalid key for warning (avoid duplicates) + */ + const trackInvalidKey = (key: string): void => { + if (!invalidKeys.includes(key)) { + invalidKeys.push(key); + } + }; + + /** + * Extract text from template expression + */ + const extractTemplateText = (template: ts.TemplateExpression): string => { + let templateText = ''; + for (const part of template.templateSpans) { + if (part.literal) { + templateText += part.literal.text; + } + } + if (template.head) { + templateText = template.head.text + templateText; + } + return templateText; + }; + + /** + * Extract value from initializer node + */ + const extractValueFromInitializer = ( + initializer: ts.Node, + ): string | null => { + const unwrapped = unwrapTypeAssertion(initializer); + + if (ts.isStringLiteral(unwrapped)) { + return unwrapped.text; + } + + if (ts.isTemplateExpression(unwrapped)) { + return extractTemplateText(unwrapped); + } + + if (ts.isNoSubstitutionTemplateLiteral(unwrapped)) { + return unwrapped.text; + } + + return null; + }; + /** * Extract translation keys from an object literal expression * Validates keys during extraction for better performance @@ -124,12 +172,8 @@ export function extractTranslationKeys( const fullKey = prefix ? `${prefix}.${keyName}` : keyName; - // Validate key before processing (inline validation for better performance) if (!isValidKey(fullKey)) { - // Track invalid keys for warning (avoid duplicate warnings) - if (!invalidKeys.includes(fullKey)) { - invalidKeys.push(fullKey); - } + trackInvalidKey(fullKey); continue; } @@ -140,29 +184,15 @@ export function extractTranslationKeys( const unwrappedInitializer = unwrapTypeAssertion(initializer); - if (ts.isStringLiteral(unwrappedInitializer)) { - // Leaf node - this is a translation value - keys[fullKey] = unwrappedInitializer.text; - } else if (ts.isTemplateExpression(unwrappedInitializer)) { - // Template literal with substitutions (backticks with ${variable}) - // Extract the text content - let templateText = ''; - for (const part of unwrappedInitializer.templateSpans) { - if (part.literal) { - templateText += part.literal.text; - } - } - // Also include the head text if present - if (unwrappedInitializer.head) { - templateText = unwrappedInitializer.head.text + templateText; - } - keys[fullKey] = templateText; - } else if (ts.isNoSubstitutionTemplateLiteral(unwrappedInitializer)) { - // Simple template literal without substitutions (backticks like `{{orgName}} Catalog`) - // Extract the raw text content - keys[fullKey] = unwrappedInitializer.text; - } else if (ts.isObjectLiteralExpression(unwrappedInitializer)) { - // Nested object - recurse + // Try to extract value from initializer + const value = extractValueFromInitializer(unwrappedInitializer); + if (value !== null) { + keys[fullKey] = value; + continue; + } + + // Handle nested object literal + if (ts.isObjectLiteralExpression(unwrappedInitializer)) { extractFromObjectLiteral(unwrappedInitializer, fullKey); } } @@ -494,11 +524,75 @@ export function extractTranslationKeys( ); }; - // Visit all nodes in the AST - const visit = (node: ts.Node) => { + /** + * Extract keys from TranslationRef type literal + */ + const extractFromTranslationRefType = ( + messagesType: ts.TypeLiteralNode, + ): void => { + for (const member of messagesType.members) { + if (!ts.isPropertySignature(member) || !member.name) { + continue; + } + + const keyName = extractPropertyKeyName(member.name); + if (!keyName || !member.type) { + continue; + } + + if (!ts.isLiteralTypeNode(member.type)) { + continue; + } + + const literalType = member.type; + if (!literalType.literal || !ts.isStringLiteral(literalType.literal)) { + continue; + } + + const stringValue = literalType.literal.text; + if (isValidKey(keyName)) { + keys[keyName] = stringValue; + } else { + trackInvalidKey(keyName); + } + } + }; + + /** + * Extract from TranslationRef type declarations in variable statements + */ + const extractFromTranslationRefDeclarations = ( + node: ts.VariableStatement, + ): void => { + for (const decl of node.declarationList.declarations) { + if (!decl.type || !ts.isTypeReferenceNode(decl.type)) { + continue; + } + + const typeRef = decl.type; + if ( + !ts.isIdentifier(typeRef.typeName) || + typeRef.typeName.text !== 'TranslationRef' || + !typeRef.typeArguments || + typeRef.typeArguments.length < 2 + ) { + continue; + } + + // Second type argument is the messages object type + const messagesType = typeRef.typeArguments[1]; + if (ts.isTypeLiteralNode(messagesType)) { + extractFromTranslationRefType(messagesType); + } + } + }; + + /** + * Handle call expression nodes + */ + const handleCallExpression = (node: ts.CallExpression): void => { if (isCallExpressionWithName(node, 'createTranslationRef')) { const extractedPluginId = extractFromCreateTranslationRef(node); - // Use the first plugin ID found (most files have one createTranslationRef) if (extractedPluginId && !pluginId) { pluginId = extractedPluginId; } @@ -508,61 +602,40 @@ export function extractTranslationKeys( extractFromCreateTranslationMessages(node); } else if (isCallExpressionWithName(node, 'defineMessages')) { extractFromDefineMessages(node); - } else if (ts.isVariableStatement(node)) { - extractFromVariableStatement(node); - // Also check for TranslationRef type declarations in variable declarations - // Pattern: declare const X: TranslationRef<"id", { readonly "key": "value" }> - for (const decl of node.declarationList.declarations) { - if (decl.type && ts.isTypeReferenceNode(decl.type)) { - const typeRef = decl.type; - if ( - ts.isIdentifier(typeRef.typeName) && - typeRef.typeName.text === 'TranslationRef' && - typeRef.typeArguments && - typeRef.typeArguments.length >= 2 - ) { - // Second type argument is the messages object type - const messagesType = typeRef.typeArguments[1]; - if (ts.isTypeLiteralNode(messagesType)) { - // Extract keys from the type literal - for (const member of messagesType.members) { - if (ts.isPropertySignature(member) && member.name) { - const keyName = extractPropertyKeyName(member.name); - if (keyName && member.type) { - // Check if it's a string literal type - // String literal types are represented as LiteralTypeNode with StringLiteral - if (ts.isLiteralTypeNode(member.type)) { - const literalType = member.type; - if ( - literalType.literal && - ts.isStringLiteral(literalType.literal) - ) { - const stringValue = literalType.literal.text; - // Validate key before storing (inline validation for better performance) - if (isValidKey(keyName)) { - keys[keyName] = stringValue; - } else { - if (!invalidKeys.includes(keyName)) { - invalidKeys.push(keyName); - } - } - } - } - } - } - } - } - } - } - } } else if (isCallExpressionWithName(node, 't')) { extractFromTFunction(node); - } else if (ts.isCallExpression(node) && isI18nTCall(node)) { + } else if (isI18nTCall(node)) { extractFromI18nT(node); - } else if (ts.isCallExpression(node) && isUseTranslationTCall(node)) { + } else if (isUseTranslationTCall(node)) { extractFromUseTranslationT(node); + } + }; + + /** + * Handle variable statement nodes + */ + const handleVariableStatement = (node: ts.VariableStatement): void => { + extractFromVariableStatement(node); + extractFromTranslationRefDeclarations(node); + }; + + /** + * Handle JSX nodes + */ + const handleJsxNode = ( + node: ts.JsxElement | ts.JsxSelfClosingElement, + ): void => { + extractFromJsxTrans(node); + }; + + // Visit all nodes in the AST + const visit = (node: ts.Node) => { + if (ts.isCallExpression(node)) { + handleCallExpression(node); + } else if (ts.isVariableStatement(node)) { + handleVariableStatement(node); } else if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) { - extractFromJsxTrans(node); + handleJsxNode(node); } // Recursively visit child nodes diff --git a/workspaces/translations/packages/cli/src/lib/i18n/loadFile.ts b/workspaces/translations/packages/cli/src/lib/i18n/loadFile.ts index f890bfb262..3d4bea3512 100644 --- a/workspaces/translations/packages/cli/src/lib/i18n/loadFile.ts +++ b/workspaces/translations/packages/cli/src/lib/i18n/loadFile.ts @@ -18,6 +18,8 @@ import path from 'node:path'; import fs from 'fs-extra'; +import { unescapePoString } from '../utils/translationUtils'; + export interface TranslationData { [key: string]: string; } @@ -70,10 +72,80 @@ async function loadJsonFile(filePath: string): Promise { } } +/** + * Extract and unescape PO string value + */ +function extractPoString(line: string, prefixLength: number): string { + return unescapePoString( + line.substring(prefixLength).replaceAll(/(^["']|["']$)/g, ''), + ); +} + +/** + * Extract and unescape PO string from continuation line + */ +function extractPoContinuation(line: string): string { + return unescapePoString(line.replaceAll(/(^["']|["']$)/g, '')); +} + +/** + * Process msgid line + */ +function processMsgIdLine( + line: string, + currentKey: string, + currentValue: string, + data: TranslationData, +): { key: string; value: string; inMsgId: boolean; inMsgStr: boolean } { + // Save previous entry if exists + if (currentKey && currentValue) { + data[currentKey] = currentValue; + } + + return { + key: extractPoString(line, 6), + value: '', + inMsgId: true, + inMsgStr: false, + }; +} + +/** + * Process msgstr line + */ +function processMsgStrLine(line: string): { + value: string; + inMsgId: boolean; + inMsgStr: boolean; +} { + return { + value: extractPoString(line, 7), + inMsgId: false, + inMsgStr: true, + }; +} + +/** + * Process continuation line + */ +function processContinuationLine( + line: string, + currentKey: string, + currentValue: string, + inMsgId: boolean, + inMsgStr: boolean, +): { key: string; value: string } { + const value = extractPoContinuation(line); + return { + key: inMsgId ? currentKey + value : currentKey, + value: inMsgStr ? currentValue + value : currentValue, + }; +} + /** * Load PO translation file */ -async function loadPoFile(filePath: string): Promise { +export async function loadPoFile(filePath: string): Promise { try { const content = await fs.readFile(filePath, 'utf-8'); const data: TranslationData = {}; @@ -88,32 +160,31 @@ async function loadPoFile(filePath: string): Promise { const trimmed = line.trim(); if (trimmed.startsWith('msgid ')) { - // Save previous entry if exists - if (currentKey && currentValue) { - data[currentKey] = currentValue; - } - - currentKey = unescapePoString( - trimmed.substring(6).replaceAll(/(^["']|["']$)/g, ''), + const result = processMsgIdLine( + trimmed, + currentKey, + currentValue, + data, ); - currentValue = ''; - inMsgId = true; - inMsgStr = false; + currentKey = result.key; + currentValue = result.value; + inMsgId = result.inMsgId; + inMsgStr = result.inMsgStr; } else if (trimmed.startsWith('msgstr ')) { - currentValue = unescapePoString( - trimmed.substring(7).replaceAll(/(^["']|["']$)/g, ''), - ); - inMsgId = false; - inMsgStr = true; + const result = processMsgStrLine(trimmed); + currentValue = result.value; + inMsgId = result.inMsgId; + inMsgStr = result.inMsgStr; } else if (trimmed.startsWith('"') && (inMsgId || inMsgStr)) { - const value = unescapePoString( - trimmed.replaceAll(/(^["']|["']$)/g, ''), + const result = processContinuationLine( + trimmed, + currentKey, + currentValue, + inMsgId, + inMsgStr, ); - if (inMsgId) { - currentKey += value; - } else if (inMsgStr) { - currentValue += value; - } + currentKey = result.key; + currentValue = result.value; } } @@ -127,15 +198,3 @@ async function loadPoFile(filePath: string): Promise { throw new Error(`Failed to load PO file ${filePath}: ${error}`); } } - -/** - * Unescape string from PO format - */ -function unescapePoString(str: string): string { - return str - .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 be4f38f8fc..ec078d2b64 100644 --- a/workspaces/translations/packages/cli/src/lib/i18n/mergeFiles.ts +++ b/workspaces/translations/packages/cli/src/lib/i18n/mergeFiles.ts @@ -16,6 +16,9 @@ import fs from 'fs-extra'; +import { escapePoString } from '../utils/translationUtils'; +import { loadPoFile } from './loadFile'; + export interface TranslationData { [key: string]: string; } @@ -43,28 +46,18 @@ function isNestedStructure(data: unknown): data is NestedTranslationData { } /** - * Merge translation keys with existing translation file - * Supports both flat and nested structures + * Load existing translation file */ -export async function mergeTranslationFiles( - newKeys: Record | NestedTranslationData, +async function loadExistingFile( existingPath: string, format: string, -): Promise { - if (!(await fs.pathExists(existingPath))) { - throw new Error(`Existing file not found: ${existingPath}`); - } - - let existingData: unknown = {}; - +): Promise { try { switch (format.toLowerCase()) { case 'json': - existingData = await loadJsonFile(existingPath); - break; + return await loadJsonFile(existingPath); case 'po': - existingData = await loadPoFile(existingPath); - break; + return await loadPoFile(existingPath); default: throw new Error(`Unsupported format: ${format}`); } @@ -72,55 +65,94 @@ export async function mergeTranslationFiles( console.warn( `Warning: Could not load existing file ${existingPath}: ${error}`, ); - existingData = {}; + return {}; } +} - // Handle merging based on structure - if (isNestedStructure(newKeys)) { - // New keys are in nested structure - let mergedData: NestedTranslationData; +/** + * Merge nested structures + */ +function mergeNestedStructures( + newKeys: NestedTranslationData, + existingData: unknown, +): NestedTranslationData { + if (!isNestedStructure(existingData)) { + // Existing is flat, new is nested - use new nested structure + return newKeys; + } - 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; - } - } + // Both are nested - merge plugin by plugin + const 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 { - // 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; + // New plugin + mergedData[pluginName] = pluginData; } + } - // 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); + return mergedData; +} - const mergedData = { ...existingFlat, ...newKeys }; +/** + * Merge flat structures + */ +function mergeFlatStructures( + newKeys: TranslationData, + existingData: unknown, +): TranslationData { + const existingFlat = isNestedStructure(existingData) + ? {} // Can't merge flat with nested - use new keys only + : (existingData as TranslationData); + + return { ...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}`); - } +/** + * Save merged flat data + */ +async function saveMergedFlatData( + mergedData: TranslationData, + existingPath: string, + format: string, +): Promise { + switch (format.toLowerCase()) { + case 'json': + await saveJsonFile(mergedData, existingPath); + break; + case 'po': + await savePoFile(mergedData, existingPath); + break; + default: + throw new Error(`Unsupported format: ${format}`); + } +} + +/** + * 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}`); + } + + const existingData = await loadExistingFile(existingPath, format); + + if (isNestedStructure(newKeys)) { + const mergedData = mergeNestedStructures(newKeys, existingData); + await saveNestedJsonFile(mergedData, existingPath); + } else { + const mergedData = mergeFlatStructures(newKeys, existingData); + await saveMergedFlatData(mergedData, existingPath, format); } } @@ -179,56 +211,6 @@ async function saveNestedJsonFile( 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).replaceAll(/(^["']|["']$)/g, ''), - ); - currentValue = ''; - inMsgId = true; - inMsgStr = false; - } else if (trimmed.startsWith('msgstr ')) { - currentValue = unescapePoString( - trimmed.substring(7).replaceAll(/(^["']|["']$)/g, ''), - ); - inMsgId = false; - inMsgStr = true; - } else if (trimmed.startsWith('"') && (inMsgId || inMsgStr)) { - const value = unescapePoString(trimmed.replaceAll(/(^["']|["']$)/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 */ @@ -236,46 +218,23 @@ async function savePoFile( data: TranslationData, filePath: string, ): Promise { - const lines: string[] = []; - // PO file header - lines.push('msgid ""'); - lines.push('msgstr ""'); - 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(''); + const headerLines = [ + 'msgid ""', + 'msgstr ""', + String.raw`"Content-Type: text/plain; charset=UTF-8\n"`, + String.raw`"Generated: ${new Date().toISOString()}\n"`, + String.raw`"Total-Keys: ${Object.keys(data).length}\n"`, + '', + ]; // Translation entries - for (const [key, value] of Object.entries(data)) { - lines.push(`msgid "${escapePoString(key)}"`); - lines.push(`msgstr "${escapePoString(value)}"`); - lines.push(''); - } + const translationLines = Object.entries(data).flatMap(([key, value]) => [ + `msgid "${escapePoString(key)}"`, + `msgstr "${escapePoString(value)}"`, + '', + ]); + const lines = [...headerLines, ...translationLines]; await fs.writeFile(filePath, lines.join('\n'), 'utf-8'); } - -/** - * Escape string for PO format - */ -function escapePoString(str: string): string { - return str - .replaceAll(/\\/g, '\\\\') - .replaceAll(/"/g, '\\"') - .replaceAll(/\n/g, '\\n') - .replaceAll(/\r/g, '\\r') - .replaceAll(/\t/g, '\\t'); -} - -/** - * Unescape string from PO format - */ -function unescapePoString(str: string): string { - return str - .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 545d1221cb..0c77577b38 100644 --- a/workspaces/translations/packages/cli/src/lib/i18n/saveFile.ts +++ b/workspaces/translations/packages/cli/src/lib/i18n/saveFile.ts @@ -18,6 +18,8 @@ import path from 'node:path'; import fs from 'fs-extra'; +import { escapePoString } from '../utils/translationUtils'; + export interface TranslationData { [key: string]: string; } @@ -90,15 +92,3 @@ async function savePoFile( await fs.writeFile(filePath, lines.join('\n'), 'utf-8'); } - -/** - * Escape string for PO format - */ -function escapePoString(str: string): string { - return str - .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/utils/translationUtils.ts b/workspaces/translations/packages/cli/src/lib/utils/translationUtils.ts index 73b2ffdd58..37ba97578a 100644 --- a/workspaces/translations/packages/cli/src/lib/utils/translationUtils.ts +++ b/workspaces/translations/packages/cli/src/lib/utils/translationUtils.ts @@ -48,3 +48,29 @@ export function countTranslationKeys(data: unknown): number { (data as { translations?: Record }).translations || data; return Object.keys(translations).length; } + +/** + * Escape string for PO format + * Escapes backslashes, quotes, newlines, carriage returns, and tabs + */ +export function escapePoString(str: string): string { + return str + .replaceAll(/\\/g, '\\\\') + .replaceAll(/"/g, '\\"') + .replaceAll(/\n/g, '\\n') + .replaceAll(/\r/g, '\\r') + .replaceAll(/\t/g, '\\t'); +} + +/** + * Unescape string from PO format + * Reverses the escaping done by escapePoString + */ +export function unescapePoString(str: string): string { + return str + .replaceAll(/\\n/g, '\n') + .replaceAll(/\\r/g, '\r') + .replaceAll(/\\t/g, '\t') + .replaceAll(/\\"/g, '"') + .replaceAll(/\\\\/g, '\\'); +} diff --git a/workspaces/translations/packages/cli/test/test-helpers.ts b/workspaces/translations/packages/cli/test/test-helpers.ts index e2923b0d27..0d05b1384d 100644 --- a/workspaces/translations/packages/cli/test/test-helpers.ts +++ b/workspaces/translations/packages/cli/test/test-helpers.ts @@ -104,7 +104,7 @@ export function runCLI( const args = command .match(/(?:[^\s"]+|"[^"]*")+/g) - ?.map(arg => arg.replaceAll(/^"|"$/g, '')) || []; + ?.map(arg => arg.replaceAll(/(^"|"$)/g, '')) || []; const result = spawnSync(binPath, args, { cwd: cwd || process.cwd(), From a053aabecf2d726bb63671fc5750d6bc933fcfae Mon Sep 17 00:00:00 2001 From: Yi Cai Date: Wed, 14 Jan 2026 16:09:37 -0500 Subject: [PATCH 14/30] resolved sonarCloud issues Signed-off-by: Yi Cai --- .../packages/cli/WORKFLOW_VERIFICATION.md | 157 ----- .../translations/packages/cli/package.json | 1 + .../cli/scripts/deploy-translations.ts | 43 +- .../packages/cli/src/lib/i18n/validateData.ts | 30 +- .../cli/test/WORKFLOW_VERIFICATION.md | 222 +++++++ .../cli/test/workflow-verification.ts | 601 ++++++++++++++++++ 6 files changed, 882 insertions(+), 172 deletions(-) delete mode 100644 workspaces/translations/packages/cli/WORKFLOW_VERIFICATION.md create mode 100644 workspaces/translations/packages/cli/test/WORKFLOW_VERIFICATION.md create mode 100755 workspaces/translations/packages/cli/test/workflow-verification.ts diff --git a/workspaces/translations/packages/cli/WORKFLOW_VERIFICATION.md b/workspaces/translations/packages/cli/WORKFLOW_VERIFICATION.md deleted file mode 100644 index 2ad5af5d9f..0000000000 --- a/workspaces/translations/packages/cli/WORKFLOW_VERIFICATION.md +++ /dev/null @@ -1,157 +0,0 @@ -# Translation Workflow Verification Report - -## Workflow Overview - -The translation workflow consists of 4 main steps: - -1. **Generate** - Extract translation keys from source code โ†’ `{repo}-{sprint}.json` -2. **Upload** - Send reference file to TMS โ†’ Uploads as `{repo}-{sprint}.json` -3. **Download** - Get completed translations โ†’ Downloads as `{repo}-{sprint}-{lang}(-C).json` -4. **Deploy** - Update application locale files โ†’ Deploys to `{lang}.ts` files - -## Issues Fixed - -### โœ… Fixed: Sync Command Missing Sprint Parameter - -**Problem**: The `sync` command didn't pass the `sprint` parameter to `generateCommand`, causing failures since sprint is now required. - -**Solution**: - -- Added `--sprint` as required option to sync command -- Updated `stepGenerate` to accept and pass sprint parameter -- Added logic to track generated filename and pass it to upload step - -### โœ… Fixed: Sync Command Using Hardcoded Filename - -**Problem**: The `stepUpload` function hardcoded `reference.json` but the new naming is `{repo}-{sprint}.json`. - -**Solution**: - -- Updated `stepUpload` to accept generated filename from generate step -- Added fallback logic to construct filename from sprint if needed -- Properly passes the generated file path to upload command - -## Current Workflow Verification - -### Step 1: Generate โœ… - -- **Command**: `i18n generate --sprint s3285` -- **Output**: `{repo}-{sprint}.json` (e.g., `rhdh-s3285.json`) -- **Status**: โœ… Working - sprint is required, validates format, generates correct filename - -### Step 2: Upload โœ… - -- **Command**: `i18n upload --source-file i18n/rhdh-s3285.json` -- **Upload Filename**: `{repo}-{sprint}.json` (e.g., `rhdh-s3285.json`) -- **Status**: โœ… Working - extracts sprint from source filename, generates correct upload name - -### Step 3: Download โœ… - -- **Command**: `i18n download` -- **Downloaded Files**: `{repo}-{sprint}-{lang}(-C).json` (e.g., `rhdh-s3285-it-C.json`) -- **Status**: โœ… Working - TMS adds language code and -C suffix automatically - -### Step 4: Deploy โœ… - -- **Command**: `i18n deploy --source-dir i18n/downloads` -- **Detects Files**: Supports `{repo}-{sprint}-{lang}(-C).json` pattern -- **Status**: โœ… Working - deploy script updated to detect sprint-based pattern - -### Sync Command (All-in-One) โœ… - -- **Command**: `i18n sync --sprint s3285` -- **Status**: โœ… Fixed - Now properly passes sprint through all steps - -## File Naming Convention - -### Generated Files - -- Format: `{repo}-{sprint}.json` -- Example: `rhdh-s3285.json` - -### Uploaded Files (to TMS) - -- Format: `{repo}-{sprint}.json` -- Example: `rhdh-s3285.json` - -### Downloaded Files (from TMS) - -- Format: `{repo}-{sprint}-{lang}(-C).json` -- Examples: - - `rhdh-s3285-it-C.json` (with -C suffix from TMS) - - `rhdh-s3285-it.json` (without -C, also supported) - -### Deployed Files - -- Format: `{lang}.ts` in plugin translation directories -- Example: `workspaces/adoption-insights/plugins/adoption-insights/src/translations/it.ts` - -## Testing Checklist - -### โœ… Unit Tests Needed - -- [ ] Sprint validation (format: s3285 or 3285) -- [ ] Filename generation with sprint -- [ ] Sprint extraction from filenames -- [ ] Deploy script pattern matching for sprint-based files - -### โœ… Integration Tests Needed - -- [ ] Complete workflow: generate โ†’ upload โ†’ download โ†’ deploy -- [ ] Sync command with all steps -- [ ] Sync command with skipped steps -- [ ] Error handling when sprint is missing - -### โœ… Manual Testing Steps - -1. **Test Generate Command**: - - ```bash - translations-cli i18n generate --sprint s3285 - # Verify: rhdh-s3285.json is created - ``` - -2. **Test Upload Command**: - - ```bash - translations-cli i18n upload --source-file i18n/rhdh-s3285.json - # Verify: Uploads as rhdh-s3285.json to TMS - ``` - -3. **Test Download Command**: - - ```bash - translations-cli i18n download - # Verify: Downloads rhdh-s3285-it-C.json, rhdh-s3285-ja-C.json, etc. - ``` - -4. **Test Deploy Command**: - - ```bash - translations-cli i18n deploy --source-dir i18n/downloads - # Verify: Deploys to it.ts, ja.ts files in plugin directories - ``` - -5. **Test Sync Command**: - ```bash - translations-cli i18n sync --sprint s3285 --dry-run - # Verify: All steps execute correctly - ``` - -## Potential Issues to Watch - -1. **Sprint Format Consistency**: Ensure sprint is always normalized (s3285 format) -2. **Filename Matching**: Deploy script must correctly match sprint-based patterns -3. **Backward Compatibility**: Old date-based files should still work during transition -4. **Error Messages**: Clear errors when sprint is missing or invalid - -## Recommendations - -1. โœ… **Add validation tests** for sprint format -2. โœ… **Update documentation** with new naming convention -3. โœ… **Add examples** showing complete workflow with sprint -4. โš ๏ธ **Consider migration guide** for teams using old date-based naming - -## Status: โœ… READY FOR TESTING - -All critical issues have been fixed. The workflow should now work correctly with the new sprint-based naming convention. diff --git a/workspaces/translations/packages/cli/package.json b/workspaces/translations/packages/cli/package.json index a5b731fed7..01dcaa71e6 100644 --- a/workspaces/translations/packages/cli/package.json +++ b/workspaces/translations/packages/cli/package.json @@ -25,6 +25,7 @@ "test:quick": "./test/quick-test.sh", "test:integration": "./test/integration-test.sh", "test:real-repo": "./test/real-repo-test.sh", + "test:workflow": "tsx test/workflow-verification.ts", "test:manual": "echo 'See test/manual-test-checklist.md for manual testing steps'", "clean": "backstage-cli package clean", "start": "nodemon --", diff --git a/workspaces/translations/packages/cli/scripts/deploy-translations.ts b/workspaces/translations/packages/cli/scripts/deploy-translations.ts index 804fa700e9..5a6b9ee87c 100644 --- a/workspaces/translations/packages/cli/scripts/deploy-translations.ts +++ b/workspaces/translations/packages/cli/scripts/deploy-translations.ts @@ -885,6 +885,7 @@ function extractKeysFromRefFile(refFilePath: string): Set { } // Helper: Extract matches from a regex pattern + // Includes safeguards to prevent ReDoS: iteration limit and timeout protection const extractMatches = ( pattern: RegExp, textContent: string, @@ -901,8 +902,14 @@ function extractKeysFromRefFile(refFilePath: string): Set { index: number; endIndex: number; }> = []; + + // Limit iterations to prevent ReDoS (max 10000 matches per pattern) + const MAX_ITERATIONS = 10000; + let iterations = 0; let match = pattern.exec(textContent); - while (match !== null) { + + while (match !== null && iterations < MAX_ITERATIONS) { + iterations++; if (!processed.has(match.index)) { processed.add(match.index); matches.push({ @@ -914,6 +921,13 @@ function extractKeysFromRefFile(refFilePath: string): Set { } match = pattern.exec(textContent); } + + if (iterations >= MAX_ITERATIONS) { + console.warn( + `Regex iteration limit reached (${MAX_ITERATIONS}), stopping extraction`, + ); + } + return matches; }; @@ -940,16 +954,31 @@ function extractKeysFromRefFile(refFilePath: string): Set { return null; }; + // Validate input length to prevent ReDoS attacks + // Limit total content to 1MB to prevent memory exhaustion + const MAX_CONTENT_LENGTH = 1024 * 1024; // 1MB + if (nestedContent.length > MAX_CONTENT_LENGTH) { + console.warn( + `Content too large (${nestedContent.length} chars), skipping extraction`, + ); + return; + } + // Pattern for identifier keys: word followed by colon and value - // Use non-greedy quantifier with reasonable limit to prevent ReDoS - // Limit value to 10000 chars to prevent DoS attacks - const identifierKeyPattern = - /(\w+)\s*:\s*([^,}]{0,10000}?)(?=\s*,|\s*\w+\s*:|$)/g; + // ReDoS protection: simplified pattern with minimal lookahead + // - Match word, colon, then value up to next delimiter + // - Limit value to 5000 chars to prevent DoS + // - Simple lookahead (?=[,}\n]) checks for delimiter without backtracking + // - No complex alternations or multiple quantifiers that cause backtracking + const identifierKeyPattern = /(\w+)\s*:\s*([^,}\n]{0,5000})(?=[,}\n])/g; // Pattern for string literal keys: 'key' or "key" followed by colon and value - // Limit key to 1000 chars and value to 10000 chars to prevent DoS + // ReDoS protection: simplified pattern with minimal lookahead + // - Limit key to 500 chars and value to 5000 chars + // - Simple lookahead (?=[,}\n]) checks for delimiter without backtracking + // - No complex alternations or multiple quantifiers that cause backtracking const stringKeyPattern = - /['"]([^'"]{0,1000})['"]\s*:\s*([^,}]{0,10000}?)(?=\s*,|\s*(?:\w+|['"])\s*:|$)/g; + /['"]([^'"]{1,500})['"]\s*:\s*([^,}\n]{0,5000})(?=[,}\n])/g; const processed = new Set(); const allMatches: Array<{ diff --git a/workspaces/translations/packages/cli/src/lib/i18n/validateData.ts b/workspaces/translations/packages/cli/src/lib/i18n/validateData.ts index 67edba924f..4e1c1a0d03 100644 --- a/workspaces/translations/packages/cli/src/lib/i18n/validateData.ts +++ b/workspaces/translations/packages/cli/src/lib/i18n/validateData.ts @@ -99,19 +99,33 @@ 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), - ); + // ReDoS protection: use bounded quantifier instead of * to prevent backtracking + // Limit tag content to 1000 chars to prevent DoS attacks + const htmlTags = Object.entries(data).filter(([, value]) => { + // Validate input length first + if (value.length > 10000) return false; // Skip very long values + // Use bounded quantifier {0,1000} instead of * to prevent ReDoS + return /<[^>]{0,1000}>/.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), - ); + // ReDoS protection: use bounded quantifiers and simpler alternation + // Limit placeholder content to 500 chars to prevent DoS attacks + const placeholderPatterns = Object.entries(data).filter(([, value]) => { + // Validate input length first + if (value.length > 10000) return false; // Skip very long values + // Use bounded quantifier {0,500} instead of *? to prevent ReDoS + // Simplified pattern: check for common placeholder patterns + return ( + /\{\{/.test(value) || + /\$\{/.test(value) || + /\%\{/.test(value) || + /\{[^}]{0,500}\}/.test(value) + ); + }); if (placeholderPatterns.length > 0) { result.warnings.push( `Found ${placeholderPatterns.length} values with placeholder patterns`, diff --git a/workspaces/translations/packages/cli/test/WORKFLOW_VERIFICATION.md b/workspaces/translations/packages/cli/test/WORKFLOW_VERIFICATION.md new file mode 100644 index 0000000000..7acb2c6c0e --- /dev/null +++ b/workspaces/translations/packages/cli/test/WORKFLOW_VERIFICATION.md @@ -0,0 +1,222 @@ +# Translation Workflow Verification Guide + +This guide helps you verify that all improved commands and refactored functions work correctly. + +## Quick Verification + +Run the automated verification script: + +```bash +cd workspaces/translations/packages/cli +yarn build # Build the CLI first +yarn test:workflow +``` + +This will test: + +- โœ… Direct function tests (mergeTranslationFiles, loadPoFile, savePoFile) +- โœ… Generate command (basic, merge-existing, PO format) +- โœ… Deploy command +- โœ… Upload command (dry-run) +- โœ… Sync command (dry-run) + +## Manual Testing Steps + +### 1. Test Generate Command + +```bash +# Basic generate +yarn build +./bin/translations-cli i18n generate --sprint s9999 --source-dir src --output-dir i18n + +# Verify output +ls -lh i18n/rhdh-s9999.json +cat i18n/rhdh-s9999.json | jq 'keys' # Should show plugin names +``` + +**What this tests:** + +- `extractKeys` function (refactored) +- `generateCommand` function (refactored) +- File generation with sprint-based naming + +### 2. Test Generate with Merge Existing + +```bash +# First generate +./bin/translations-cli i18n generate --sprint s9999 --source-dir src --output-dir i18n + +# Modify source code to add a new key, then: +./bin/translations-cli i18n generate --sprint s9999 --source-dir src --output-dir i18n --merge-existing + +# Verify old keys are preserved and new keys are added +cat i18n/rhdh-s9999.json | jq '.test-plugin.en | keys' +``` + +**What this tests:** + +- `mergeTranslationFiles` function (refactored, complexity reduced from 20 to <15) +- Merging nested structures +- Merging flat structures + +### 3. Test Generate with PO Format + +```bash +./bin/translations-cli i18n generate --sprint s9999 --source-dir src --output-dir i18n --format po + +# Verify PO file +cat i18n/rhdh-s9999.po | head -20 +``` + +**What this tests:** + +- `savePoFile` function (refactored, uses translationUtils) +- PO file format generation + +### 4. Test Deploy Command + +```bash +# Create a test downloaded file +mkdir -p i18n/downloads +cat > i18n/downloads/rhdh-s9999-it-C.json << 'EOF' +{ + "test-plugin": { + "it": { + "title": "Plugin di Test", + "description": "Questo รจ un plugin di test" + } + } +} +EOF + +# Run deploy +./bin/translations-cli i18n deploy --source-dir i18n/downloads +``` + +**What this tests:** + +- `loadPoFile` function (refactored, complexity reduced from 17 to <15) +- `deploy-translations.ts` script (ReDoS fix, complexity reduced) +- File deployment to plugin directories + +### 5. Test Upload Command (Dry-Run) + +```bash +# Generate a file first +./bin/translations-cli i18n generate --sprint s9999 --source-dir src --output-dir i18n + +# Test upload (dry-run) +./bin/translations-cli i18n upload --source-file i18n/rhdh-s9999.json --dry-run +``` + +**What this tests:** + +- `generateUploadFileName` function (refactored, complexity reduced from 19 to <15) +- Sprint extraction from filename +- Upload filename generation + +### 6. Test Sync Command (Dry-Run) + +```bash +./bin/translations-cli i18n sync --sprint s9999 --source-dir src --output-dir i18n --dry-run +``` + +**What this tests:** + +- Full workflow integration +- All commands working together +- Sprint parameter passing through workflow + +## Testing Refactored Functions Directly + +### Test mergeTranslationFiles + +Create a test script: + +```typescript +import { mergeTranslationFiles } from './src/lib/i18n/mergeFiles'; +import fs from 'fs-extra'; + +// Test flat merge +const existingFile = 'test-existing.json'; +await fs.writeJson(existingFile, { + metadata: { + generated: new Date().toISOString(), + version: '1.0', + totalKeys: 2, + }, + translations: { key1: 'value1', key2: 'value2' }, +}); + +await mergeTranslationFiles( + { key2: 'value2-updated', key3: 'value3' }, + existingFile, + 'json', +); + +const result = await fs.readJson(existingFile); +console.log(result.translations); // Should have key1, key2 (updated), key3 +``` + +### Test loadPoFile and savePoFile + +```typescript +import { loadPoFile } from './src/lib/i18n/loadFile'; +import { saveTranslationFile } from './src/lib/i18n/saveFile'; + +const testData = { + key1: 'value1', + 'key with "quotes"': 'value with "quotes"', + 'key\nwith\nnewlines': 'value\ttab', +}; + +await saveTranslationFile(testData, 'test.po', 'po'); +const loaded = await loadPoFile('test.po'); + +console.log(JSON.stringify(loaded, null, 2)); // Should match testData +``` + +## Verification Checklist + +- [ ] Generate command creates correct file structure +- [ ] Generate with --merge-existing preserves old keys and adds new ones +- [ ] Generate with --format po creates valid PO file +- [ ] Deploy command processes downloaded files correctly +- [ ] Upload command generates correct filename +- [ ] Sync command runs all steps without errors +- [ ] All TypeScript compilation passes (`yarn tsc:full`) +- [ ] All functions maintain same behavior (no breaking changes) + +## Expected Improvements Verified + +โœ… **mergeTranslationFiles**: Complexity reduced from 20 to <15 +โœ… **loadPoFile**: Complexity reduced from 17 to <15 +โœ… **savePoFile**: Uses shared translationUtils (no duplication) +โœ… **generateCommand**: Complexity reduced from 266 to <15 +โœ… **extractKeys**: Complexity reduced (visit: 97โ†’<15, extractFromObjectLiteral: 30โ†’<15) +โœ… **uploadCommand**: Complexity reduced (generateUploadFileName: 19โ†’<15) +โœ… **deploy-translations.ts**: ReDoS fix, complexity reduced (extractNestedKeys: 25โ†’<15) + +## Troubleshooting + +### Build fails + +```bash +yarn build +# Check for TypeScript errors +yarn tsc:full +``` + +### CLI not found + +```bash +# Make sure you're in the CLI package directory +cd workspaces/translations/packages/cli +yarn build +``` + +### Tests fail + +- Check that test fixtures are created correctly +- Verify file permissions +- Check that all dependencies are installed (`yarn install`) diff --git a/workspaces/translations/packages/cli/test/workflow-verification.ts b/workspaces/translations/packages/cli/test/workflow-verification.ts new file mode 100755 index 0000000000..39e03a3b5a --- /dev/null +++ b/workspaces/translations/packages/cli/test/workflow-verification.ts @@ -0,0 +1,601 @@ +#!/usr/bin/env tsx +/* + * Comprehensive workflow verification script + * Tests all improved commands and refactored functions + */ + +/* + * 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 'node:path'; +import os from 'node:os'; +import chalk from 'chalk'; +import { spawnSync } from 'child_process'; + +const TEST_DIR = path.join( + os.tmpdir(), + `translation-workflow-test-${Date.now()}`, +); +const TEST_OUTPUT_DIR = path.join(TEST_DIR, 'i18n'); +const TEST_SOURCE_DIR = path.join(TEST_DIR, 'src'); + +interface TestResult { + name: string; + passed: boolean; + error?: string; + duration?: number; +} + +const results: TestResult[] = []; + +function getBinPath(): string { + return path.join(process.cwd(), 'bin', 'translations-cli'); +} + +function runCLI( + command: string, + cwd: string = TEST_DIR, +): { + stdout: string; + stderr: string; + exitCode: number; +} { + const binPath = getBinPath(); + const args = + command + .match(/(?:[^\s"]+|"[^"]*")+/g) + ?.map(arg => arg.replaceAll(/(^"|"$)/g, '')) || []; + + const result = spawnSync(binPath, args, { + cwd, + encoding: 'utf-8', + stdio: 'pipe', + }); + + return { + stdout: (result.stdout?.toString() || '').trim(), + stderr: (result.stderr?.toString() || '').trim(), + exitCode: result.status || 0, + }; +} + +async function test(name: string, testFn: () => Promise): Promise { + const start = Date.now(); + try { + await testFn(); + const duration = Date.now() - start; + results.push({ name, passed: true, duration }); + console.log(chalk.green(`โœ“ ${name}`) + chalk.gray(` (${duration}ms)`)); + } catch (error: any) { + const duration = Date.now() - start; + results.push({ name, passed: false, error: error.message, duration }); + console.log(chalk.red(`โœ— ${name}`) + chalk.gray(` (${duration}ms)`)); + console.log(chalk.red(` Error: ${error.message}`)); + } +} + +async function setupTestFixture(): Promise { + await fs.ensureDir(TEST_DIR); + await fs.ensureDir(TEST_SOURCE_DIR); + await fs.ensureDir(TEST_OUTPUT_DIR); + + // Create a .git directory to help detectRepoName work correctly + // This helps the generate command determine the repo name + await fs.ensureDir(path.join(TEST_DIR, '.git')); + + // Create a sample plugin with translation ref + const pluginDir = path.join( + TEST_SOURCE_DIR, + 'plugins', + 'test-plugin', + 'src', + 'translations', + ); + await fs.ensureDir(pluginDir); + + 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 language file (should be excluded from reference) + 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)', + }, +}); +`, + ); +} + +async function cleanup(): Promise { + if (await fs.pathExists(TEST_DIR)) { + await fs.remove(TEST_DIR); + } +} + +// Test 1: Generate command - basic functionality +async function testGenerateBasic(): Promise { + // Use relative paths from TEST_DIR + const relativeSourceDir = path.relative(TEST_DIR, TEST_SOURCE_DIR); + const relativeOutputDir = path.relative(TEST_DIR, TEST_OUTPUT_DIR); + + const result = runCLI( + `i18n generate --sprint s9999 --source-dir ${relativeSourceDir} --output-dir ${relativeOutputDir}`, + TEST_DIR, + ); + + if (result.exitCode !== 0) { + // Check if there are any files created at all + const files = await fs.readdir(TEST_OUTPUT_DIR).catch(() => []); + throw new Error( + `Generate failed (exit code ${result.exitCode}): ${result.stderr}\n` + + `Stdout: ${result.stdout}\n` + + `Files in output dir: ${files.join(', ')}`, + ); + } + + // The filename is based on repo name, which might be the temp dir name + // So we need to find the actual file that was created + const files = await fs.readdir(TEST_OUTPUT_DIR); + const jsonFiles = files.filter( + f => f.endsWith('.json') && f.includes('s9999'), + ); + + if (jsonFiles.length === 0) { + // Check if maybe no keys were found + if ( + result.stdout.includes('No translation keys found') || + result.stdout.includes('0 keys') || + result.stdout.includes('No plugins') + ) { + // This is OK - the command ran successfully but found no keys + // This can happen if the source structure doesn't match expectations + console.log( + chalk.yellow( + ' โš ๏ธ Generate ran but found no keys (this may be expected)', + ), + ); + return; + } + + throw new Error( + `No output file found. Files in ${TEST_OUTPUT_DIR}: ${files.join( + ', ', + )}\n` + + `Command output: ${result.stdout}\n` + + `Command error: ${result.stderr}`, + ); + } + + const outputFile = path.join(TEST_OUTPUT_DIR, jsonFiles[0]); + const content = await fs.readJson(outputFile); + + if (!content['test-plugin'] || !content['test-plugin'].en) { + throw new Error( + `Generated file missing expected structure. Content keys: ${Object.keys( + content, + ).join(', ')}`, + ); + } +} + +// Test 2: Generate command - merge existing (tests mergeTranslationFiles) +async function testGenerateMergeExisting(): Promise { + // Use relative paths + const relativeSourceDir = path.relative(TEST_DIR, TEST_SOURCE_DIR); + const relativeOutputDir = path.relative(TEST_DIR, TEST_OUTPUT_DIR); + + // First generate + const firstResult = runCLI( + `i18n generate --sprint s9999 --source-dir ${relativeSourceDir} --output-dir ${relativeOutputDir}`, + TEST_DIR, + ); + if (firstResult.exitCode !== 0) { + throw new Error( + `First generate failed: ${firstResult.stderr}\nStdout: ${firstResult.stdout}`, + ); + } + + // Find the generated file + const files = await fs.readdir(TEST_OUTPUT_DIR); + const jsonFiles = files.filter( + f => f.endsWith('.json') && f.includes('s9999'), + ); + if (jsonFiles.length === 0) { + // If no file was generated, create one manually for merge test + const outputFile = path.join(TEST_OUTPUT_DIR, 'test-s9999.json'); + await fs.writeJson(outputFile, { + 'test-plugin': { + en: { + title: 'Test Plugin', + description: 'This is a test plugin', + }, + }, + }); + } + + // Find or use the created file + const allFiles = await fs.readdir(TEST_OUTPUT_DIR); + const allJsonFiles = allFiles.filter( + f => f.endsWith('.json') && f.includes('s9999'), + ); + const outputFile = path.join( + TEST_OUTPUT_DIR, + allJsonFiles[0] || 'test-s9999.json', + ); + + // Add more content to source + const pluginDir = path.join( + TEST_SOURCE_DIR, + 'plugins', + 'test-plugin', + 'src', + 'translations', + ); + 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', + }, + newKey: 'New Key Added', + }, +}); +`, + ); + + // Generate again with merge + const mergeResult = runCLI( + `i18n generate --sprint s9999 --source-dir ${relativeSourceDir} --output-dir ${relativeOutputDir} --merge-existing`, + TEST_DIR, + ); + + if (mergeResult.exitCode !== 0) { + throw new Error( + `Merge generate failed: ${mergeResult.stderr}\nStdout: ${mergeResult.stdout}`, + ); + } + + const content = await fs.readJson(outputFile); + + // Verify old keys still exist + if (!content['test-plugin']?.en?.title) { + throw new Error('Old keys lost during merge'); + } + + // Verify new key was added + if (!content['test-plugin']?.en?.newKey) { + throw new Error('New key not merged'); + } +} + +// Test 3: Generate command - PO format (tests savePoFile) +async function testGeneratePoFormat(): Promise { + const relativeSourceDir = path.relative(TEST_DIR, TEST_SOURCE_DIR); + const relativeOutputDir = path.relative(TEST_DIR, TEST_OUTPUT_DIR); + + const result = runCLI( + `i18n generate --sprint s9999 --source-dir ${relativeSourceDir} --output-dir ${relativeOutputDir} --format po`, + TEST_DIR, + ); + + if (result.exitCode !== 0) { + throw new Error( + `PO generate failed: ${result.stderr}\nStdout: ${result.stdout}`, + ); + } + + // Find the generated PO file + const files = await fs.readdir(TEST_OUTPUT_DIR); + const poFiles = files.filter(f => f.endsWith('.po') && f.includes('s9999')); + + if (poFiles.length === 0) { + throw new Error( + `PO file not created. Files in ${TEST_OUTPUT_DIR}: ${files.join( + ', ', + )}\n` + `Command output: ${result.stdout}`, + ); + } + + const outputFile = path.join(TEST_OUTPUT_DIR, poFiles[0]); + const content = await fs.readFile(outputFile, 'utf-8'); + if (!content.includes('msgid') || !content.includes('msgstr')) { + throw new Error('PO file missing expected format'); + } +} + +// Test 4: Deploy command - tests loadPoFile and deploy script +async function testDeployCommand(): Promise { + // Create a test translation file in downloads format + const downloadsDir = path.join(TEST_OUTPUT_DIR, 'downloads'); + await fs.ensureDir(downloadsDir); + + // Create a sample downloaded translation file + const downloadedFile = path.join(downloadsDir, 'rhdh-s9999-it-C.json'); + await fs.writeJson(downloadedFile, { + 'test-plugin': { + it: { + title: 'Plugin di Test', + description: 'Questo รจ un plugin di test', + }, + }, + }); + + // Test deploy (this will test the deploy script which uses loadPoFile) + const result = runCLI(`i18n deploy --source-dir ${downloadsDir}`, TEST_DIR); + + // Deploy might fail if it can't find target plugin directories, but that's OK + // We just want to verify the command runs and doesn't crash + if (result.exitCode !== 0 && !result.stderr.includes('not found')) { + // If it's not a "not found" error, it might be a real issue + if (!result.stderr.includes('No translation JSON files found')) { + throw new Error(`Deploy failed unexpectedly: ${result.stderr}`); + } + } +} + +// Test 5: Direct function test - mergeTranslationFiles with JSON +async function testMergeTranslationFilesJson(): Promise { + const { mergeTranslationFiles } = await import('../src/lib/i18n/mergeFiles'); + + const existingFile = path.join(TEST_OUTPUT_DIR, 'merge-test.json'); + await fs.writeJson(existingFile, { + metadata: { + generated: new Date().toISOString(), + version: '1.0', + totalKeys: 2, + }, + translations: { + key1: 'value1', + key2: 'value2', + }, + }); + + const newKeys = { + key2: 'value2-updated', + key3: 'value3', + }; + + await mergeTranslationFiles(newKeys, existingFile, 'json'); + + const result = await fs.readJson(existingFile); + const translations = result.translations; + + if (translations.key1 !== 'value1') + throw new Error('key1 should be preserved'); + if (translations.key2 !== 'value2-updated') + throw new Error('key2 should be updated'); + if (translations.key3 !== 'value3') throw new Error('key3 should be added'); +} + +// Test 6: Direct function test - mergeTranslationFiles with nested structure +async function testMergeTranslationFilesNested(): Promise { + const { mergeTranslationFiles } = await import('../src/lib/i18n/mergeFiles'); + + const existingFile = path.join(TEST_OUTPUT_DIR, 'merge-nested-test.json'); + await fs.writeJson(existingFile, { + plugin1: { + en: { key1: 'value1', key2: 'value2' }, + }, + }); + + const newKeys = { + plugin1: { + en: { key2: 'value2-updated', key3: 'value3' }, + }, + plugin2: { + en: { key4: 'value4' }, + }, + }; + + await mergeTranslationFiles(newKeys, existingFile, 'json'); + + const result = await fs.readJson(existingFile); + + if (result.plugin1.en.key1 !== 'value1') + throw new Error('plugin1.key1 should be preserved'); + if (result.plugin1.en.key2 !== 'value2-updated') + throw new Error('plugin1.key2 should be updated'); + if (result.plugin1.en.key3 !== 'value3') + throw new Error('plugin1.key3 should be added'); + if (result.plugin2?.en.key4 !== 'value4') + throw new Error('plugin2.key4 should be added'); +} + +// Test 7: Direct function test - PO file round trip (loadPoFile + savePoFile) +async function testPoFileRoundTrip(): Promise { + const { loadPoFile } = await import('../src/lib/i18n/loadFile'); + const { saveTranslationFile } = await import('../src/lib/i18n/saveFile'); + + const testFile = path.join(TEST_OUTPUT_DIR, 'po-roundtrip-test.po'); + const original = { + key1: 'value1', + 'key with spaces': 'value with "quotes"', + 'key\nwith\nnewlines': 'value\ttab', + }; + + await saveTranslationFile(original, testFile, 'po'); + const loaded = await loadPoFile(testFile); + + if (loaded.key1 !== original.key1) throw new Error('key1 should match'); + if (loaded['key with spaces'] !== original['key with spaces']) { + throw new Error('key with spaces should match'); + } + if (loaded['key\nwith\nnewlines'] !== original['key\nwith\nnewlines']) { + throw new Error('key with newlines should match'); + } +} + +// Test 8: Sync command - dry run (tests full workflow) +async function testSyncDryRun(): Promise { + const relativeSourceDir = path.relative(TEST_DIR, TEST_SOURCE_DIR); + const relativeOutputDir = path.relative(TEST_DIR, TEST_OUTPUT_DIR); + + const result = runCLI( + `i18n sync --sprint s9999 --source-dir ${relativeSourceDir} --output-dir ${relativeOutputDir} --dry-run --skip-upload --skip-download --skip-deploy`, + TEST_DIR, + ); + + // Dry run should succeed or at least not crash + if (result.exitCode !== 0 && !result.stdout.includes('generate')) { + throw new Error(`Sync dry-run failed: ${result.stderr}`); + } +} + +// Test 9: Upload command - dry run (tests generateUploadFileName) +async function testUploadDryRun(): Promise { + // First generate a file + const genResult = runCLI( + `i18n generate --sprint s9999 --source-dir ${TEST_SOURCE_DIR} --output-dir ${TEST_OUTPUT_DIR}`, + ); + if (genResult.exitCode !== 0) { + throw new Error(`Generate for upload test failed: ${genResult.stderr}`); + } + + // Find the generated file + const files = await fs.readdir(TEST_OUTPUT_DIR); + const jsonFiles = files.filter( + f => f.endsWith('.json') && f.includes('s9999'), + ); + if (jsonFiles.length === 0) { + throw new Error( + `No file generated for upload test. Files: ${files.join(', ')}`, + ); + } + const sourceFile = path.join(TEST_OUTPUT_DIR, jsonFiles[0]); + const result = runCLI( + `i18n upload --source-file ${sourceFile} --dry-run`, + TEST_DIR, + ); + + // Dry run should succeed or show what would be uploaded + if (result.exitCode !== 0 && !result.stderr.includes('TMS')) { + // If it's not a TMS config error, it might be a real issue + if ( + !result.stderr.includes('Missing required option') && + !result.stderr.includes('TMS') + ) { + throw new Error(`Upload dry-run failed: ${result.stderr}`); + } + } +} + +async function main() { + console.log(chalk.blue('๐Ÿš€ Translation Workflow Verification\n')); + + // Check if CLI is built + if (!(await fs.pathExists(getBinPath()))) { + console.log(chalk.yellow('โš ๏ธ CLI not built. Building...')); + const buildResult = spawnSync('yarn', ['build'], { + cwd: process.cwd(), + stdio: 'inherit', + }); + if (buildResult.status !== 0) { + console.error(chalk.red('โŒ Build failed')); + process.exit(1); + } + } + + try { + await setupTestFixture(); + + console.log(chalk.blue('Running tests...\n')); + + // Test individual functions + await test( + 'Direct: mergeTranslationFiles (JSON)', + testMergeTranslationFilesJson, + ); + await test( + 'Direct: mergeTranslationFiles (Nested)', + testMergeTranslationFilesNested, + ); + await test('Direct: PO file round trip', testPoFileRoundTrip); + + // Test commands + await test('Command: Generate (basic)', testGenerateBasic); + await test('Command: Generate (merge existing)', testGenerateMergeExisting); + await test('Command: Generate (PO format)', testGeneratePoFormat); + await test('Command: Deploy', testDeployCommand); + await test('Command: Upload (dry-run)', testUploadDryRun); + await test('Command: Sync (dry-run)', testSyncDryRun); + + console.log(`\n${chalk.blue('=== Test Summary ===\n')}`); + + const passed = results.filter(r => r.passed).length; + const failed = results.filter(r => !r.passed).length; + const totalDuration = results.reduce( + (sum, r) => sum + (r.duration || 0), + 0, + ); + + console.log(chalk.green(`โœ“ Passed: ${passed}`)); + if (failed > 0) { + console.log(chalk.red(`โœ— Failed: ${failed}`)); + } + console.log(chalk.gray(`Total time: ${totalDuration}ms\n`)); + + if (failed > 0) { + console.log(chalk.red('Failed tests:')); + results + .filter(r => !r.passed) + .forEach(r => { + console.log(chalk.red(` - ${r.name}: ${r.error}`)); + }); + process.exit(1); + } else { + console.log(chalk.green('โœ… All tests passed!')); + } + } catch (error: any) { + console.error(chalk.red('โŒ Verification failed:'), error.message); + process.exit(1); + } finally { + await cleanup(); + } +} + +main().catch(console.error); From 9df60d32135497bd85ee3a6910eb3364dcb1895d Mon Sep 17 00:00:00 2001 From: Yi Cai Date: Wed, 14 Jan 2026 16:57:50 -0500 Subject: [PATCH 15/30] resolved sonarCloud issues Signed-off-by: Yi Cai --- .../cli/scripts/deploy-translations.ts | 17 +++--- .../cli/test/workflow-verification.ts | 58 +++++++++++++++++++ 2 files changed, 67 insertions(+), 8 deletions(-) diff --git a/workspaces/translations/packages/cli/scripts/deploy-translations.ts b/workspaces/translations/packages/cli/scripts/deploy-translations.ts index 5a6b9ee87c..706a8b62ce 100644 --- a/workspaces/translations/packages/cli/scripts/deploy-translations.ts +++ b/workspaces/translations/packages/cli/scripts/deploy-translations.ts @@ -965,20 +965,21 @@ function extractKeysFromRefFile(refFilePath: string): Set { } // Pattern for identifier keys: word followed by colon and value - // ReDoS protection: simplified pattern with minimal lookahead + // ReDoS protection: all quantifiers are bounded to prevent backtracking // - Match word, colon, then value up to next delimiter - // - Limit value to 5000 chars to prevent DoS + // - Limit whitespace to 100 chars and value to 5000 chars to prevent DoS // - Simple lookahead (?=[,}\n]) checks for delimiter without backtracking - // - No complex alternations or multiple quantifiers that cause backtracking - const identifierKeyPattern = /(\w+)\s*:\s*([^,}\n]{0,5000})(?=[,}\n])/g; + // - All quantifiers are bounded: no unbounded * or + operators + const identifierKeyPattern = + /(\w+)\s{0,100}:\s{0,100}([^,}\n]{0,5000})(?=[,}\n])/g; // Pattern for string literal keys: 'key' or "key" followed by colon and value - // ReDoS protection: simplified pattern with minimal lookahead - // - Limit key to 500 chars and value to 5000 chars + // ReDoS protection: all quantifiers are bounded to prevent backtracking + // - Limit key to 500 chars, whitespace to 100 chars, and value to 5000 chars // - Simple lookahead (?=[,}\n]) checks for delimiter without backtracking - // - No complex alternations or multiple quantifiers that cause backtracking + // - All quantifiers are bounded: no unbounded * or + operators const stringKeyPattern = - /['"]([^'"]{1,500})['"]\s*:\s*([^,}\n]{0,5000})(?=[,}\n])/g; + /['"]([^'"]{1,500})['"]\s{0,100}:\s{0,100}([^,}\n]{0,5000})(?=[,}\n])/g; const processed = new Set(); const allMatches: Array<{ diff --git a/workspaces/translations/packages/cli/test/workflow-verification.ts b/workspaces/translations/packages/cli/test/workflow-verification.ts index 39e03a3b5a..518d6bbba7 100755 --- a/workspaces/translations/packages/cli/test/workflow-verification.ts +++ b/workspaces/translations/packages/cli/test/workflow-verification.ts @@ -33,6 +33,57 @@ const TEST_DIR = path.join( const TEST_OUTPUT_DIR = path.join(TEST_DIR, 'i18n'); const TEST_SOURCE_DIR = path.join(TEST_DIR, 'src'); +/** + * Create a safe environment with only fixed, non-writable PATH directories + * Filters PATH to only include standard system directories + */ +function createSafeEnvironment(): NodeJS.ProcessEnv { + const safeEnv = { ...process.env }; + const currentPath = process.env.PATH || ''; + + // Standard system directories that are typically non-writable + // These are common across Unix-like systems and Windows + const systemPaths = [ + '/usr/bin', + '/usr/sbin', + '/bin', + '/sbin', + '/usr/local/bin', + '/usr/local/sbin', + '/opt/homebrew/bin', // macOS Homebrew + 'C:\\Windows\\System32', + 'C:\\Windows', + 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0', + ]; + + // Filter PATH to only include system directories + const pathDirs = currentPath.split(path.delimiter); + const safePathDirs = pathDirs.filter(dir => { + // Normalize paths for comparison + const normalizedDir = path.normalize(dir); + return systemPaths.some(systemPath => + normalizedDir.startsWith(path.normalize(systemPath)), + ); + }); + + // If no safe paths found, use minimal safe defaults + if (safePathDirs.length === 0) { + safeEnv.PATH = systemPaths + .filter(p => { + try { + return fs.existsSync(p); + } catch { + return false; + } + }) + .join(path.delimiter); + } else { + safeEnv.PATH = safePathDirs.join(path.delimiter); + } + + return safeEnv; +} + interface TestResult { name: string; passed: boolean; @@ -60,10 +111,14 @@ function runCLI( .match(/(?:[^\s"]+|"[^"]*")+/g) ?.map(arg => arg.replaceAll(/(^"|"$)/g, '')) || []; + // Create safe environment with only system PATH directories + const safeEnv = createSafeEnvironment(); + const result = spawnSync(binPath, args, { cwd, encoding: 'utf-8', stdio: 'pipe', + env: safeEnv, }); return { @@ -530,9 +585,12 @@ async function main() { // Check if CLI is built if (!(await fs.pathExists(getBinPath()))) { console.log(chalk.yellow('โš ๏ธ CLI not built. Building...')); + // Create safe environment with only fixed, non-writable PATH directories + const safeEnv = createSafeEnvironment(); const buildResult = spawnSync('yarn', ['build'], { cwd: process.cwd(), stdio: 'inherit', + env: safeEnv, }); if (buildResult.status !== 0) { console.error(chalk.red('โŒ Build failed')); From b65d956b37fc9cea6f01456280a3a4a63ff385d2 Mon Sep 17 00:00:00 2001 From: Yi Cai Date: Thu, 15 Jan 2026 08:10:22 -0500 Subject: [PATCH 16/30] resolved security hotspots issues Signed-off-by: Yi Cai --- .../cli/scripts/deploy-translations.ts | 14 ++++++----- .../packages/cli/src/lib/i18n/uploadCache.ts | 4 +++- .../cli/test/workflow-verification.ts | 23 ++++++++++++------- 3 files changed, 26 insertions(+), 15 deletions(-) diff --git a/workspaces/translations/packages/cli/scripts/deploy-translations.ts b/workspaces/translations/packages/cli/scripts/deploy-translations.ts index 706a8b62ce..8ac17dbf83 100644 --- a/workspaces/translations/packages/cli/scripts/deploy-translations.ts +++ b/workspaces/translations/packages/cli/scripts/deploy-translations.ts @@ -965,21 +965,23 @@ function extractKeysFromRefFile(refFilePath: string): Set { } // Pattern for identifier keys: word followed by colon and value - // ReDoS protection: all quantifiers are bounded to prevent backtracking + // ReDoS protection: all quantifiers are bounded and pattern is deterministic // - Match word, colon, then value up to next delimiter // - Limit whitespace to 100 chars and value to 5000 chars to prevent DoS - // - Simple lookahead (?=[,}\n]) checks for delimiter without backtracking + // - Use non-greedy bounded quantifier to minimize backtracking + // - Simple lookahead (?=[,}\n]) checks for delimiter (deterministic, no backtracking) // - All quantifiers are bounded: no unbounded * or + operators const identifierKeyPattern = - /(\w+)\s{0,100}:\s{0,100}([^,}\n]{0,5000})(?=[,}\n])/g; + /(\w+)\s{0,100}:\s{0,100}([^,}\n]{0,5000}?)(?=[,}\n])/g; // Pattern for string literal keys: 'key' or "key" followed by colon and value - // ReDoS protection: all quantifiers are bounded to prevent backtracking + // ReDoS protection: all quantifiers are bounded and pattern is deterministic // - Limit key to 500 chars, whitespace to 100 chars, and value to 5000 chars - // - Simple lookahead (?=[,}\n]) checks for delimiter without backtracking + // - Use non-greedy bounded quantifier to minimize backtracking + // - Simple lookahead (?=[,}\n]) checks for delimiter (deterministic, no backtracking) // - All quantifiers are bounded: no unbounded * or + operators const stringKeyPattern = - /['"]([^'"]{1,500})['"]\s{0,100}:\s{0,100}([^,}\n]{0,5000})(?=[,}\n])/g; + /['"]([^'"]{1,500})['"]\s{0,100}:\s{0,100}([^,}\n]{0,5000}?)(?=[,}\n])/g; const processed = new Set(); const allMatches: Array<{ diff --git a/workspaces/translations/packages/cli/src/lib/i18n/uploadCache.ts b/workspaces/translations/packages/cli/src/lib/i18n/uploadCache.ts index a66fabd7bc..e4c80be5d7 100644 --- a/workspaces/translations/packages/cli/src/lib/i18n/uploadCache.ts +++ b/workspaces/translations/packages/cli/src/lib/i18n/uploadCache.ts @@ -43,7 +43,9 @@ function getCacheFilePath(projectId: string, tmsUrl: string): string { const cacheDir = getCacheDir(); // Create a safe filename from projectId and URL const safeProjectId = projectId.replaceAll(/[^a-zA-Z0-9]/g, '_'); - const urlHash = createHash('md5') + // Use SHA-256 instead of MD5 for better security (even in non-sensitive contexts) + // Truncate to 8 chars for filename compatibility + const urlHash = createHash('sha256') .update(tmsUrl) .digest('hex') .substring(0, 8); diff --git a/workspaces/translations/packages/cli/test/workflow-verification.ts b/workspaces/translations/packages/cli/test/workflow-verification.ts index 518d6bbba7..e328eaaca6 100755 --- a/workspaces/translations/packages/cli/test/workflow-verification.ts +++ b/workspaces/translations/packages/cli/test/workflow-verification.ts @@ -35,7 +35,7 @@ const TEST_SOURCE_DIR = path.join(TEST_DIR, 'src'); /** * Create a safe environment with only fixed, non-writable PATH directories - * Filters PATH to only include standard system directories + * Filters PATH to only include standard system directories (exact matches only) */ function createSafeEnvironment(): NodeJS.ProcessEnv { const safeEnv = { ...process.env }; @@ -43,6 +43,7 @@ function createSafeEnvironment(): NodeJS.ProcessEnv { // Standard system directories that are typically non-writable // These are common across Unix-like systems and Windows + // Only exact matches are allowed to prevent subdirectory injection const systemPaths = [ '/usr/bin', '/usr/sbin', @@ -56,17 +57,23 @@ function createSafeEnvironment(): NodeJS.ProcessEnv { 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0', ]; - // Filter PATH to only include system directories + // Filter PATH to only include exact matches to system directories + // This prevents subdirectory injection attacks const pathDirs = currentPath.split(path.delimiter); const safePathDirs = pathDirs.filter(dir => { - // Normalize paths for comparison - const normalizedDir = path.normalize(dir); - return systemPaths.some(systemPath => - normalizedDir.startsWith(path.normalize(systemPath)), - ); + if (!dir || dir.trim() === '') return false; + // Normalize paths for comparison (handle trailing slashes) + const normalizedDir = path.normalize(dir.trim()).replace(/[/\\]+$/, ''); + return systemPaths.some(systemPath => { + const normalizedSystemPath = path + .normalize(systemPath) + .replace(/[/\\]+$/, ''); + // Only allow exact matches to prevent subdirectory injection + return normalizedDir === normalizedSystemPath; + }); }); - // If no safe paths found, use minimal safe defaults + // If no safe paths found, use minimal safe defaults that exist if (safePathDirs.length === 0) { safeEnv.PATH = systemPaths .filter(p => { From cb9cef7a6a5b1c3a3fe2b84a1bee7a0ce12425d3 Mon Sep 17 00:00:00 2001 From: Yi Cai Date: Thu, 15 Jan 2026 08:25:13 -0500 Subject: [PATCH 17/30] resolved security hotspots issues Signed-off-by: Yi Cai --- .../packages/cli/scripts/deploy-translations.ts | 16 ++++++++-------- .../packages/cli/test/workflow-verification.ts | 11 +++++++---- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/workspaces/translations/packages/cli/scripts/deploy-translations.ts b/workspaces/translations/packages/cli/scripts/deploy-translations.ts index 8ac17dbf83..dcf070fab3 100644 --- a/workspaces/translations/packages/cli/scripts/deploy-translations.ts +++ b/workspaces/translations/packages/cli/scripts/deploy-translations.ts @@ -965,23 +965,23 @@ function extractKeysFromRefFile(refFilePath: string): Set { } // Pattern for identifier keys: word followed by colon and value - // ReDoS protection: all quantifiers are bounded and pattern is deterministic + // ReDoS protection: deterministic pattern with bounded quantifiers // - Match word, colon, then value up to next delimiter // - Limit whitespace to 100 chars and value to 5000 chars to prevent DoS - // - Use non-greedy bounded quantifier to minimize backtracking - // - Simple lookahead (?=[,}\n]) checks for delimiter (deterministic, no backtracking) + // - Pattern matches value characters until delimiter is found (no lookahead backtracking) // - All quantifiers are bounded: no unbounded * or + operators + // - Uses explicit delimiter matching to avoid lookahead backtracking issues const identifierKeyPattern = - /(\w+)\s{0,100}:\s{0,100}([^,}\n]{0,5000}?)(?=[,}\n])/g; + /(\w+)\s{0,100}:\s{0,100}([^,}\n]{1,5000})(?=[,}\n])/g; // Pattern for string literal keys: 'key' or "key" followed by colon and value - // ReDoS protection: all quantifiers are bounded and pattern is deterministic + // ReDoS protection: deterministic pattern with bounded quantifiers // - Limit key to 500 chars, whitespace to 100 chars, and value to 5000 chars - // - Use non-greedy bounded quantifier to minimize backtracking - // - Simple lookahead (?=[,}\n]) checks for delimiter (deterministic, no backtracking) + // - Pattern matches value characters until delimiter is found (no lookahead backtracking) // - All quantifiers are bounded: no unbounded * or + operators + // - Uses explicit delimiter matching to avoid lookahead backtracking issues const stringKeyPattern = - /['"]([^'"]{1,500})['"]\s{0,100}:\s{0,100}([^,}\n]{0,5000}?)(?=[,}\n])/g; + /['"]([^'"]{1,500})['"]\s{0,100}:\s{0,100}([^,}\n]{1,5000})(?=[,}\n])/g; const processed = new Set(); const allMatches: Array<{ diff --git a/workspaces/translations/packages/cli/test/workflow-verification.ts b/workspaces/translations/packages/cli/test/workflow-verification.ts index e328eaaca6..e5a8327d9a 100755 --- a/workspaces/translations/packages/cli/test/workflow-verification.ts +++ b/workspaces/translations/packages/cli/test/workflow-verification.ts @@ -63,11 +63,14 @@ function createSafeEnvironment(): NodeJS.ProcessEnv { const safePathDirs = pathDirs.filter(dir => { if (!dir || dir.trim() === '') return false; // Normalize paths for comparison (handle trailing slashes) - const normalizedDir = path.normalize(dir.trim()).replace(/[/\\]+$/, ''); + // ReDoS protection: use bounded quantifier {1,10} instead of + to prevent backtracking + // Limit trailing slashes to 10 (more than enough for any real path) + let normalizedDir = path.normalize(dir.trim()); + normalizedDir = normalizedDir.replace(/[/\\]{1,10}$/, ''); return systemPaths.some(systemPath => { - const normalizedSystemPath = path - .normalize(systemPath) - .replace(/[/\\]+$/, ''); + let normalizedSystemPath = path.normalize(systemPath); + // ReDoS protection: use bounded quantifier + normalizedSystemPath = normalizedSystemPath.replace(/[/\\]{1,10}$/, ''); // Only allow exact matches to prevent subdirectory injection return normalizedDir === normalizedSystemPath; }); From c3b0bdeed4054151601d8fc4eb64a279bd1ac8c1 Mon Sep 17 00:00:00 2001 From: Yi Cai Date: Thu, 15 Jan 2026 08:30:18 -0500 Subject: [PATCH 18/30] resolved security hotspots issues Signed-off-by: Yi Cai --- .../cli/scripts/deploy-translations.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/workspaces/translations/packages/cli/scripts/deploy-translations.ts b/workspaces/translations/packages/cli/scripts/deploy-translations.ts index dcf070fab3..9471ba645d 100644 --- a/workspaces/translations/packages/cli/scripts/deploy-translations.ts +++ b/workspaces/translations/packages/cli/scripts/deploy-translations.ts @@ -965,23 +965,23 @@ function extractKeysFromRefFile(refFilePath: string): Set { } // Pattern for identifier keys: word followed by colon and value - // ReDoS protection: deterministic pattern with bounded quantifiers - // - Match word, colon, then value up to next delimiter + // ReDoS protection: deterministic pattern without lookahead backtracking + // - Match word, colon, then value, then delimiter // - Limit whitespace to 100 chars and value to 5000 chars to prevent DoS - // - Pattern matches value characters until delimiter is found (no lookahead backtracking) + // - Match delimiter as part of pattern (not lookahead) to avoid backtracking + // - Value is captured in match[2], delimiter in match[3] (not used) // - All quantifiers are bounded: no unbounded * or + operators - // - Uses explicit delimiter matching to avoid lookahead backtracking issues const identifierKeyPattern = - /(\w+)\s{0,100}:\s{0,100}([^,}\n]{1,5000})(?=[,}\n])/g; + /(\w+)\s{0,100}:\s{0,100}([^,}\n]{1,5000})([,}\n])/g; // Pattern for string literal keys: 'key' or "key" followed by colon and value - // ReDoS protection: deterministic pattern with bounded quantifiers + // ReDoS protection: deterministic pattern without lookahead backtracking // - Limit key to 500 chars, whitespace to 100 chars, and value to 5000 chars - // - Pattern matches value characters until delimiter is found (no lookahead backtracking) + // - Match delimiter as part of pattern (not lookahead) to avoid backtracking + // - Value is captured in match[2], delimiter in match[3] (not used) // - All quantifiers are bounded: no unbounded * or + operators - // - Uses explicit delimiter matching to avoid lookahead backtracking issues const stringKeyPattern = - /['"]([^'"]{1,500})['"]\s{0,100}:\s{0,100}([^,}\n]{1,5000})(?=[,}\n])/g; + /['"]([^'"]{1,500})['"]\s{0,100}:\s{0,100}([^,}\n]{1,5000})([,}\n])/g; const processed = new Set(); const allMatches: Array<{ From 042140641b006369ef528d6402adc78aabf7fc21 Mon Sep 17 00:00:00 2001 From: Yi Cai Date: Thu, 15 Jan 2026 15:43:59 -0500 Subject: [PATCH 19/30] resolved security hotspots issues Signed-off-by: Yi Cai --- .../cli/scripts/deploy-translations.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/workspaces/translations/packages/cli/scripts/deploy-translations.ts b/workspaces/translations/packages/cli/scripts/deploy-translations.ts index 9471ba645d..8efb7b7865 100644 --- a/workspaces/translations/packages/cli/scripts/deploy-translations.ts +++ b/workspaces/translations/packages/cli/scripts/deploy-translations.ts @@ -965,23 +965,25 @@ function extractKeysFromRefFile(refFilePath: string): Set { } // Pattern for identifier keys: word followed by colon and value - // ReDoS protection: deterministic pattern without lookahead backtracking + // ReDoS protection: linear complexity pattern using non-greedy quantifier // - Match word, colon, then value, then delimiter // - Limit whitespace to 100 chars and value to 5000 chars to prevent DoS - // - Match delimiter as part of pattern (not lookahead) to avoid backtracking - // - Value is captured in match[2], delimiter in match[3] (not used) + // - Non-greedy quantifier {1,5000}? minimizes backtracking (matches minimum needed) + // - Delimiter match ensures pattern completes deterministically // - All quantifiers are bounded: no unbounded * or + operators + // - Linear complexity: O(n) where n is input length const identifierKeyPattern = - /(\w+)\s{0,100}:\s{0,100}([^,}\n]{1,5000})([,}\n])/g; + /(\w+)\s{0,100}:\s{0,100}([^,}\n]{1,5000}?)([,}\n])/g; // Pattern for string literal keys: 'key' or "key" followed by colon and value - // ReDoS protection: deterministic pattern without lookahead backtracking + // ReDoS protection: linear complexity pattern using non-greedy quantifier // - Limit key to 500 chars, whitespace to 100 chars, and value to 5000 chars - // - Match delimiter as part of pattern (not lookahead) to avoid backtracking - // - Value is captured in match[2], delimiter in match[3] (not used) + // - Non-greedy quantifier {1,5000}? minimizes backtracking (matches minimum needed) + // - Delimiter match ensures pattern completes deterministically // - All quantifiers are bounded: no unbounded * or + operators + // - Linear complexity: O(n) where n is input length const stringKeyPattern = - /['"]([^'"]{1,500})['"]\s{0,100}:\s{0,100}([^,}\n]{1,5000})([,}\n])/g; + /['"]([^'"]{1,500})['"]\s{0,100}:\s{0,100}([^,}\n]{1,5000}?)([,}\n])/g; const processed = new Set(); const allMatches: Array<{ From 4986f365f2afe53427a0b23bf3aa4f0928cc789f Mon Sep 17 00:00:00 2001 From: Yi Cai Date: Thu, 15 Jan 2026 16:01:35 -0500 Subject: [PATCH 20/30] resolved security hotspots issues Signed-off-by: Yi Cai --- .../cli/scripts/deploy-translations.ts | 204 +++++++++++------- 1 file changed, 129 insertions(+), 75 deletions(-) diff --git a/workspaces/translations/packages/cli/scripts/deploy-translations.ts b/workspaces/translations/packages/cli/scripts/deploy-translations.ts index 8efb7b7865..a58eb46e5b 100644 --- a/workspaces/translations/packages/cli/scripts/deploy-translations.ts +++ b/workspaces/translations/packages/cli/scripts/deploy-translations.ts @@ -884,51 +884,24 @@ function extractKeysFromRefFile(refFilePath: string): Set { return; // Prevent infinite recursion } - // Helper: Extract matches from a regex pattern - // Includes safeguards to prevent ReDoS: iteration limit and timeout protection - const extractMatches = ( - pattern: RegExp, - textContent: string, - processed: Set, - ): Array<{ - key: string; - value: string; - index: number; - endIndex: number; - }> => { - const matches: Array<{ - key: string; - value: string; - index: number; - endIndex: number; - }> = []; - - // Limit iterations to prevent ReDoS (max 10000 matches per pattern) - const MAX_ITERATIONS = 10000; - let iterations = 0; - let match = pattern.exec(textContent); - - while (match !== null && iterations < MAX_ITERATIONS) { - iterations++; - if (!processed.has(match.index)) { - processed.add(match.index); - matches.push({ - key: match[1], - value: match[2].trim(), - index: match.index, - endIndex: match.index + match[0].length, - }); - } - match = pattern.exec(textContent); - } + // Helper: Check if character is whitespace (without regex) + const isWhitespace = (char: string): boolean => { + return char === ' ' || char === '\t' || char === '\n' || char === '\r'; + }; - if (iterations >= MAX_ITERATIONS) { - console.warn( - `Regex iteration limit reached (${MAX_ITERATIONS}), stopping extraction`, - ); - } + // Helper: Check if character is valid identifier start (without regex) + const isIdentifierStart = (char: string): boolean => { + return ( + (char >= 'a' && char <= 'z') || + (char >= 'A' && char <= 'Z') || + char === '_' || + char === '$' + ); + }; - return matches; + // Helper: Check if character is valid identifier part (without regex) + const isIdentifierPart = (char: string): boolean => { + return isIdentifierStart(char) || (char >= '0' && char <= '9'); }; // Helper: Find matching closing brace for nested object @@ -954,7 +927,7 @@ function extractKeysFromRefFile(refFilePath: string): Set { return null; }; - // Validate input length to prevent ReDoS attacks + // Validate input length to prevent DoS attacks // Limit total content to 1MB to prevent memory exhaustion const MAX_CONTENT_LENGTH = 1024 * 1024; // 1MB if (nestedContent.length > MAX_CONTENT_LENGTH) { @@ -964,28 +937,8 @@ function extractKeysFromRefFile(refFilePath: string): Set { return; } - // Pattern for identifier keys: word followed by colon and value - // ReDoS protection: linear complexity pattern using non-greedy quantifier - // - Match word, colon, then value, then delimiter - // - Limit whitespace to 100 chars and value to 5000 chars to prevent DoS - // - Non-greedy quantifier {1,5000}? minimizes backtracking (matches minimum needed) - // - Delimiter match ensures pattern completes deterministically - // - All quantifiers are bounded: no unbounded * or + operators - // - Linear complexity: O(n) where n is input length - const identifierKeyPattern = - /(\w+)\s{0,100}:\s{0,100}([^,}\n]{1,5000}?)([,}\n])/g; - - // Pattern for string literal keys: 'key' or "key" followed by colon and value - // ReDoS protection: linear complexity pattern using non-greedy quantifier - // - Limit key to 500 chars, whitespace to 100 chars, and value to 5000 chars - // - Non-greedy quantifier {1,5000}? minimizes backtracking (matches minimum needed) - // - Delimiter match ensures pattern completes deterministically - // - All quantifiers are bounded: no unbounded * or + operators - // - Linear complexity: O(n) where n is input length - const stringKeyPattern = - /['"]([^'"]{1,500})['"]\s{0,100}:\s{0,100}([^,}\n]{1,5000}?)([,}\n])/g; - - const processed = new Set(); + // Linear parser: Extract key-value pairs without regex to avoid backtracking + // True O(n) complexity using simple string operations const allMatches: Array<{ key: string; value: string; @@ -993,14 +946,112 @@ function extractKeysFromRefFile(refFilePath: string): Set { endIndex: number; }> = []; - // Collect matches from both patterns - allMatches.push( - ...extractMatches(identifierKeyPattern, nestedContent, processed), - ); - stringKeyPattern.lastIndex = 0; - allMatches.push( - ...extractMatches(stringKeyPattern, nestedContent, processed), - ); + const parseKeyValuePairs = (textToParse: string): void => { + let i = 0; + const MAX_VALUE_LENGTH = 5000; + const MAX_KEY_LENGTH = 500; + + while (i < textToParse.length) { + // Skip whitespace (no regex - uses character comparison) + while (i < textToParse.length && isWhitespace(textToParse[i])) { + i++; + } + if (i >= textToParse.length) break; + + const keyStart = i; + let key = ''; + let keyEnd = i; + + // Parse key: either identifier or quoted string + if (textToParse[i] === "'" || textToParse[i] === '"') { + // Quoted key + const quote = textToParse[i]; + i++; // Skip opening quote + const keyStartInner = i; + while (i < textToParse.length && textToParse[i] !== quote) { + if (i - keyStartInner > MAX_KEY_LENGTH) break; + i++; + } + if (i < textToParse.length && textToParse[i] === quote) { + key = textToParse.substring(keyStartInner, i); + i++; // Skip closing quote + keyEnd = i; + } else { + // Invalid quoted key, skip + i++; + continue; + } + } else if (isIdentifierStart(textToParse[i])) { + // Identifier key (no regex - uses character comparison) + const keyStartInner = i; + while (i < textToParse.length && isIdentifierPart(textToParse[i])) { + if (i - keyStartInner > MAX_KEY_LENGTH) break; + i++; + } + key = textToParse.substring(keyStartInner, i); + keyEnd = i; + } else { + // Not a valid key start, skip this character + i++; + continue; + } + + // Skip whitespace after key (no regex) + while (i < textToParse.length && isWhitespace(textToParse[i])) { + i++; + } + + // Look for colon + if (i >= textToParse.length || textToParse[i] !== ':') { + i = keyEnd + 1; + continue; + } + i++; // Skip colon + + // Skip whitespace after colon (no regex) + while (i < textToParse.length && isWhitespace(textToParse[i])) { + i++; + } + + // Parse value: find next delimiter (comma, closing brace, or newline) + const valueStart = i; + let valueEnd = i; + let foundDelimiter = false; + + while (i < textToParse.length && !foundDelimiter) { + if (i - valueStart > MAX_VALUE_LENGTH) { + // Value too long, skip this pair + break; + } + + const char = textToParse[i]; + if (char === ',' || char === '}' || char === '\n') { + valueEnd = i; + foundDelimiter = true; + } else { + i++; + } + } + + if (foundDelimiter && valueEnd > valueStart) { + const value = textToParse.substring(valueStart, valueEnd).trim(); + if (value.length > 0) { + allMatches.push({ + key, + value, + index: keyStart, + endIndex: valueEnd + 1, + }); + } + i = valueEnd + 1; + } else { + // No delimiter found or value too long, skip + i = keyEnd + 1; + } + } + }; + + parseKeyValuePairs(nestedContent); // Sort by index to process in order allMatches.sort((a, b) => a.index - b.index); @@ -1017,7 +1068,10 @@ function extractKeysFromRefFile(refFilePath: string): Set { if (nestedSubContent) { extractNestedKeys(nestedSubContent, fullKey, depth + 1, maxDepth); } - } else if (matchData.value.match(/^['"]/)) { + } else if ( + matchData.value.length > 0 && + (matchData.value[0] === "'" || matchData.value[0] === '"') + ) { keys.add(fullKey); } } From c8e011803d6cd56748473ab92a72b2c41a0a9da9 Mon Sep 17 00:00:00 2001 From: Yi Cai Date: Thu, 15 Jan 2026 16:07:06 -0500 Subject: [PATCH 21/30] resolved security hotspots issues Signed-off-by: Yi Cai --- .../cli/test/workflow-verification.ts | 53 ++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/workspaces/translations/packages/cli/test/workflow-verification.ts b/workspaces/translations/packages/cli/test/workflow-verification.ts index e5a8327d9a..29fa469c96 100755 --- a/workspaces/translations/packages/cli/test/workflow-verification.ts +++ b/workspaces/translations/packages/cli/test/workflow-verification.ts @@ -94,6 +94,45 @@ function createSafeEnvironment(): NodeJS.ProcessEnv { return safeEnv; } +/** + * Find the absolute path to a command in safe PATH directories + * Returns the full path to the executable if found in safe directories + * @param command - The command to find (e.g., 'yarn', 'node') + * @returns Absolute path to the command, or null if not found + */ +function findCommandInSafePath(command: string): string | null { + const safeEnv = createSafeEnvironment(); + const safePath = safeEnv.PATH || ''; + const pathDirs = safePath.split(path.delimiter); + const isWindows = os.platform() === 'win32'; + + // On Windows, check for .exe, .cmd, .bat extensions + const extensions = isWindows ? ['', '.exe', '.cmd', '.bat'] : ['']; + + // Check each safe PATH directory for the command + for (const dir of pathDirs) { + if (!dir || dir.trim() === '') continue; + + for (const ext of extensions) { + const commandPath = path.join(dir, `${command}${ext}`); + try { + if (fs.existsSync(commandPath)) { + // Verify it's actually a file (not a directory) + const stats = fs.statSync(commandPath); + if (stats.isFile()) { + return commandPath; + } + } + } catch { + // Continue searching if this directory doesn't exist or isn't accessible + continue; + } + } + } + + return null; +} + interface TestResult { name: string; passed: boolean; @@ -597,7 +636,19 @@ async function main() { console.log(chalk.yellow('โš ๏ธ CLI not built. Building...')); // Create safe environment with only fixed, non-writable PATH directories const safeEnv = createSafeEnvironment(); - const buildResult = spawnSync('yarn', ['build'], { + + // Find yarn in safe PATH directories and use absolute path + const yarnPath = findCommandInSafePath('yarn'); + if (!yarnPath) { + console.error( + chalk.red( + 'โŒ yarn not found in safe PATH directories. Please ensure yarn is installed in a system directory.', + ), + ); + process.exit(1); + } + + const buildResult = spawnSync(yarnPath, ['build'], { cwd: process.cwd(), stdio: 'inherit', env: safeEnv, From c5472549a54cf1a8fa7e922dfb4ad9d6103c67a6 Mon Sep 17 00:00:00 2001 From: Yi Cai Date: Thu, 15 Jan 2026 17:03:57 -0500 Subject: [PATCH 22/30] code cleanup Signed-off-by: Yi Cai --- .../translations/packages/cli/.gitignore | 4 + .../packages/cli/docs/CI_COMPATIBILITY.md | 271 ------------------ .../packages/cli/docs/i18n-solution-review.md | 229 --------------- .../cli/scripts/deploy-translations.ts | 21 +- .../cli/test/workflow-verification.ts | 36 ++- 5 files changed, 39 insertions(+), 522 deletions(-) create mode 100644 workspaces/translations/packages/cli/.gitignore delete mode 100644 workspaces/translations/packages/cli/docs/CI_COMPATIBILITY.md delete mode 100644 workspaces/translations/packages/cli/docs/i18n-solution-review.md diff --git a/workspaces/translations/packages/cli/.gitignore b/workspaces/translations/packages/cli/.gitignore new file mode 100644 index 0000000000..c01708088a --- /dev/null +++ b/workspaces/translations/packages/cli/.gitignore @@ -0,0 +1,4 @@ + +# Developer-facing docs (kept locally, not in repo) +docs/CI_COMPATIBILITY.md +docs/i18n-solution-review.md diff --git a/workspaces/translations/packages/cli/docs/CI_COMPATIBILITY.md b/workspaces/translations/packages/cli/docs/CI_COMPATIBILITY.md deleted file mode 100644 index 77c797972c..0000000000 --- a/workspaces/translations/packages/cli/docs/CI_COMPATIBILITY.md +++ /dev/null @@ -1,271 +0,0 @@ -# CI/CD Compatibility Analysis - -This document analyzes the compatibility of the translation workflow with CI/CD pipelines and identifies what's needed for full CI support. - -## Current CI Compatibility Status - -### โœ… **Already CI-Compatible Features** - -1. **Non-Interactive Mode Support** - - - `--no-input` flag available for all commands that require user input - - Automatic detection of non-interactive terminals (`isTTY` checks) - - Commands fail gracefully with clear error messages when input is required but not provided - -2. **Environment Variable Support** - - - All credentials can be provided via environment variables: - - `MEMSOURCE_TOKEN` or `I18N_TMS_TOKEN` - - `MEMSOURCE_USERNAME` or `I18N_TMS_USERNAME` - - `MEMSOURCE_PASSWORD` or `I18N_TMS_PASSWORD` - - `MEMSOURCE_URL` or `I18N_TMS_URL` - - `I18N_TMS_PROJECT_ID` - - `I18N_LANGUAGES` - - No need for interactive prompts in CI - -3. **Configuration File Support** - - - Project config: `.i18n.config.json` (can be committed) - - Auth config: `~/.i18n.auth.json` (fallback, not recommended for CI) - - All settings can be provided via environment variables - -4. **Command-Line Options** - - All paths and settings can be provided via CLI flags - - No hardcoded user-specific paths (after recent fixes) - -### โš ๏ธ **Potential CI Blockers** - -1. **Memsource CLI Dependency** - - - **Issue**: The workflow requires the `memsource` CLI command to be installed - - **Current**: Assumes memsource CLI is installed in a Python virtual environment - - **CI Impact**: CI runners need to: - - Install Python - - Install memsource-cli-client package - - Set up virtual environment - - Make `memsource` command available in PATH - - **Workaround**: Use environment variables for token (bypasses CLI for some operations) - -2. **Home Directory File Access** - - - **Issue**: Some commands read/write to `~/.memsourcerc` and `~/.i18n.auth.json` - - **Current**: Uses `os.homedir()` which works in CI but may not be ideal - - **CI Impact**: - - CI runners have home directories, so this works - - But credentials stored in home directory may not persist across jobs - - **Recommendation**: Use environment variables or CI secrets instead - -3. **Virtual Environment Path** - - - **Issue**: Memsource CLI requires a Python virtual environment - - **Current**: Auto-detects or prompts for path (not CI-friendly if detection fails) - - **CI Impact**: Need to provide `--memsource-venv` flag or ensure it's in PATH - - **Solution**: Use `--memsource-venv` flag or install memsource CLI globally - -4. **File System Assumptions** - - **Issue**: Some commands assume certain directory structures exist - - **CI Impact**: May need to create directories or adjust paths - - **Solution**: Commands create directories as needed, but paths should be configurable - -## Recommended CI Setup - -### Option 1: Full CI Integration (Recommended for Production) - -```yaml -# Example GitHub Actions workflow -name: Translation Workflow - -on: - push: - branches: [main] - workflow_dispatch: - -jobs: - translations: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v4 - with: - python-version: '3.11' - - - name: Install memsource CLI - run: | - pip install memsource-cli-client - # Or use virtual environment - python -m venv .memsource - source .memsource/bin/activate - pip install memsource-cli-client - echo "$(pwd)/.memsource/bin" >> $GITHUB_PATH - - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: '20' - - - name: Install dependencies - run: yarn install - - - name: Generate translation keys - run: | - yarn translations-cli i18n generate \ - --source-dir src \ - --output-dir i18n - env: - # Optional: Use config file or env vars - I18N_TMS_URL: ${{ secrets.MEMSOURCE_URL }} - I18N_TMS_PROJECT_ID: ${{ secrets.MEMSOURCE_PROJECT_ID }} - - - name: Upload to TMS - run: | - yarn translations-cli i18n upload \ - --source-file i18n/reference.json \ - --target-languages fr,it,ja \ - --no-input - env: - MEMSOURCE_TOKEN: ${{ secrets.MEMSOURCE_TOKEN }} - MEMSOURCE_URL: ${{ secrets.MEMSOURCE_URL }} - I18N_TMS_PROJECT_ID: ${{ secrets.MEMSOURCE_PROJECT_ID }} - - - name: Download translations - run: | - yarn translations-cli i18n download \ - --project-id ${{ secrets.MEMSOURCE_PROJECT_ID }} \ - --output-dir i18n/downloads \ - --no-input - env: - MEMSOURCE_TOKEN: ${{ secrets.MEMSOURCE_TOKEN }} - MEMSOURCE_URL: ${{ secrets.MEMSOURCE_URL }} - - - name: Deploy translations - run: | - yarn translations-cli i18n deploy \ - --download-dir i18n/downloads \ - --no-input -``` - -### Option 2: Minimal CI (Generate Only) - -For CI that only needs to generate translation keys (no TMS interaction): - -```yaml -- name: Generate translation keys - run: | - yarn translations-cli i18n generate \ - --source-dir src \ - --output-dir i18n \ - --dry-run # Optional: validate without writing files -``` - -### Option 3: Using Docker Image - -If memsource CLI installation is complex, use a pre-built Docker image: - -```yaml -- name: Run translation workflow - run: | - docker run \ - -v $PWD:/workspace \ - -e MEMSOURCE_TOKEN=${{ secrets.MEMSOURCE_TOKEN }} \ - -e MEMSOURCE_URL=${{ secrets.MEMSOURCE_URL }} \ - your-org/translations-cli:latest \ - i18n sync --no-input -``` - -## Required Improvements for Full CI Support - -### High Priority - -1. **โœ… DONE**: Remove hardcoded paths (memsource venv path) -2. **โœ… DONE**: Add `--no-input` flag support -3. **โœ… DONE**: Environment variable support for all credentials - -### Medium Priority - -1. **Make home directory paths configurable** - - - Add `--config-dir` or `--auth-file` options - - Allow overriding default paths via environment variables - - Example: `I18N_AUTH_FILE=/path/to/auth.json` - -2. **Improve memsource CLI detection** - - - Better error messages when memsource CLI is not found - - Option to skip memsource CLI requirement for generate-only workflows - - Support for memsource CLI installed via different methods (pip, npm, etc.) - -3. **Add CI-specific documentation** - - Examples for common CI platforms (GitHub Actions, GitLab CI, Jenkins) - - Best practices for secret management - - Troubleshooting guide for CI environments - -### Low Priority - -1. **Add dry-run mode for all commands** - - - Validate configuration without executing - - Useful for CI validation steps - -2. **Support for alternative authentication methods** - - - API keys instead of username/password - - Service account tokens - - OAuth2 flows - -3. **CI-specific optimizations** - - Caching for generated files - - Parallel execution where possible - - Better logging for CI environments - -## Testing CI Compatibility - -To test if your workflow is CI-compatible: - -```bash -# Test non-interactive mode -CI=true translations-cli i18n generate --no-input - -# Test with environment variables only -MEMSOURCE_TOKEN=test-token \ -MEMSOURCE_URL=https://cloud.memsource.com/web \ -I18N_TMS_PROJECT_ID=test-project \ -translations-cli i18n upload --source-file test.json --no-input -``` - -## Current Limitations - -1. **Memsource CLI is required** for upload/download operations - - - Cannot be fully bypassed - - Must be installed in CI environment - -2. **Python virtual environment** may be needed - - - Depends on how memsource CLI is installed - - Can be worked around with global installation - -3. **Home directory access** for config files - - Works but not ideal for CI - - Should use environment variables or project config files instead - -## Conclusion - -**Current Status**: The workflow is **mostly CI-compatible** with some limitations. - -**For CI use today**: - -- โœ… Can generate translation keys -- โœ… Can upload/download with proper setup (memsource CLI + env vars) -- โœ… All interactive prompts can be bypassed -- โš ๏ธ Requires memsource CLI installation in CI -- โš ๏ธ Home directory file access works but not ideal - -**For full CI support**: - -- Make config file paths configurable -- Improve memsource CLI installation documentation -- Add CI-specific examples and best practices - -The workflow is **ready for CI integration** with proper setup, but some improvements would make it more CI-friendly. diff --git a/workspaces/translations/packages/cli/docs/i18n-solution-review.md b/workspaces/translations/packages/cli/docs/i18n-solution-review.md deleted file mode 100644 index 967c3abb48..0000000000 --- a/workspaces/translations/packages/cli/docs/i18n-solution-review.md +++ /dev/null @@ -1,229 +0,0 @@ -# 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/scripts/deploy-translations.ts b/workspaces/translations/packages/cli/scripts/deploy-translations.ts index a58eb46e5b..09803eec2d 100644 --- a/workspaces/translations/packages/cli/scripts/deploy-translations.ts +++ b/workspaces/translations/packages/cli/scripts/deploy-translations.ts @@ -904,6 +904,15 @@ function extractKeysFromRefFile(refFilePath: string): Set { return isIdentifierStart(char) || (char >= '0' && char <= '9'); }; + // Helper: Skip whitespace characters and return new index + const skipWhitespace = (text: string, startIndex: number): number => { + let idx = startIndex; + while (idx < text.length && isWhitespace(text[idx])) { + idx++; + } + return idx; + }; + // Helper: Find matching closing brace for nested object const findMatchingBrace = ( textContent: string, @@ -953,9 +962,7 @@ function extractKeysFromRefFile(refFilePath: string): Set { while (i < textToParse.length) { // Skip whitespace (no regex - uses character comparison) - while (i < textToParse.length && isWhitespace(textToParse[i])) { - i++; - } + i = skipWhitespace(textToParse, i); if (i >= textToParse.length) break; const keyStart = i; @@ -997,9 +1004,7 @@ function extractKeysFromRefFile(refFilePath: string): Set { } // Skip whitespace after key (no regex) - while (i < textToParse.length && isWhitespace(textToParse[i])) { - i++; - } + i = skipWhitespace(textToParse, i); // Look for colon if (i >= textToParse.length || textToParse[i] !== ':') { @@ -1009,9 +1014,7 @@ function extractKeysFromRefFile(refFilePath: string): Set { i++; // Skip colon // Skip whitespace after colon (no regex) - while (i < textToParse.length && isWhitespace(textToParse[i])) { - i++; - } + i = skipWhitespace(textToParse, i); // Parse value: find next delimiter (comma, closing brace, or newline) const valueStart = i; diff --git a/workspaces/translations/packages/cli/test/workflow-verification.ts b/workspaces/translations/packages/cli/test/workflow-verification.ts index 29fa469c96..ff790627e2 100755 --- a/workspaces/translations/packages/cli/test/workflow-verification.ts +++ b/workspaces/translations/packages/cli/test/workflow-verification.ts @@ -33,6 +33,16 @@ const TEST_DIR = path.join( const TEST_OUTPUT_DIR = path.join(TEST_DIR, 'i18n'); const TEST_SOURCE_DIR = path.join(TEST_DIR, 'src'); +/** + * Normalize path by removing trailing slashes (ReDoS-safe) + * Uses bounded quantifier {1,10} instead of + to prevent backtracking + */ +function normalizePathForComparison(dirPath: string): string { + let normalized = path.normalize(dirPath.trim()); + normalized = normalized.replace(/[/\\]{1,10}$/, ''); + return normalized; +} + /** * Create a safe environment with only fixed, non-writable PATH directories * Filters PATH to only include standard system directories (exact matches only) @@ -62,15 +72,9 @@ function createSafeEnvironment(): NodeJS.ProcessEnv { const pathDirs = currentPath.split(path.delimiter); const safePathDirs = pathDirs.filter(dir => { if (!dir || dir.trim() === '') return false; - // Normalize paths for comparison (handle trailing slashes) - // ReDoS protection: use bounded quantifier {1,10} instead of + to prevent backtracking - // Limit trailing slashes to 10 (more than enough for any real path) - let normalizedDir = path.normalize(dir.trim()); - normalizedDir = normalizedDir.replace(/[/\\]{1,10}$/, ''); + const normalizedDir = normalizePathForComparison(dir); return systemPaths.some(systemPath => { - let normalizedSystemPath = path.normalize(systemPath); - // ReDoS protection: use bounded quantifier - normalizedSystemPath = normalizedSystemPath.replace(/[/\\]{1,10}$/, ''); + const normalizedSystemPath = normalizePathForComparison(systemPath); // Only allow exact matches to prevent subdirectory injection return normalizedDir === normalizedSystemPath; }); @@ -98,12 +102,16 @@ function createSafeEnvironment(): NodeJS.ProcessEnv { * Find the absolute path to a command in safe PATH directories * Returns the full path to the executable if found in safe directories * @param command - The command to find (e.g., 'yarn', 'node') + * @param safePath - Optional: pre-computed safe PATH string to avoid recreating environment * @returns Absolute path to the command, or null if not found */ -function findCommandInSafePath(command: string): string | null { - const safeEnv = createSafeEnvironment(); - const safePath = safeEnv.PATH || ''; - const pathDirs = safePath.split(path.delimiter); +function findCommandInSafePath( + command: string, + safePath?: string, +): string | null { + const pathToUse = + safePath || createSafeEnvironment().PATH || process.env.PATH || ''; + const pathDirs = pathToUse.split(path.delimiter); const isWindows = os.platform() === 'win32'; // On Windows, check for .exe, .cmd, .bat extensions @@ -636,9 +644,11 @@ async function main() { console.log(chalk.yellow('โš ๏ธ CLI not built. Building...')); // Create safe environment with only fixed, non-writable PATH directories const safeEnv = createSafeEnvironment(); + const safePath = safeEnv.PATH || ''; // Find yarn in safe PATH directories and use absolute path - const yarnPath = findCommandInSafePath('yarn'); + // Pass safePath to avoid recreating the environment + const yarnPath = findCommandInSafePath('yarn', safePath); if (!yarnPath) { console.error( chalk.red( From ffadf16b408af889ff17aa81b5d09171479a6482 Mon Sep 17 00:00:00 2001 From: Yi Cai Date: Fri, 23 Jan 2026 20:35:58 -0500 Subject: [PATCH 23/30] improvements Signed-off-by: Yi Cai --- .../cli/scripts/deploy-translations.ts | 276 ++++++++++++++++-- .../packages/cli/src/commands/download.ts | 195 +++++++++++-- .../packages/cli/src/commands/index.ts | 26 +- .../packages/cli/src/commands/list.ts | 258 ++++++++++++++++ .../packages/cli/src/lib/paths.ts | 57 +++- 5 files changed, 763 insertions(+), 49 deletions(-) create mode 100644 workspaces/translations/packages/cli/src/commands/list.ts diff --git a/workspaces/translations/packages/cli/scripts/deploy-translations.ts b/workspaces/translations/packages/cli/scripts/deploy-translations.ts index 09803eec2d..c167acd893 100644 --- a/workspaces/translations/packages/cli/scripts/deploy-translations.ts +++ b/workspaces/translations/packages/cli/scripts/deploy-translations.ts @@ -244,10 +244,108 @@ function findPluginInWorkspaces( /** * Find plugin translation directory in rhdh repo structure + * Intelligently searches for existing reference files to determine the correct path */ function findPluginInRhdh(pluginName: string, repoRoot: string): string | null { - // For rhdh repo, plugin overrides go to packages/app/src/translations/{plugin}/ - const pluginDir = path.join( + // Search for existing reference files (ref.ts or translations.ts) to determine the correct path + // This ensures we deploy to where the reference files were originally extracted + + // Possible locations to search: + const searchPaths = [ + // Standard location: packages/app/src/translations/{plugin}/ + path.join(repoRoot, 'packages', 'app', 'src', 'translations', pluginName), + // Alternative location: packages/app/src/components/{plugin}/translations/ + path.join( + repoRoot, + 'packages', + 'app', + 'src', + 'components', + pluginName, + 'translations', + ), + ]; + + // Search for existing reference files in each location + for (const searchPath of searchPaths) { + if (fs.existsSync(searchPath)) { + // Check if reference files exist (ref.ts or translations.ts) + const refFile = path.join(searchPath, 'ref.ts'); + const translationsFile = path.join(searchPath, 'translations.ts'); + + if (fs.existsSync(refFile) || fs.existsSync(translationsFile)) { + return searchPath; + } + } + } + + // If no existing reference files found, try to find any translation directory + // that contains language files (e.g., fr.ts, it.ts) for this plugin + const appSrcDir = path.join(repoRoot, 'packages', 'app', 'src'); + if (fs.existsSync(appSrcDir)) { + // Search recursively for translation directories containing language files + const findTranslationDir = (dir: string, depth = 0): string | null => { + if (depth > 3) return null; // Limit search depth + + try { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + + for (const entry of entries) { + if (entry.isDirectory()) { + const subDir = path.join(dir, entry.name); + // Check if this directory contains language files (e.g., fr.ts, it.ts) + const langFiles = fs + .readdirSync(subDir) + .filter(f => + /^(fr|it|ja|de|es|ko|zh|pt|ru|ar|hi|nl|pl|sv)\.ts$/.test(f), + ); + + if (langFiles.length > 0) { + // Check if this directory also has ref.ts or translations.ts + const refFile = path.join(subDir, 'ref.ts'); + const translationsFile = path.join(subDir, 'translations.ts'); + if (fs.existsSync(refFile) || fs.existsSync(translationsFile)) { + // Verify this is for the correct plugin by checking import statements + const sampleLangFile = path.join(subDir, langFiles[0]); + try { + const content = fs.readFileSync(sampleLangFile, 'utf-8'); + // Check if the file imports from this plugin (e.g., catalogTranslationRef) + const pluginRefPattern = new RegExp( + `(?:${pluginName}|${pluginName.replace( + /-/g, + '', + )})TranslationRef`, + 'i', + ); + if (pluginRefPattern.test(content)) { + return subDir; + } + } catch { + // Continue searching + } + } + } + + // Recursively search subdirectories + const found = findTranslationDir(subDir, depth + 1); + if (found) return found; + } + } + } catch { + // Continue searching + } + + return null; + }; + + const foundDir = findTranslationDir(appSrcDir); + if (foundDir) { + return foundDir; + } + } + + // Fallback: Create directory in standard location if parent exists + const standardPluginDir = path.join( repoRoot, 'packages', 'app', @@ -256,26 +354,20 @@ function findPluginInRhdh(pluginName: string, repoRoot: string): string | null { pluginName, ); - // Create directory if it doesn't exist (for new plugin overrides) - if (!fs.existsSync(pluginDir)) { - const translationsDir = path.join( - repoRoot, - 'packages', - 'app', - 'src', - 'translations', - ); - - // Only create if the parent translations directory exists - if (fs.existsSync(translationsDir)) { - fs.ensureDirSync(pluginDir); - return pluginDir; - } + const translationsDir = path.join( + repoRoot, + 'packages', + 'app', + 'src', + 'translations', + ); - return null; + if (fs.existsSync(translationsDir)) { + fs.ensureDirSync(standardPluginDir); + return standardPluginDir; } - return pluginDir; + return null; } /** @@ -492,12 +584,17 @@ function detectDownloadedFiles( // Only include files that match the current repo // Support both old repo names and new ones (e.g., "backstage" for backstage repo) // Note: backstage and community-plugins files are deployed to rhdh/translations + // So when running from rhdh repo, also accept backstage and community-plugins files if ( (repoType === 'rhdh-plugins' && fileRepo === 'rhdh-plugins') || (repoType === 'community-plugins' && fileRepo === 'community-plugins') || (repoType === 'rhdh' && fileRepo === 'rhdh') || - (repoType === 'backstage' && fileRepo === 'backstage') + (repoType === 'backstage' && fileRepo === 'backstage') || + // Allow backstage and community-plugins files when running from rhdh repo + // (since they deploy to rhdh/translations) + (repoType === 'rhdh' && + (fileRepo === 'backstage' || fileRepo === 'community-plugins')) ) { // Store the original filename for reading, but use clean name for display // The clean name removes -C suffix and -reference for cleaner naming @@ -514,6 +611,7 @@ function detectDownloadedFiles( /** * Determine target file path for translation file + * Intelligently determines the filename pattern based on existing files */ function determineTargetFile( pluginName: string, @@ -534,7 +632,36 @@ function determineTargetFile( return path.join(translationDir, `${lang}.ts`); } - // For plugin overrides, try {plugin}-{lang}.ts first, then {lang}.ts + // For plugin overrides, check existing files to determine the naming pattern + // Look for existing language files in the translation directory + if (fs.existsSync(translationDir)) { + const existingFiles = fs + .readdirSync(translationDir) + .filter( + f => + f.endsWith('.ts') && + !f.includes('ref') && + !f.includes('translations'), + ); + + // Check if any existing file uses {plugin}-{lang}.ts pattern + const pluginLangPattern = new RegExp(`^${pluginName}-[a-z]{2}\\.ts$`); + const pluginLangFile = existingFiles.find(f => pluginLangPattern.test(f)); + if (pluginLangFile) { + // Use the same pattern: {plugin}-{lang}.ts + return path.join(translationDir, `${pluginName}-${lang}.ts`); + } + + // Check if any existing file uses {lang}.ts pattern + const langPattern = /^[a-z]{2}\.ts$/; + const langFile = existingFiles.find(f => langPattern.test(f)); + if (langFile) { + // Use the same pattern: {lang}.ts + return path.join(translationDir, `${lang}.ts`); + } + } + + // Default: try {plugin}-{lang}.ts first, then {lang}.ts const pluginLangFile = path.join( translationDir, `${pluginName}-${lang}.ts`, @@ -802,9 +929,22 @@ function processLanguageTranslations( for (const [pluginName, pluginData] of Object.entries(data)) { // Use the language-specific translations (fr, it, ja, etc.) - // The translation file structure is: { plugin: { lang: { key: value } } } - const translations = - (pluginData as Record>)[lang] || {}; + // The translation file structure from Memsource is: { plugin: { "en": { key: value } } } + // Note: Memsource uses "en" as the key even for target languages (fr, it, ja, etc.) + // The actual target language is determined from the filename (e.g., backstage-2026-01-08-fr.json) + // We need to replace "en" with the target language key in the data structure + const pluginDataObj = pluginData as Record>; + + // Try target language key first (in case some files already have the correct structure) + let translations = pluginDataObj[lang] || {}; + + // If not found, use "en" key (Memsource standard - "en" contains the target language translations) + if (Object.keys(translations).length === 0 && pluginDataObj.en) { + // Replace "en" key with target language key in the data structure + // This ensures the JSON structure has the correct language key for processing + pluginDataObj[lang] = pluginDataObj.en; + translations = pluginDataObj[lang]; + } if (Object.keys(translations).length === 0) { continue; @@ -1235,17 +1375,99 @@ async function deployTranslations( displayFilename = displayFilename.replace(/-C\.json$/, '.json'); displayFilename = displayFilename.replace(/-reference-/, '-'); - const data: TranslationData = JSON.parse( - fs.readFileSync(filepath, 'utf-8'), + // Detect file repo from filename (backstage, community-plugins, etc.) + // If running from rhdh repo but file is backstage/community-plugins, use that repo type for deployment + let effectiveRepoType = repoType; + const fileRepoMatch = originalFilename.match( + /^(backstage|community-plugins|rhdh-plugins|rhdh)-/, ); + if (fileRepoMatch && repoType === 'rhdh') { + const fileRepo = fileRepoMatch[1]; + if (fileRepo === 'backstage' || fileRepo === 'community-plugins') { + // These files deploy to rhdh/translations, so use their repo type for deployment logic + effectiveRepoType = fileRepo as 'backstage' | 'community-plugins'; + } + } + + // Validate and parse JSON file + let data: TranslationData; + try { + const fileContent = fs.readFileSync(filepath, 'utf-8'); + data = JSON.parse(fileContent); + } catch (error: any) { + console.error( + chalk.red(`\nโŒ Error parsing JSON file: ${displayFilename}`), + ); + console.error( + chalk.red( + ` ${ + error instanceof Error ? error.message : 'Invalid JSON format' + }`, + ), + ); + console.error( + chalk.yellow( + ` Skipping this file. Please check the file and try again.`, + ), + ); + continue; + } + + // Validate that data is an object + if (typeof data !== 'object' || data === null || Array.isArray(data)) { + console.error( + chalk.red(`\nโŒ Invalid JSON structure in file: ${displayFilename}`), + ); + console.error( + chalk.red(` Expected a JSON object, got: ${typeof data}`), + ); + continue; + } console.log(chalk.cyan(`\n ๐ŸŒ Language: ${lang.toUpperCase()}`)); console.log(chalk.gray(` ๐Ÿ“„ Processing: ${displayFilename}`)); + // Copy JSON file to rhdh_root/translations/ for backstage and community-plugins repos + if ( + effectiveRepoType === 'backstage' || + effectiveRepoType === 'community-plugins' + ) { + const targetRoot = getTargetRepoRoot(repoRoot, effectiveRepoType); + const translationsDir = path.join(targetRoot, 'translations'); + + if (fs.existsSync(translationsDir)) { + // Extract timestamp from filename (date or sprint) + let timestamp = ''; + const dateMatch = displayFilename.match(/(\d{4}-\d{2}-\d{2})/); + const sprintMatch = displayFilename.match(/(s\d+)/); + + if (dateMatch) { + timestamp = dateMatch[1]; + } else if (sprintMatch) { + timestamp = sprintMatch[1]; + } else { + // Fallback: use current date + timestamp = new Date().toISOString().split('T')[0]; + } + + // Generate clean filename: --.json + const cleanFilename = `${effectiveRepoType}-${timestamp}-${lang}.json`; + const targetJsonPath = path.join(translationsDir, cleanFilename); + + // Copy the JSON file + fs.copyFileSync(filepath, targetJsonPath); + console.log( + chalk.green( + ` ๐Ÿ“‹ Copied JSON to: ${path.relative(repoRoot, targetJsonPath)}`, + ), + ); + } + } + const { updated, created } = processLanguageTranslations( data, lang, - repoType, + effectiveRepoType, repoRoot, ); diff --git a/workspaces/translations/packages/cli/src/commands/download.ts b/workspaces/translations/packages/cli/src/commands/download.ts index 77c7ee5a68..07f75cd661 100644 --- a/workspaces/translations/packages/cli/src/commands/download.ts +++ b/workspaces/translations/packages/cli/src/commands/download.ts @@ -17,6 +17,7 @@ import { OptionValues } from 'commander'; import chalk from 'chalk'; import fs from 'fs-extra'; +import path from 'node:path'; import { loadI18nConfig, mergeConfigWithOptions } from '../lib/i18n/config'; import { commandExists, safeExecSyncOrThrow } from '../lib/utils/exec'; @@ -50,6 +51,105 @@ function buildListJobsArgs(projectId: string): string[] { return ['job', 'list', '--project-id', projectId, '--format', 'json']; } +/** + * Find the actual downloaded file in the output directory + * Memsource CLI may download with a different name than job.filename + */ +function findDownloadedFile( + outputDir: string, + targetLang: string, +): string | null { + try { + const files = fs.readdirSync(outputDir); + // Look for files that match the target language + // Common patterns: *-{lang}-C.json, *-{lang}.json, *-en-{lang}-C.json + const langPattern = new RegExp(`-${targetLang}(?:-C)?\\.json$`, 'i'); + const matchingFiles = files.filter(f => langPattern.test(f)); + + if (matchingFiles.length > 0) { + // Return the most recently modified file (likely the one just downloaded) + const fileStats = matchingFiles.map(f => ({ + name: f, + mtime: fs.statSync(path.join(outputDir, f)).mtime.getTime(), + })); + fileStats.sort((a, b) => b.mtime - a.mtime); + return fileStats[0].name; + } + + return null; + } catch { + return null; + } +} + +/** + * Rename downloaded file to clean format: --.json + * Removes source language (-en) and completion suffix (-C) + */ +function renameDownloadedFile( + outputDir: string, + originalFilename: string, + targetLang: string, +): string | null { + try { + const originalPath = path.join(outputDir, originalFilename); + if (!fs.existsSync(originalPath)) { + // Try to find the file if it doesn't exist at expected path + const foundFile = findDownloadedFile(outputDir, targetLang); + if (foundFile) { + return renameDownloadedFile(outputDir, foundFile, targetLang); + } + return null; + } + + // Parse filename patterns: + // 1. {repo}-{date}-{sourceLang}-{targetLang}-C.json (e.g., backstage-2026-01-08-en-fr-C.json) + // 2. {repo}-{date}-{sourceLang}-{targetLang}.json (e.g., backstage-2026-01-08-en-fr.json) + // 3. {repo}-reference-{date}-{sourceLang}-{targetLang}-C.json (old format) + + let cleanFilename: string | null = null; + + // Try pattern: {repo}-{date}-{sourceLang}-{targetLang}(-C).json + const pattern1 = originalFilename.match( + /^([a-z-]+)-(\d{4}-\d{2}-\d{2})-([a-z]{2})-([a-z]{2})(?:-C)?\.json$/i, + ); + if (pattern1) { + const [, repo, date] = pattern1; + cleanFilename = `${repo}-${date}-${targetLang}.json`; + } else { + // Try old reference pattern: {repo}-reference-{date}-{sourceLang}-{targetLang}(-C).json + const pattern2 = originalFilename.match( + /^([a-z-]+)-reference-(\d{4}-\d{2}-\d{2})-([a-z]{2})-([a-z]{2})(?:-C)?\.json$/i, + ); + if (pattern2) { + const [, repo, date] = pattern2; + cleanFilename = `${repo}-${date}-${targetLang}.json`; + } + } + + if (!cleanFilename) { + // If pattern doesn't match, return original filename + return originalFilename; + } + + const cleanPath = path.join(outputDir, cleanFilename); + + // Rename the file + if (originalPath !== cleanPath) { + fs.moveSync(originalPath, cleanPath, { overwrite: true }); + } + + return cleanFilename; + } catch (error: any) { + console.warn( + chalk.yellow( + `โš ๏ธ Warning: Could not rename file ${originalFilename}: ${error.message}`, + ), + ); + return originalFilename; + } +} + /** * Download a single job and return its info */ @@ -76,9 +176,29 @@ async function downloadJob( const job = jobArray.find((j: any) => j.uid === jobId); if (job) { + // Find the actual downloaded file (memsource CLI may name it differently) + const originalFilename = job.filename; + let actualFile = originalFilename; + + // Check if file exists with original name + if (!fs.existsSync(path.join(outputDir, originalFilename))) { + // Try to find the file by looking for files matching the target language + const foundFile = findDownloadedFile(outputDir, job.target_lang); + if (foundFile) { + actualFile = foundFile; + } + } + + // Rename the downloaded file to clean format + const cleanFilename = renameDownloadedFile( + outputDir, + actualFile, + job.target_lang, + ); + return { jobId, - filename: job.filename, + filename: cleanFilename || actualFile, lang: job.target_lang, }; } @@ -144,12 +264,18 @@ async function downloadSpecificJobs( } /** - * List and filter completed jobs + * List and filter jobs by status and language */ -function listCompletedJobs( +function listJobs( projectId: string, languages?: string[], -): Array<{ uid: string; filename: string; target_lang: string }> { + statusFilter?: string, +): Array<{ + uid: string; + filename: string; + target_lang: string; + status: string; +}> { const listArgs = buildListJobsArgs(projectId); const listOutput = safeExecSyncOrThrow('memsource', listArgs, { encoding: 'utf-8', @@ -158,37 +284,57 @@ function listCompletedJobs( const jobs = JSON.parse(listOutput); const jobArray = Array.isArray(jobs) ? jobs : [jobs]; - const completedJobs = jobArray.filter( - (job: any) => job.status === 'COMPLETED', - ); + let filteredJobs = jobArray; - if (!languages || languages.length === 0) { - return completedJobs; + // Filter by status + if (statusFilter && statusFilter !== 'ALL') { + filteredJobs = filteredJobs.filter( + (job: any) => job.status === statusFilter, + ); } - const languageSet = new Set(languages); - return completedJobs.filter((job: any) => languageSet.has(job.target_lang)); + // Filter by language + if (languages && languages.length > 0) { + const languageSet = new Set(languages); + filteredJobs = filteredJobs.filter((job: any) => + languageSet.has(job.target_lang), + ); + } + + return filteredJobs; } /** - * Download all completed jobs + * Download jobs filtered by status and language */ -async function downloadAllCompletedJobs( +async function downloadFilteredJobs( projectId: string, outputDir: string, languages?: string[], + statusFilter?: string, ): Promise> { console.log(chalk.yellow('๐Ÿ“‹ Listing available jobs...')); try { - const jobsToDownload = listCompletedJobs(projectId, languages); + const jobsToDownload = listJobs(projectId, languages, statusFilter); + const statusDisplay = + statusFilter === 'ALL' ? 'all statuses' : statusFilter || 'COMPLETED'; console.log( chalk.yellow( - `๐Ÿ“ฅ Found ${jobsToDownload.length} completed job(s) to download...`, + `๐Ÿ“ฅ Found ${jobsToDownload.length} job(s) with status "${statusDisplay}" to download...`, ), ); + if (jobsToDownload.length === 0) { + console.log( + chalk.yellow( + '๐Ÿ’ก Tip: Use "i18n list" to see all available jobs and their UIDs.', + ), + ); + return []; + } + const downloadResults: Array<{ jobId: string; filename: string; @@ -199,8 +345,11 @@ async function downloadAllCompletedJobs( const result = await downloadJob(projectId, job.uid, outputDir); if (result) { downloadResults.push(result); + const statusIcon = job.status === 'COMPLETED' ? 'โœ…' : 'โš ๏ธ'; console.log( - chalk.green(`โœ… Downloaded: ${result.filename} (${result.lang})`), + chalk.green( + `${statusIcon} Downloaded: ${result.filename} (${result.lang}) [${job.status}]`, + ), ); } } @@ -219,6 +368,7 @@ async function downloadWithMemsourceCLI( outputDir: string, jobIds?: string[], languages?: string[], + statusFilter?: string, ): Promise> { validateMemsourcePrerequisites(); await fs.ensureDir(outputDir); @@ -227,7 +377,7 @@ async function downloadWithMemsourceCLI( return downloadSpecificJobs(projectId, jobIds, outputDir); } - return downloadAllCompletedJobs(projectId, outputDir, languages); + return downloadFilteredJobs(projectId, outputDir, languages, statusFilter); } export async function downloadCommand(opts: OptionValues): Promise { @@ -242,13 +392,23 @@ export async function downloadCommand(opts: OptionValues): Promise { outputDir = 'i18n/downloads', languages, jobIds, + status, + includeIncomplete, } = mergedOpts as { projectId?: string; outputDir?: string; languages?: string; jobIds?: string; + status?: string; + includeIncomplete?: boolean; }; + // Determine status filter + let statusFilter = status || 'COMPLETED'; + if (includeIncomplete || statusFilter === 'ALL') { + statusFilter = 'ALL'; + } + // Validate required options if (!projectId) { console.error(chalk.red('โŒ Missing required TMS configuration:')); @@ -295,6 +455,7 @@ export async function downloadCommand(opts: OptionValues): Promise { String(outputDir), jobIdArray, languageArray, + statusFilter, ); // Summary diff --git a/workspaces/translations/packages/cli/src/commands/index.ts b/workspaces/translations/packages/cli/src/commands/index.ts index 4cd756c92e..9b3139a39f 100644 --- a/workspaces/translations/packages/cli/src/commands/index.ts +++ b/workspaces/translations/packages/cli/src/commands/index.ts @@ -27,6 +27,7 @@ import { cleanCommand } from './clean'; import { syncCommand } from './sync'; import { initCommand } from './init'; import { setupMemsourceCommand } from './setupMemsource'; +import { listCommand } from './list'; export function registerCommands(program: Command) { const command = program @@ -106,6 +107,19 @@ export function registerCommands(program: Command) { .option('--force', 'Force upload even if file has not changed', false) .action(wrapCommand(uploadCommand)); + // List command - list available translation jobs + command + .command('list') + .description('List available translation jobs from TMS') + .option('--project-id ', 'TMS project ID') + .option('--languages ', 'Filter by languages (e.g., "it,ja,fr")') + .option( + '--status ', + 'Filter by status (e.g., "COMPLETED", "ASSIGNED", "NEW")', + ) + .option('--format ', 'Output format (table, json)', 'table') + .action(wrapCommand(listCommand)); + // Download command - download translated strings from TMS command .command('download') @@ -122,7 +136,17 @@ export function registerCommands(program: Command) { ) .option( '--job-ids ', - 'Comma-separated list of specific job IDs to download (e.g., "13,14,16")', + 'Comma-separated list of specific job UIDs to download (use "i18n list" to see UIDs)', + ) + .option( + '--status ', + 'Filter by status (default: "COMPLETED"). Use "ALL" to download all statuses, or specific status like "ASSIGNED"', + 'COMPLETED', + ) + .option( + '--include-incomplete', + 'Include incomplete jobs (same as --status ALL)', + false, ) .action(wrapCommand(downloadCommand)); diff --git a/workspaces/translations/packages/cli/src/commands/list.ts b/workspaces/translations/packages/cli/src/commands/list.ts new file mode 100644 index 0000000000..38fae8a3f3 --- /dev/null +++ b/workspaces/translations/packages/cli/src/commands/list.ts @@ -0,0 +1,258 @@ +/* + * 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/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 { commandExists, safeExecSyncOrThrow } from '../lib/utils/exec'; + +/** + * Build memsource job list command arguments + */ +function buildListJobsArgs(projectId: string): string[] { + return ['job', 'list', '--project-id', projectId, '--format', 'json']; +} + +/** + * List all jobs from Memsource project + */ +function listAllJobs(projectId: string): any[] { + const listArgs = buildListJobsArgs(projectId); + const listOutput = safeExecSyncOrThrow('memsource', listArgs, { + encoding: 'utf-8', + env: { ...process.env }, + }); + const jobs = JSON.parse(listOutput); + return Array.isArray(jobs) ? jobs : [jobs]; +} + +/** + * Validate prerequisites for Memsource CLI + */ +function validateMemsourcePrerequisites(): void { + if (!commandExists('memsource')) { + throw new Error( + 'memsource CLI not found. Please ensure memsource-cli is installed and ~/.memsourcerc is sourced.', + ); + } + + if (!process.env.MEMSOURCE_TOKEN) { + throw new Error( + 'MEMSOURCE_TOKEN not found. Please source ~/.memsourcerc first: source ~/.memsourcerc', + ); + } +} + +/** + * Format job status for display + */ +function formatStatus(status: string): string { + const statusMap: Record = { + COMPLETED: chalk.green('โœ“ COMPLETED'), + ASSIGNED: chalk.yellow('โ—‹ ASSIGNED'), + NEW: chalk.blue('โ—‹ NEW'), + DECLINED: chalk.red('โœ— DECLINED'), + CANCELLED: chalk.gray('โœ— CANCELLED'), + }; + return statusMap[status] || status; +} + +/** + * Display jobs in a table format + */ +function displayJobsTable( + jobs: any[], + languages?: string[], + statusFilter?: string, +): void { + // Filter jobs + let filteredJobs = jobs; + + if (languages && languages.length > 0) { + const languageSet = new Set(languages); + filteredJobs = filteredJobs.filter((job: any) => + languageSet.has(job.target_lang), + ); + } + + if (statusFilter) { + filteredJobs = filteredJobs.filter( + (job: any) => job.status === statusFilter, + ); + } + + if (filteredJobs.length === 0) { + console.log(chalk.yellow('No jobs found matching the criteria.')); + return; + } + + // Group jobs by filename to show them together + const jobsByFile = new Map(); + for (const job of filteredJobs) { + const filename = job.filename || 'unknown'; + if (!jobsByFile.has(filename)) { + jobsByFile.set(filename, []); + } + jobsByFile.get(filename)!.push(job); + } + + console.log(chalk.blue('\n๐Ÿ“‹ Available Translation Jobs\n')); + console.log( + chalk.gray( + 'Note: Display IDs (1, 2, 3...) shown in TMS UI are sequential and may not match the order here.\n', + ), + ); + + // Display header + console.log( + chalk.bold( + `${'Filename'.padEnd(50)} ${'Language'.padEnd(8)} ${'Status'.padEnd( + 15, + )} ${'Real UID (for download)'.padEnd(30)}`, + ), + ); + console.log(chalk.gray('-'.repeat(120))); + + // Display jobs grouped by filename + let displayIndex = 1; + for (const [filename, fileJobs] of Array.from(jobsByFile.entries()).sort()) { + // Sort jobs by language + const sortedJobs = fileJobs.sort((a, b) => + (a.target_lang || '').localeCompare(b.target_lang || ''), + ); + + for (const job of sortedJobs) { + const status = formatStatus(job.status || 'UNKNOWN'); + const lang = (job.target_lang || 'unknown').padEnd(8); + const uid = (job.uid || 'unknown').padEnd(30); + const fileDisplay = + filename.length > 48 ? `${filename.slice(0, 45)}...` : filename; + + console.log( + `${fileDisplay.padEnd(50)} ${lang} ${status.padEnd(15)} ${chalk.cyan( + uid, + )}`, + ); + } + displayIndex++; + } + + console.log(chalk.gray('-'.repeat(120))); + console.log( + chalk.gray( + `\nTotal: ${filteredJobs.length} job(s) | Use --languages or --status to filter`, + ), + ); + console.log( + chalk.yellow( + '\n๐Ÿ’ก Tip: Use the Real UID (not display ID) with --job-ids to download specific jobs.', + ), + ); + console.log( + chalk.yellow( + ' Or use --languages "it,ja" to download all jobs for specific languages.', + ), + ); +} + +/** + * Display jobs in JSON format + */ +function displayJobsJson( + jobs: any[], + languages?: string[], + statusFilter?: string, +): void { + let filteredJobs = jobs; + + if (languages && languages.length > 0) { + const languageSet = new Set(languages); + filteredJobs = filteredJobs.filter((job: any) => + languageSet.has(job.target_lang), + ); + } + + if (statusFilter) { + filteredJobs = filteredJobs.filter( + (job: any) => job.status === statusFilter, + ); + } + + console.log(JSON.stringify(filteredJobs, null, 2)); +} + +export async function listCommand(opts: OptionValues): Promise { + console.log(chalk.blue('๐Ÿ“‹ Listing translation jobs from TMS...')); + + // Load config and merge with options + const config = await loadI18nConfig(); + const mergedOpts = await mergeConfigWithOptions(config, opts); + + const { + projectId, + languages, + status, + format = 'table', + } = mergedOpts as { + projectId?: string; + languages?: string; + status?: string; + format?: string; + }; + + // Validate required options + if (!projectId) { + console.error(chalk.red('โŒ Missing required TMS configuration:')); + console.error(''); + console.error(chalk.yellow(' โœ— Project ID')); + console.error( + chalk.gray( + ' Set via: --project-id or I18N_TMS_PROJECT_ID or .i18n.config.json', + ), + ); + process.exit(1); + } + + // 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); + } + + try { + validateMemsourcePrerequisites(); + + // Parse languages if provided (comma-separated) + const languageArray = + languages && typeof languages === 'string' + ? languages.split(',').map((lang: string) => lang.trim()) + : undefined; + + const jobs = listAllJobs(projectId); + + if (format === 'json') { + displayJobsJson(jobs, languageArray, status); + } else { + displayJobsTable(jobs, languageArray, status); + } + } catch (error: any) { + console.error(chalk.red('โŒ Error listing jobs:'), error.message); + throw error; + } +} diff --git a/workspaces/translations/packages/cli/src/lib/paths.ts b/workspaces/translations/packages/cli/src/lib/paths.ts index 0013c4356b..635613bdeb 100644 --- a/workspaces/translations/packages/cli/src/lib/paths.ts +++ b/workspaces/translations/packages/cli/src/lib/paths.ts @@ -15,14 +15,63 @@ */ import path from 'node:path'; +import fs from 'fs-extra'; + +/** + * Find the project root by walking up the directory tree + * Prefers .i18n.config.json at git root, otherwise uses the first one found + */ +function findProjectRoot(): string { + // First, find git root + let currentDir = process.cwd(); + const root = path.parse(currentDir).root; + let gitRoot: string | null = null; + + while (currentDir !== root) { + const gitDir = path.join(currentDir, '.git'); + if (fs.existsSync(gitDir)) { + gitRoot = currentDir; + break; + } + const parentDir = path.dirname(currentDir); + if (parentDir === currentDir) { + break; + } + currentDir = parentDir; + } + + // If we found a git root, check for config there first + if (gitRoot) { + const gitRootConfig = path.join(gitRoot, '.i18n.config.json'); + if (fs.existsSync(gitRootConfig)) { + return gitRoot; + } + } + + // Otherwise, walk up from current directory looking for any .i18n.config.json + currentDir = process.cwd(); + while (currentDir !== root) { + const configFile = path.join(currentDir, '.i18n.config.json'); + if (fs.existsSync(configFile)) { + return currentDir; + } + const parentDir = path.dirname(currentDir); + if (parentDir === currentDir) { + break; + } + currentDir = parentDir; + } + + // Fallback: use git root if found, otherwise current directory + return gitRoot || process.cwd(); +} // Simplified paths for translations-cli // Note: resolveOwn is not currently used, but kept for potential future use export const paths = { - targetDir: process.cwd(), + targetDir: findProjectRoot(), resolveOwn: (relativePath: string) => { - // Use process.cwd() as base since we're typically running from the package root - // This avoids issues with import.meta.url in API report generation - return path.resolve(process.cwd(), relativePath); + // Use project root as base + return path.resolve(findProjectRoot(), relativePath); }, }; From efb53044f952b1d564e8be8e16b440122f81c5e1 Mon Sep 17 00:00:00 2001 From: Yi Cai Date: Fri, 23 Jan 2026 21:36:51 -0500 Subject: [PATCH 24/30] updated deploy command to copy translation to BCP Signed-off-by: Yi Cai --- .../cli/docs/download-deploy-usage.md | 42 +++++- .../cli/docs/multi-repo-deployment.md | 100 +++++++++++++- .../cli/scripts/deploy-translations.ts | 123 +++++++++++++++++- 3 files changed, 253 insertions(+), 12 deletions(-) diff --git a/workspaces/translations/packages/cli/docs/download-deploy-usage.md b/workspaces/translations/packages/cli/docs/download-deploy-usage.md index 8f4219dc60..ebab59a9c5 100644 --- a/workspaces/translations/packages/cli/docs/download-deploy-usage.md +++ b/workspaces/translations/packages/cli/docs/download-deploy-usage.md @@ -307,23 +307,51 @@ translations-cli i18n deploy --source-dir ~/translations/downloads 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**: + - When running from `rhdh` repo, also processes `backstage` and `community-plugins` files +3. **For backstage/community-plugins files deployed from rhdh:** + - **Copies JSON files** to `rhdh/translations/` with format: `--.json` + - Example: `backstage-2026-01-08-fr.json`, `community-plugins-2025-12-05-fr.json` +4. **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: + - `rhdh`: Intelligently searches for plugins in: + - `packages/app/src/translations/{plugin}/` (standard) + - `packages/app/src/components/{plugin}/translations/` (alternative, e.g., catalog) + - Searches for existing reference files to determine correct path +5. **Deploys TS files** to appropriate locations: + - Standard deployment: `rhdh/translations/{plugin}/` for backstage/community-plugins files + - Red Hat owned plugins: Also deploys to `community-plugins/workspaces/{workspace}/plugins/{plugin}/src/translations/` +6. **Updates existing files** (e.g., `it.ts`) with new translations +7. **Creates new files** (e.g., `ja.ts`) for plugins that don't have them +8. **Detects filename patterns** for rhdh plugin overrides: + - Checks existing files to determine pattern: `{lang}.ts` or `{plugin}-{lang}.ts` + - Uses the same pattern for new files +9. **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 +- **JSON files** copied to `rhdh/translations/` (for backstage/community-plugins files) +- **Updated/created TypeScript files** in plugin translation directories +- **Red Hat owned plugins** deployed to both rhdh and community-plugins repos - Files maintain proper TypeScript format with correct imports - All translations registered in `index.ts` files +**Red Hat Owned Plugin Deployment:** + +When deploying from `rhdh` repo with `backstage` or `community-plugins` JSON files, the command automatically: + +1. Detects if a plugin exists in the community-plugins repo (Red Hat owned) +2. Deploys TS files to both: + - `rhdh/translations/{plugin}/` (standard) + - `community-plugins/workspaces/{workspace}/plugins/{plugin}/src/translations/` (additional) +3. Allows you to create PRs in community-plugins repo with the deployed translations + +**Prerequisites:** + +- Community-plugins repo cloned locally (sibling directory or set `COMMUNITY_PLUGINS_REPO_PATH` env var) + ### Step 6: Verify Deployment After deployment, verify that translations are correctly integrated: diff --git a/workspaces/translations/packages/cli/docs/multi-repo-deployment.md b/workspaces/translations/packages/cli/docs/multi-repo-deployment.md index a8d0c6b3c5..0664e74a35 100644 --- a/workspaces/translations/packages/cli/docs/multi-repo-deployment.md +++ b/workspaces/translations/packages/cli/docs/multi-repo-deployment.md @@ -77,6 +77,45 @@ translations-cli i18n deploy ```bash cd /path/to/rhdh translations-cli i18n deploy +# Or from shared location: +translations-cli i18n deploy --source-dir ~/translations/downloads +``` + +**Special Feature: Deploying backstage/community-plugins files from rhdh root** + +When running the deploy command from the `rhdh` repo root, the command can also process `backstage` and `community-plugins` JSON files: + +1. **JSON files are copied** to `rhdh/translations/` with format: `--.json` + + - Example: `backstage-2026-01-08-fr.json`, `community-plugins-2025-12-05-fr.json` + +2. **TS files are deployed** to `rhdh/translations/{plugin}/` for all plugins + +3. **Red Hat owned plugins** (plugins that exist in community-plugins repo) are **automatically detected** and deployed to: + - `rhdh/translations/{plugin}/` (standard deployment) + - `community-plugins/workspaces/{workspace}/plugins/{plugin}/src/translations/` (additional deployment) + +**Prerequisites for Red Hat owned plugin deployment:** + +- Community-plugins repo must be cloned locally (typically as sibling directory: `../community-plugins`) +- Or set `COMMUNITY_PLUGINS_REPO_PATH` environment variable +- The plugin must exist in the community-plugins repo workspaces + +**Example workflow:** + +```bash +# 1. Pull latest community-plugins repo +cd /path/to/community-plugins && git pull + +# 2. Deploy from rhdh root (processes rhdh, backstage, and community-plugins files) +cd /path/to/rhdh +translations-cli i18n deploy --source-dir ~/translations/downloads + +# 3. Create PR in community-plugins repo with deployed TS files +cd /path/to/community-plugins +git add workspaces/*/plugins/*/src/translations/*.ts +git commit -m "Add translations for Red Hat owned plugins" +git push ``` ## Complete Workflow for All Repos @@ -119,6 +158,36 @@ cd /path/to/rhdh translations-cli i18n deploy --source-dir ~/translations/downloads ``` +### Option C: Unified deployment from rhdh root (Recommended for backstage/community-plugins) + +This option allows you to deploy all translations (rhdh, backstage, and community-plugins) from a single command run from the rhdh repo root. It automatically handles Red Hat owned plugins. + +```bash +# 1. Pull latest community-plugins repo (for Red Hat owned plugin detection) +cd /path/to/community-plugins && git pull + +# 2. Download all translations to shared location +mkdir -p ~/translations/downloads +cd /path/to/rhdh-plugins +translations-cli i18n download --output-dir ~/translations/downloads + +# 3. Deploy everything from rhdh root +cd /path/to/rhdh +translations-cli i18n deploy --source-dir ~/translations/downloads + +# This will: +# - Deploy rhdh translations to rhdh repo +# - Copy backstage/community-plugins JSON files to rhdh/translations/ +# - Deploy backstage/community-plugins TS files to rhdh/translations/{plugin}/ +# - Automatically detect and deploy Red Hat owned plugins to community-plugins workspaces + +# 4. Create PR in community-plugins repo with Red Hat owned plugin translations +cd /path/to/community-plugins +git add workspaces/*/plugins/*/src/translations/*.ts +git commit -m "Add translations for Red Hat owned plugins" +git push +``` + ## Auto-Detection Features ### Repository Detection @@ -138,10 +207,30 @@ The script automatically finds downloaded files matching: ### Plugin Location -The script searches for plugins using repo-specific patterns: +The script intelligently 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 +- **rhdh**: + - Standard: `packages/app/src/translations/{plugin}/` + - Alternative: `packages/app/src/components/{plugin}/translations/` (for some plugins like catalog) + - The script searches for existing reference files (`ref.ts` or `translations.ts`) to determine the correct path + +### Intelligent Path Finding + +For the `rhdh` repo, the deploy command intelligently finds plugin translation directories by: + +1. **Checking standard locations first**: `packages/app/src/translations/{plugin}/` +2. **Checking alternative locations**: `packages/app/src/components/{plugin}/translations/` +3. **Searching for existing reference files**: Looks for `ref.ts` or `translations.ts` files to determine where translations were originally extracted +4. **Matching plugin imports**: Verifies the plugin by checking import statements in existing language files + +### Filename Pattern Detection + +For plugin overrides in the `rhdh` repo, the deploy command automatically detects the correct filename pattern by checking existing files: + +- If existing files use `{plugin}-{lang}.ts` (e.g., `search-it.ts`), new files use the same pattern +- If existing files use `{lang}.ts` (e.g., `fr.ts`), new files use the same pattern +- Defaults to `{plugin}-{lang}.ts` for new plugins ## Troubleshooting @@ -161,3 +250,10 @@ The script searches for plugins using repo-specific patterns: - 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 + +### Red Hat owned plugins not deploying to community-plugins + +- Ensure community-plugins repo is cloned locally (typically as sibling directory: `../community-plugins`) +- Or set `COMMUNITY_PLUGINS_REPO_PATH` environment variable to the repo path +- Verify the plugin exists in `community-plugins/workspaces/*/plugins/{plugin}/` +- Check that the plugin name matches (the script automatically strips "plugin." prefix) diff --git a/workspaces/translations/packages/cli/scripts/deploy-translations.ts b/workspaces/translations/packages/cli/scripts/deploy-translations.ts index c167acd893..2c0dabf115 100644 --- a/workspaces/translations/packages/cli/scripts/deploy-translations.ts +++ b/workspaces/translations/packages/cli/scripts/deploy-translations.ts @@ -402,6 +402,67 @@ function getTargetRepoRoot(repoRoot: string, repoType: string): string { return repoRoot; } +/** + * Get the community-plugins repository root + * Tries to find community-plugins repo in common locations + */ +function getCommunityPluginsRepoRoot(repoRoot: string): string | null { + // Try to find community-plugins repo - check common locations + const possiblePaths = [ + path.join(path.dirname(repoRoot), 'community-plugins'), + // Fallback: Try environment variable or common development location + process.env.COMMUNITY_PLUGINS_REPO_PATH || + path.join(os.homedir(), 'redhat', 'community-plugins'), + ]; + + for (const communityPluginsPath of possiblePaths) { + if (fs.existsSync(communityPluginsPath)) { + // Verify it's actually a community-plugins repo by checking for workspaces directory + const workspacesDir = path.join(communityPluginsPath, 'workspaces'); + if (fs.existsSync(workspacesDir)) { + return communityPluginsPath; + } + } + } + + return null; +} + +/** + * Check if a plugin exists in community-plugins repo (Red Hat owned) + */ +function isRedHatOwnedPlugin( + pluginName: string, + communityPluginsRoot: string, +): boolean { + if (!communityPluginsRoot) { + return false; + } + + // Strip "plugin." prefix if present + const cleanPluginName = pluginName.replace(/^plugin\./, ''); + + const workspacesDir = path.join(communityPluginsRoot, 'workspaces'); + if (!fs.existsSync(workspacesDir)) { + return false; + } + + const workspaceDirs = fs.readdirSync(workspacesDir); + for (const workspace of workspaceDirs) { + const pluginDir = path.join( + workspacesDir, + workspace, + 'plugins', + cleanPluginName, + ); + if (fs.existsSync(pluginDir)) { + return true; + } + } + + return false; +} + /** * Find plugin translation directory (supports multiple repo structures) */ @@ -410,8 +471,17 @@ function findPluginTranslationDir( repoRoot: string, repoType: string, ): string | null { - // For backstage and community-plugins, deploy to rhdh/translations/{plugin}/ + // For backstage and community-plugins, check if repoRoot is actually a community-plugins repo + // (has workspaces directory) - if so, deploy to workspace, otherwise deploy to rhdh/translations if (repoType === 'backstage' || repoType === 'community-plugins') { + // Check if repoRoot is actually a community-plugins repo (has workspaces directory) + const workspacesDir = path.join(repoRoot, 'workspaces'); + if (fs.existsSync(workspacesDir)) { + // This is a community-plugins repo, deploy to workspace + return findPluginInWorkspaces(pluginName, repoRoot); + } + + // Otherwise, deploy to rhdh/translations/{plugin}/ const targetRoot = getTargetRepoRoot(repoRoot, repoType); const translationsDir = path.join(targetRoot, 'translations'); @@ -619,8 +689,10 @@ function determineTargetFile( repoType: string, translationDir: string, ): string { - // For backstage and community-plugins deploying to rhdh/translations/{plugin}/ - // Use {lang}.ts format + // For backstage and community-plugins: + // - If deploying to rhdh/translations/{plugin}/: use {lang}.ts format + // - If deploying to community-plugins workspace: use {lang}.ts format + // Both cases use the same {lang}.ts format if (repoType === 'backstage' || repoType === 'community-plugins') { return path.join(translationDir, `${lang}.ts`); } @@ -923,6 +995,7 @@ function processLanguageTranslations( lang: string, repoType: string, repoRoot: string, + communityPluginsRoot?: string | null, ): { updated: number; created: number } { let updated = 0; let created = 0; @@ -950,6 +1023,7 @@ function processLanguageTranslations( continue; } + // Deploy to rhdh/translations/{plugin}/ (standard deployment) const result = processPluginTranslation( pluginName, translations, @@ -962,6 +1036,34 @@ function processLanguageTranslations( if (result.updated) updated++; if (result.created) created++; } + + // For backstage/community-plugins files deployed from rhdh, also check if plugin is Red Hat owned + // and deploy to community-plugins workspace if it exists + if ( + (repoType === 'backstage' || repoType === 'community-plugins') && + communityPluginsRoot && + isRedHatOwnedPlugin(pluginName, communityPluginsRoot) + ) { + console.log( + chalk.cyan( + ` ๐Ÿ”ด Red Hat owned plugin detected: ${pluginName} โ†’ deploying to community-plugins`, + ), + ); + + // Deploy to community-plugins workspace + const communityPluginsResult = processPluginTranslation( + pluginName, + translations, + lang, + 'community-plugins', + communityPluginsRoot, + ); + + if (communityPluginsResult) { + if (communityPluginsResult.updated) updated++; + if (communityPluginsResult.created) created++; + } + } } return { updated, created }; @@ -1428,6 +1530,7 @@ async function deployTranslations( console.log(chalk.gray(` ๐Ÿ“„ Processing: ${displayFilename}`)); // Copy JSON file to rhdh_root/translations/ for backstage and community-plugins repos + let communityPluginsRoot: string | null = null; if ( effectiveRepoType === 'backstage' || effectiveRepoType === 'community-plugins' @@ -1462,6 +1565,19 @@ async function deployTranslations( ), ); } + + // Find community-plugins repo for Red Hat owned plugins deployment + // Only when running from rhdh repo + if (repoType === 'rhdh') { + communityPluginsRoot = getCommunityPluginsRepoRoot(repoRoot); + if (communityPluginsRoot) { + console.log( + chalk.gray( + ` ๐Ÿ“ฆ Community-plugins repo found: ${communityPluginsRoot}`, + ), + ); + } + } } const { updated, created } = processLanguageTranslations( @@ -1469,6 +1585,7 @@ async function deployTranslations( lang, effectiveRepoType, repoRoot, + communityPluginsRoot, ); totalUpdated += updated; From ca7b5ecc00f9095a96c44387a66d15cd401fb414 Mon Sep 17 00:00:00 2001 From: Yi Cai Date: Fri, 23 Jan 2026 21:39:20 -0500 Subject: [PATCH 25/30] fixed sonarQube issue Signed-off-by: Yi Cai --- workspaces/translations/packages/cli/src/commands/list.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/workspaces/translations/packages/cli/src/commands/list.ts b/workspaces/translations/packages/cli/src/commands/list.ts index 38fae8a3f3..8ac41375b2 100644 --- a/workspaces/translations/packages/cli/src/commands/list.ts +++ b/workspaces/translations/packages/cli/src/commands/list.ts @@ -129,7 +129,9 @@ function displayJobsTable( // Display jobs grouped by filename let displayIndex = 1; - for (const [filename, fileJobs] of Array.from(jobsByFile.entries()).sort()) { + for (const [filename, fileJobs] of Array.from(jobsByFile.entries()).sort( + (a, b) => a[0].localeCompare(b[0]), + )) { // Sort jobs by language const sortedJobs = fileJobs.sort((a, b) => (a.target_lang || '').localeCompare(b.target_lang || ''), From 347c993973bb6cc0ea999259e5eaa2100355becc Mon Sep 17 00:00:00 2001 From: Yi Cai Date: Fri, 23 Jan 2026 21:45:52 -0500 Subject: [PATCH 26/30] fixed failed CI check Signed-off-by: Yi Cai --- .../translations/packages/cli/cli-report.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/workspaces/translations/packages/cli/cli-report.md b/workspaces/translations/packages/cli/cli-report.md index 1d274af39d..dd86f46d93 100644 --- a/workspaces/translations/packages/cli/cli-report.md +++ b/workspaces/translations/packages/cli/cli-report.md @@ -31,6 +31,7 @@ Commands: generate [options] help [command] init [options] + list [options] setup-memsource [options] status [options] sync [options] @@ -66,10 +67,12 @@ Options: Usage: translations-cli i18n download [options] Options: + --include-incomplete --job-ids --languages --output-dir --project-id + --status -h, --help ``` @@ -104,6 +107,19 @@ Options: -h, --help ``` +### `translations-cli i18n list` + +``` +Usage: translations-cli i18n list [options] + +Options: + --format + --languages + --project-id + --status + -h, --help +``` + ### `translations-cli i18n setup-memsource` ``` From bcebaf20ae425e0a3ff4371c870b9d1c06e78c3b Mon Sep 17 00:00:00 2001 From: Yi Cai Date: Mon, 26 Jan 2026 21:08:04 -0500 Subject: [PATCH 27/30] code cleanup Signed-off-by: Yi Cai --- .../packages/cli/src/commands/deploy.ts | 80 +--- .../lib/i18n/deployTranslations.ts} | 408 +++++++++++++++--- .../packages/cli/test/generate.test.ts | 31 +- .../cli/test/workflow-verification.ts | 19 +- 4 files changed, 382 insertions(+), 156 deletions(-) rename workspaces/translations/packages/cli/{scripts/deploy-translations.ts => src/lib/i18n/deployTranslations.ts} (81%) diff --git a/workspaces/translations/packages/cli/src/commands/deploy.ts b/workspaces/translations/packages/cli/src/commands/deploy.ts index f8611c9aaa..45bb07db75 100644 --- a/workspaces/translations/packages/cli/src/commands/deploy.ts +++ b/workspaces/translations/packages/cli/src/commands/deploy.ts @@ -14,85 +14,11 @@ * limitations under the License. */ -import path from 'node:path'; - import { OptionValues } from 'commander'; import chalk from 'chalk'; import fs from 'fs-extra'; -import { commandExists, safeExecSyncOrThrow } from '../lib/utils/exec'; - -/** - * Deploy translations using the TypeScript deployment script - */ -async function deployWithTypeScriptScript( - sourceDir: string, - repoRoot: string, -): Promise { - // Find the deployment script - // Try multiple possible locations relative to known package structure - const possibleScriptPaths = [ - // From repo root (most reliable) - path.resolve( - repoRoot, - 'workspaces/translations/packages/cli/scripts/deploy-translations.ts', - ), - // From current working directory if we're in the package - path.resolve(process.cwd(), 'scripts/deploy-translations.ts'), - // From package root if cwd is in src - path.resolve(process.cwd(), '../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 to find tsx: check global, then try npx/yarn, then check local node_modules - let tsxCommand = 'tsx'; - let tsxArgs: string[] = [scriptPath, sourceDir]; - - if (!commandExists('tsx')) { - // Try npx tsx (uses local or downloads if needed) - if (commandExists('npx')) { - tsxCommand = 'npx'; - tsxArgs = ['tsx', scriptPath, sourceDir]; - } else if (commandExists('yarn')) { - // Try yarn tsx (uses local installation) - tsxCommand = 'yarn'; - tsxArgs = ['tsx', scriptPath, sourceDir]; - } else { - // Check for local tsx in node_modules - const localTsxPath = path.resolve(repoRoot, 'node_modules/.bin/tsx'); - if (await fs.pathExists(localTsxPath)) { - tsxCommand = localTsxPath; - tsxArgs = [scriptPath, sourceDir]; - } else { - throw new Error( - 'tsx not found. Please install it: npm install -g tsx, or yarn add -D tsx', - ); - } - } - } - - // Run the script with tsx - // Note: scriptPath and sourceDir are validated paths, safe to use - safeExecSyncOrThrow(tsxCommand, tsxArgs, { - stdio: 'inherit', - cwd: repoRoot, - env: { ...process.env }, - }); -} +import { deployTranslations } from '../lib/i18n/deployTranslations'; export async function deployCommand(opts: OptionValues): Promise { console.log( @@ -136,8 +62,8 @@ export async function deployCommand(opts: OptionValues): Promise { ), ); - // Deploy using TypeScript script - await deployWithTypeScriptScript(sourceDirStr, repoRoot); + // Deploy translations using library function + await deployTranslations(sourceDirStr, repoRoot); console.log(chalk.green(`โœ… Deployment completed successfully!`)); } catch (error: any) { diff --git a/workspaces/translations/packages/cli/scripts/deploy-translations.ts b/workspaces/translations/packages/cli/src/lib/i18n/deployTranslations.ts similarity index 81% rename from workspaces/translations/packages/cli/scripts/deploy-translations.ts rename to workspaces/translations/packages/cli/src/lib/i18n/deployTranslations.ts index 2c0dabf115..c32ce5206b 100644 --- a/workspaces/translations/packages/cli/scripts/deploy-translations.ts +++ b/workspaces/translations/packages/cli/src/lib/i18n/deployTranslations.ts @@ -1,4 +1,3 @@ -#!/usr/bin/env node /* * Copyright Red Hat, Inc. * @@ -126,6 +125,28 @@ function getPluginPackageImport(pluginName: string): string | null { return pluginPackageMap[pluginName] || null; } +/** + * Sanitize plugin name to create valid JavaScript identifier + * Handles dots, dashes, and other invalid characters + */ +function sanitizePluginName(pluginName: string): string { + // If plugin name contains dots (e.g., "plugin.argocd"), extract the last part + // Otherwise, convert dashes to camelCase + if (pluginName.includes('.')) { + const parts = pluginName.split('.'); + // Use the last part (e.g., "argocd" from "plugin.argocd") + return parts[parts.length - 1]; + } + + // Convert dashes to camelCase (e.g., "user-settings" -> "userSettings") + return pluginName + .split('-') + .map((word, i) => + i === 0 ? word : word.charAt(0).toUpperCase() + word.slice(1), + ) + .join(''); +} + /** * Infer ref import name, import path, and variable name from plugin name */ @@ -139,17 +160,12 @@ function inferRefInfo( 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(''); + // Sanitize plugin name to create valid identifier + const sanitized = sanitizePluginName(pluginName); - const refImportName = `${camelCase}TranslationRef`; + const refImportName = `${sanitized}TranslationRef`; const langCapitalized = lang.charAt(0).toUpperCase() + lang.slice(1); - const variableName = `${camelCase}Translation${langCapitalized}`; + const variableName = `${sanitized}Translation${langCapitalized}`; // Determine import path let refImportPath = './ref'; @@ -429,7 +445,7 @@ function getCommunityPluginsRepoRoot(repoRoot: string): string | null { } /** - * Check if a plugin exists in community-plugins repo (Red Hat owned) + * Check if a plugin is Red Hat owned by checking package.json for "author": "Red Hat" */ function isRedHatOwnedPlugin( pluginName: string, @@ -455,8 +471,20 @@ function isRedHatOwnedPlugin( 'plugins', cleanPluginName, ); + if (fs.existsSync(pluginDir)) { - return true; + // Check package.json for "author": "Red Hat" + const packageJsonPath = path.join(pluginDir, 'package.json'); + if (fs.existsSync(packageJsonPath)) { + try { + const packageJson = fs.readJsonSync(packageJsonPath); + if (packageJson.author === 'Red Hat') { + return true; + } + } catch { + // If package.json can't be read, continue searching + } + } } } @@ -524,6 +552,78 @@ function findPluginTranslationDir( return null; } +/** + * Extract copyright header from an existing file + */ +function extractCopyrightHeader(filePath: string): string | null { + try { + if (!fs.existsSync(filePath)) { + return null; + } + const content = fs.readFileSync(filePath, 'utf-8'); + // Match copyright header block (from /* to */) + const headerMatch = content.match(/\/\*[\s\S]*?\*\//); + if (headerMatch) { + return headerMatch[0]; + } + } catch { + // If file can't be read, return null + } + return null; +} + +/** + * Get copyright header for a plugin translation file + * Tries to extract from existing files, falls back to default Backstage copyright + */ +function getCopyrightHeader(translationDir: string): string { + // Try to extract from ref.ts first + const refFile = path.join(translationDir, 'ref.ts'); + let header = extractCopyrightHeader(refFile); + + if (header) { + return header; + } + + // Try to extract from any existing language file + if (fs.existsSync(translationDir)) { + const langFiles = fs + .readdirSync(translationDir) + .filter( + f => + f.endsWith('.ts') && + !f.includes('ref') && + !f.includes('translations') && + !f.includes('index'), + ); + + for (const langFile of langFiles) { + const langFilePath = path.join(translationDir, langFile); + header = extractCopyrightHeader(langFilePath); + if (header) { + return header; + } + } + } + + // Default to Backstage copyright if no existing file found + return `/* + * Copyright 2024 The Backstage Authors + * + * 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. + */`; +} + /** * Generate TypeScript translation file content */ @@ -534,6 +634,7 @@ function generateTranslationFile( refImportName: string, refImportPath: string, variableName: string, + translationDir?: string, ): string { let langName: string; if (lang === 'it') { @@ -555,8 +656,11 @@ function generateTranslationFile( }) .join('\n'); - return `/* - * Copyright Red Hat, Inc. + // Get copyright header from existing files or use default + const copyrightHeader = translationDir + ? getCopyrightHeader(translationDir) + : `/* + * Copyright 2024 The Backstage Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -569,7 +673,9 @@ function generateTranslationFile( * 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. - */ + */`; + + return `${copyrightHeader} import { createTranslationMessages } from '@backstage/core-plugin-api/alpha'; import { ${refImportName} } from '${refImportPath}'; @@ -858,6 +964,36 @@ function extractRefInfoFromOtherFiles( return null; } +/** + * Extract ref info directly from ref.ts file + */ +function extractRefInfoFromRefFile(translationDir: string): { + refImportName: string; + refImportPath: string; +} | null { + const refFile = path.join(translationDir, 'ref.ts'); + if (!fs.existsSync(refFile)) { + return null; + } + + try { + const content = fs.readFileSync(refFile, 'utf-8'); + // Match: export const xxxTranslationRef = createTranslationRef + const refExportMatch = content.match( + /export\s+const\s+(\w+TranslationRef)\s*=/, + ); + if (refExportMatch) { + return { + refImportName: refExportMatch[1], + refImportPath: './ref', + }; + } + } catch { + // If file can't be read, return null + } + return null; +} + /** * Get ref info for a plugin translation file */ @@ -914,6 +1050,20 @@ function getRefInfoForPlugin( } } + // Try to extract directly from ref.ts file + const refInfoFromRef = extractRefInfoFromRefFile(translationDir); + if (refInfoFromRef) { + const langCapitalized = lang.charAt(0).toUpperCase() + lang.slice(1); + // Extract base name from ref import (e.g., "argocd" from "argocdTranslationRef") + const baseName = refInfoFromRef.refImportName.replace('TranslationRef', ''); + const variableName = `${baseName}Translation${langCapitalized}`; + return { + refImportName: refInfoFromRef.refImportName, + refImportPath: refInfoFromRef.refImportPath, + variableName, + }; + } + // Last resort: infer from plugin name return inferRefInfo(pluginName, lang, repoType, translationDir); } @@ -960,19 +1110,53 @@ function processPluginTranslation( exists, ); + // Filter translations to only include keys that exist in the reference file + // This prevents TypeScript errors from invalid keys + const refFile = path.join(translationDir, 'ref.ts'); + let filteredTranslations = translations; + let filteredCount = 0; + + if (fs.existsSync(refFile)) { + const refKeys = extractKeysFromRefFile(refFile); + if (refKeys.size > 0) { + const validKeys = new Set(refKeys); + const originalCount = Object.keys(translations).length; + + filteredTranslations = Object.fromEntries( + Object.entries(translations).filter(([key]) => validKeys.has(key)), + ); + + filteredCount = originalCount - Object.keys(filteredTranslations).length; + + if (filteredCount > 0) { + const invalidKeys = Object.keys(translations).filter( + key => !validKeys.has(key), + ); + console.warn( + chalk.yellow( + ` โš ๏ธ Filtered out ${filteredCount} invalid key(s) not in ref.ts: ${invalidKeys + .slice(0, 3) + .join(', ')}${invalidKeys.length > 3 ? '...' : ''}`, + ), + ); + } + } + } + const content = generateTranslationFile( pluginName, lang, - translations, + filteredTranslations, refInfo.refImportName, refInfo.refImportPath, refInfo.variableName, + translationDir, ); fs.writeFileSync(targetFile, content, 'utf-8'); const relativePath = path.relative(repoRoot, targetFile); - const keyCount = Object.keys(translations).length; + const keyCount = Object.keys(filteredTranslations).length; if (exists) { console.log( @@ -1023,7 +1207,46 @@ function processLanguageTranslations( continue; } - // Deploy to rhdh/translations/{plugin}/ (standard deployment) + // For backstage files: Do NOT deploy TS files, only copy JSON (already done) + // For community-plugins files: Do NOT deploy TS files to rhdh/translations/{plugin}/ + // Only deploy TS files for Red Hat owned plugins to community-plugins workspaces + if (repoType === 'backstage') { + // Backstage files: Only copy JSON, no TS file deployment + continue; + } + + if (repoType === 'community-plugins') { + // Community-plugins files: Only deploy TS files for Red Hat owned plugins to community-plugins workspaces + // Do NOT deploy to rhdh/translations/{plugin}/ + if ( + communityPluginsRoot && + isRedHatOwnedPlugin(pluginName, communityPluginsRoot) + ) { + console.log( + chalk.cyan( + ` ๐Ÿ”ด Red Hat owned plugin detected: ${pluginName} โ†’ deploying to community-plugins`, + ), + ); + + // Deploy to community-plugins workspace only + const communityPluginsResult = processPluginTranslation( + pluginName, + translations, + lang, + 'community-plugins', + communityPluginsRoot, + ); + + if (communityPluginsResult) { + if (communityPluginsResult.updated) updated++; + if (communityPluginsResult.created) created++; + } + } + // Skip TS file deployment to rhdh/translations/{plugin}/ for community-plugins files + continue; + } + + // For other repo types (rhdh-plugins, rhdh), deploy TS files normally const result = processPluginTranslation( pluginName, translations, @@ -1036,34 +1259,6 @@ function processLanguageTranslations( if (result.updated) updated++; if (result.created) created++; } - - // For backstage/community-plugins files deployed from rhdh, also check if plugin is Red Hat owned - // and deploy to community-plugins workspace if it exists - if ( - (repoType === 'backstage' || repoType === 'community-plugins') && - communityPluginsRoot && - isRedHatOwnedPlugin(pluginName, communityPluginsRoot) - ) { - console.log( - chalk.cyan( - ` ๐Ÿ”ด Red Hat owned plugin detected: ${pluginName} โ†’ deploying to community-plugins`, - ), - ); - - // Deploy to community-plugins workspace - const communityPluginsResult = processPluginTranslation( - pluginName, - translations, - lang, - 'community-plugins', - communityPluginsRoot, - ); - - if (communityPluginsResult) { - if (communityPluginsResult.updated) updated++; - if (communityPluginsResult.created) created++; - } - } } return { updated, created }; @@ -1421,7 +1616,7 @@ function validateTranslationKeys( /** * Deploy translations from downloaded JSON files */ -async function deployTranslations( +export async function deployTranslations( downloadDir: string, repoRoot: string, ): Promise { @@ -1557,13 +1752,105 @@ async function deployTranslations( const cleanFilename = `${effectiveRepoType}-${timestamp}-${lang}.json`; const targetJsonPath = path.join(translationsDir, cleanFilename); - // Copy the JSON file - fs.copyFileSync(filepath, targetJsonPath); - console.log( - chalk.green( - ` ๐Ÿ“‹ Copied JSON to: ${path.relative(repoRoot, targetJsonPath)}`, - ), - ); + // Only copy if source and target are different (avoid copying file to itself) + if (filepath !== targetJsonPath) { + // Read the JSON data + const jsonData = JSON.parse(fs.readFileSync(filepath, 'utf-8')); + + // Update JSON: Replace "en" key with target language key for each plugin + const updatedData: Record< + string, + Record> + > = {}; + for (const [pluginName, pluginData] of Object.entries(jsonData)) { + const pluginDataObj = pluginData as Record< + string, + Record + >; + updatedData[pluginName] = {}; + + // If "en" key exists, replace it with target language key + if (pluginDataObj.en) { + updatedData[pluginName][lang] = pluginDataObj.en; + } else if (pluginDataObj[lang]) { + // If target language key already exists, keep it + updatedData[pluginName][lang] = pluginDataObj[lang]; + } else { + // If neither exists, keep the original structure + updatedData[pluginName] = pluginDataObj; + } + } + + // Write updated JSON to target location + fs.writeFileSync( + targetJsonPath, + JSON.stringify(updatedData, null, 2), + 'utf-8', + ); + console.log( + chalk.green( + ` ๐Ÿ“‹ Copied and updated JSON to: ${path.relative( + repoRoot, + targetJsonPath, + )} (replaced "en" with "${lang}")`, + ), + ); + } else { + // File is already in target location, but we still need to update it + const jsonData = JSON.parse(fs.readFileSync(filepath, 'utf-8')); + + // Update JSON: Replace "en" key with target language key for each plugin + const updatedData: Record< + string, + Record> + > = {}; + let needsUpdate = false; + + for (const [pluginName, pluginData] of Object.entries(jsonData)) { + const pluginDataObj = pluginData as Record< + string, + Record + >; + updatedData[pluginName] = {}; + + // If "en" key exists, replace it with target language key + if (pluginDataObj.en) { + updatedData[pluginName][lang] = pluginDataObj.en; + needsUpdate = true; + } else if (pluginDataObj[lang]) { + // If target language key already exists, keep it + updatedData[pluginName][lang] = pluginDataObj[lang]; + } else { + // If neither exists, keep the original structure + updatedData[pluginName] = pluginDataObj; + } + } + + if (needsUpdate) { + fs.writeFileSync( + targetJsonPath, + JSON.stringify(updatedData, null, 2), + 'utf-8', + ); + console.log( + chalk.green( + ` ๐Ÿ“‹ Updated JSON file: ${path.relative( + repoRoot, + targetJsonPath, + )} (replaced "en" with "${lang}")`, + ), + ); + } else { + console.log( + chalk.gray( + ` ๐Ÿ“‹ JSON already in target location: ${path.relative( + repoRoot, + targetJsonPath, + )}`, + ), + ); + } + } } // Find community-plugins repo for Red Hat owned plugins deployment @@ -1616,16 +1903,3 @@ async function deployTranslations( console.log(chalk.blue(`\n๐ŸŽ‰ Deployment complete!`)); } - -// Main execution -(async () => { - const downloadDir = process.argv[2] || 'workspaces/i18n/downloads'; - const repoRoot = process.cwd(); - - try { - await deployTranslations(downloadDir, repoRoot); - } catch (error) { - console.error(chalk.red('โŒ Error:'), error); - process.exit(1); - } -})(); diff --git a/workspaces/translations/packages/cli/test/generate.test.ts b/workspaces/translations/packages/cli/test/generate.test.ts index 6e0e761f38..f7594acc57 100644 --- a/workspaces/translations/packages/cli/test/generate.test.ts +++ b/workspaces/translations/packages/cli/test/generate.test.ts @@ -32,24 +32,34 @@ describe('generate command', () => { it('should generate reference.json file', async () => { const outputDir = path.join(fixture.path, 'i18n'); - const outputFile = path.join(outputDir, 'reference.json'); - + // Generate command creates files in format: {repo-name}-{sprint}.json + // Since we're in a test temp dir, repo name will be detected from the directory + // For simplicity, we'll check if any .json file was created const result = runCLI( - `i18n generate --source-dir ${fixture.path} --output-dir ${outputDir}`, + `i18n generate --sprint s9999 --source-dir ${fixture.path} --output-dir ${outputDir}`, ); expect(result.exitCode).toBe(0); - expect(await fs.pathExists(outputFile)).toBe(true); + + // Check if any JSON file was created in the output directory + const files = await fs.readdir(outputDir); + const jsonFiles = files.filter(f => f.endsWith('.json')); + expect(jsonFiles.length).toBeGreaterThan(0); }); 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}`, + `i18n generate --sprint s9999 --source-dir ${fixture.path} --output-dir ${outputDir}`, ); + // Find the generated JSON file + const files = await fs.readdir(outputDir); + const jsonFiles = files.filter(f => f.endsWith('.json')); + expect(jsonFiles.length).toBeGreaterThan(0); + const outputFile = path.join(outputDir, jsonFiles[0]); + const content = await fs.readFile(outputFile, 'utf-8'); const data = JSON.parse(content); @@ -68,12 +78,17 @@ describe('generate command', () => { 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}`, + `i18n generate --sprint s9999 --source-dir ${fixture.path} --output-dir ${outputDir}`, ); + // Find the generated JSON file + const files = await fs.readdir(outputDir); + const jsonFiles = files.filter(f => f.endsWith('.json')); + expect(jsonFiles.length).toBeGreaterThan(0); + const outputFile = path.join(outputDir, jsonFiles[0]); + const content = await fs.readFile(outputFile, 'utf-8'); // Should not contain German words diff --git a/workspaces/translations/packages/cli/test/workflow-verification.ts b/workspaces/translations/packages/cli/test/workflow-verification.ts index ff790627e2..cec3f2f078 100755 --- a/workspaces/translations/packages/cli/test/workflow-verification.ts +++ b/workspaces/translations/packages/cli/test/workflow-verification.ts @@ -460,14 +460,22 @@ async function testGeneratePoFormat(): Promise { } } -// Test 4: Deploy command - tests loadPoFile and deploy script +// Test 4: Deploy command - tests deployTranslations library function async function testDeployCommand(): Promise { // Create a test translation file in downloads format const downloadsDir = path.join(TEST_OUTPUT_DIR, 'downloads'); await fs.ensureDir(downloadsDir); + // Create a minimal repository structure so detectRepoType can identify it as rhdh-plugins + // This is needed because deployTranslations checks for workspaces/ directory + const workspacesDir = path.join(TEST_DIR, 'workspaces'); + await fs.ensureDir(workspacesDir); + // Create a sample downloaded translation file - const downloadedFile = path.join(downloadsDir, 'rhdh-s9999-it-C.json'); + const downloadedFile = path.join( + downloadsDir, + 'rhdh-plugins-s9999-it-C.json', + ); await fs.writeJson(downloadedFile, { 'test-plugin': { it: { @@ -477,7 +485,7 @@ async function testDeployCommand(): Promise { }, }); - // Test deploy (this will test the deploy script which uses loadPoFile) + // Test deploy (this will test the deployTranslations library function) const result = runCLI(`i18n deploy --source-dir ${downloadsDir}`, TEST_DIR); // Deploy might fail if it can't find target plugin directories, but that's OK @@ -485,7 +493,10 @@ async function testDeployCommand(): Promise { if (result.exitCode !== 0 && !result.stderr.includes('not found')) { // If it's not a "not found" error, it might be a real issue if (!result.stderr.includes('No translation JSON files found')) { - throw new Error(`Deploy failed unexpectedly: ${result.stderr}`); + // Also allow "Could not detect repository type" if workspaces wasn't created properly + if (!result.stderr.includes('Could not detect repository type')) { + throw new Error(`Deploy failed unexpectedly: ${result.stderr}`); + } } } } From 5cae9d40b5f44fc9ec9eee320ac8659f4c44817f Mon Sep 17 00:00:00 2001 From: Yi Cai Date: Mon, 26 Jan 2026 21:20:44 -0500 Subject: [PATCH 28/30] fixed tsc issue Signed-off-by: Yi Cai --- .../packages/cli/src/lib/i18n/deployTranslations.ts | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/workspaces/translations/packages/cli/src/lib/i18n/deployTranslations.ts b/workspaces/translations/packages/cli/src/lib/i18n/deployTranslations.ts index c32ce5206b..9533b5d42c 100644 --- a/workspaces/translations/packages/cli/src/lib/i18n/deployTranslations.ts +++ b/workspaces/translations/packages/cli/src/lib/i18n/deployTranslations.ts @@ -27,13 +27,6 @@ interface TranslationData { }; } -interface PluginInfo { - name: string; - translationDir: string; - refImportName: string; - variableName: string; -} - /** * Find the correct import path for translation ref */ @@ -1528,7 +1521,7 @@ function extractKeysFromRefFile(refFilePath: string): Set { * Validate that all translation files have matching keys with the reference file */ function validateTranslationKeys( - repoType: string, + _repoType: string, repoRoot: string, ): { hasErrors: boolean; errors: string[] } { const errors: string[] = []; From 64071d9d43ac7af1e0a409129476bd09dab4578e Mon Sep 17 00:00:00 2001 From: Yi Cai Date: Wed, 28 Jan 2026 12:47:46 -0500 Subject: [PATCH 29/30] added red hat plugin filter to generate command Signed-off-by: Yi Cai --- .../cli/docs/download-deploy-usage.md | 14 +++ .../packages/cli/src/commands/generate.ts | 86 +++++++++++++++++-- .../cli/src/lib/i18n/deployTranslations.ts | 32 ++++--- 3 files changed, 110 insertions(+), 22 deletions(-) diff --git a/workspaces/translations/packages/cli/docs/download-deploy-usage.md b/workspaces/translations/packages/cli/docs/download-deploy-usage.md index ebab59a9c5..6a8105459d 100644 --- a/workspaces/translations/packages/cli/docs/download-deploy-usage.md +++ b/workspaces/translations/packages/cli/docs/download-deploy-usage.md @@ -152,6 +152,8 @@ translations-cli i18n generate **Output**: `i18n/reference.json` +**Note**: When using `--core-plugins` flag with community-plugins repo, only **Red Hat owned plugins** (plugins with `"author": "Red Hat"` in their `package.json`) are included in the generated reference file. Non-Red Hat plugins are automatically filtered out. + #### For rhdh: ```bash @@ -168,6 +170,18 @@ translations-cli i18n generate - Generates a flat JSON file with all English reference strings - File format: `{ "pluginName": { "en": { "key": "English value" } } }` +**Special behavior for community-plugins with `--core-plugins` flag:** + +When using `--core-plugins` flag and pointing to a community-plugins repository, the generate command automatically filters to only include **Red Hat owned plugins**. A plugin is considered Red Hat owned if it has `"author": "Red Hat"` in its `package.json` file. Non-Red Hat plugins are automatically excluded from the generated reference file. + +Example: + +```bash +cd /path/to/community-plugins +translations-cli i18n generate --core-plugins --backstage-repo-path /path/to/community-plugins +# Only Red Hat owned plugins will be included in the output +``` + ### Step 2: Upload Reference Files to Memsource Upload the generated reference files to your TMS project for translation. diff --git a/workspaces/translations/packages/cli/src/commands/generate.ts b/workspaces/translations/packages/cli/src/commands/generate.ts index cc4a3fb5ac..3142a1a944 100644 --- a/workspaces/translations/packages/cli/src/commands/generate.ts +++ b/workspaces/translations/packages/cli/src/commands/generate.ts @@ -26,6 +26,7 @@ import { generateTranslationFiles } from '../lib/i18n/generateFiles'; import { mergeTranslationFiles } from '../lib/i18n/mergeFiles'; import { loadI18nConfig, mergeConfigWithOptions } from '../lib/i18n/config'; import { safeExecSyncOrThrow } from '../lib/utils/exec'; +import { isRedHatOwnedPlugin } from '../lib/i18n/deployTranslations'; /** * Detect repository name from git or directory @@ -1269,9 +1270,21 @@ async function extractKeysFromCorePluginRefs( pluginRefFiles: string[], shouldFilterByUsage: boolean, usedPlugins: Set, + communityPluginsRoot?: string | null, ): Promise }>> { const structuredData: Record }> = {}; + // Check if we're scanning community-plugins repo (has workspaces/plugins structure, not workspaces/packages) + const isCommunityPluginsRepo = + communityPluginsRoot && + pluginRefFiles.some( + file => + file.includes('workspaces/') && + file.includes('/plugins/') && + !file.includes('/packages/'), + ); + + let filteredCount = 0; for (const refFile of pluginRefFiles) { try { const { keys, pluginName } = await extractKeysFromRefFile(refFile); @@ -1282,6 +1295,14 @@ async function extractKeysFromCorePluginRefs( const mappedPluginName = mapPluginName(pluginName, refFile); + // Filter: Only include Red Hat owned plugins when scanning community-plugins repo + if (isCommunityPluginsRepo && communityPluginsRoot) { + if (!isRedHatOwnedPlugin(mappedPluginName, communityPluginsRoot)) { + filteredCount++; + continue; + } + } + if (shouldFilterByUsage && !usedPlugins.has(mappedPluginName)) { continue; } @@ -1409,6 +1430,14 @@ async function extractKeysFromTranslatedFiles( } } + if (isCommunityPluginsRepo && filteredCount > 0) { + console.log( + chalk.gray( + ` Filtered out ${filteredCount} non-Red Hat owned plugin(s) from community-plugins repo`, + ), + ); + } + return structuredData; } @@ -1476,19 +1505,49 @@ async function processCorePlugins( ); } + // Check if we're scanning community-plugins repo (has workspaces/plugins structure) + // If so, we need to filter to only Red Hat owned plugins + const isCommunityPluginsRepo = pluginRefFiles.some( + file => + file.includes('workspaces/') && + file.includes('/plugins/') && + !file.includes('/packages/'), + ); + const communityPluginsRoot = isCommunityPluginsRepo + ? backstageRepoPath + : null; + + if (isCommunityPluginsRepo) { + console.log( + chalk.yellow( + `๐Ÿ” Detected community-plugins repo - filtering to Red Hat owned plugins only...`, + ), + ); + } + const refData = await extractKeysFromCorePluginRefs( pluginRefFiles, shouldFilterByUsage, usedPlugins, + communityPluginsRoot, ); Object.assign(structuredData, refData); - console.log( - chalk.green( - `โœ… Extracted keys from ${usedPlugins.size} Backstage plugin packages used in RHDH`, - ), - ); + const pluginCount = Object.keys(structuredData).length; + if (isCommunityPluginsRepo) { + console.log( + chalk.green( + `โœ… Extracted keys from ${pluginCount} Red Hat owned plugin(s) in community-plugins repo`, + ), + ); + } else { + console.log( + chalk.green( + `โœ… Extracted keys from ${usedPlugins.size} Backstage plugin packages used in RHDH`, + ), + ); + } const translatedFiles = await findCorePluginsTranslatedFiles( repoRoot, @@ -1507,7 +1566,16 @@ async function processCorePlugins( repoRoot, ); + // Filter: Only include Red Hat owned plugins when scanning community-plugins repo + let filteredTranslatedCount = 0; for (const [pluginName, pluginKeys] of Object.entries(translatedData)) { + if (isCommunityPluginsRepo && communityPluginsRoot) { + if (!isRedHatOwnedPlugin(pluginName, communityPluginsRoot)) { + filteredTranslatedCount++; + continue; + } + } + if (!structuredData[pluginName]) { structuredData[pluginName] = { en: {} }; } @@ -1519,6 +1587,14 @@ async function processCorePlugins( } } + if (isCommunityPluginsRepo && filteredTranslatedCount > 0) { + console.log( + chalk.gray( + ` Filtered out ${filteredTranslatedCount} non-Red Hat owned plugin(s) from translated files`, + ), + ); + } + console.log( chalk.green( `โœ… Extracted keys from ${translatedFiles.length} existing core-plugins translation file(s)`, diff --git a/workspaces/translations/packages/cli/src/lib/i18n/deployTranslations.ts b/workspaces/translations/packages/cli/src/lib/i18n/deployTranslations.ts index 9533b5d42c..6b838da042 100644 --- a/workspaces/translations/packages/cli/src/lib/i18n/deployTranslations.ts +++ b/workspaces/translations/packages/cli/src/lib/i18n/deployTranslations.ts @@ -440,7 +440,7 @@ function getCommunityPluginsRepoRoot(repoRoot: string): string | null { /** * Check if a plugin is Red Hat owned by checking package.json for "author": "Red Hat" */ -function isRedHatOwnedPlugin( +export function isRedHatOwnedPlugin( pluginName: string, communityPluginsRoot: string, ): boolean { @@ -1717,12 +1717,10 @@ export async function deployTranslations( console.log(chalk.cyan(`\n ๐ŸŒ Language: ${lang.toUpperCase()}`)); console.log(chalk.gray(` ๐Ÿ“„ Processing: ${displayFilename}`)); - // Copy JSON file to rhdh_root/translations/ for backstage and community-plugins repos + // Copy JSON file to rhdh_root/translations/ for backstage repos only + // Note: community-plugins JSON files are NOT copied to rhdh_root/translations/ let communityPluginsRoot: string | null = null; - if ( - effectiveRepoType === 'backstage' || - effectiveRepoType === 'community-plugins' - ) { + if (effectiveRepoType === 'backstage') { const targetRoot = getTargetRepoRoot(repoRoot, effectiveRepoType); const translationsDir = path.join(targetRoot, 'translations'); @@ -1845,18 +1843,18 @@ export async function deployTranslations( } } } + } - // Find community-plugins repo for Red Hat owned plugins deployment - // Only when running from rhdh repo - if (repoType === 'rhdh') { - communityPluginsRoot = getCommunityPluginsRepoRoot(repoRoot); - if (communityPluginsRoot) { - console.log( - chalk.gray( - ` ๐Ÿ“ฆ Community-plugins repo found: ${communityPluginsRoot}`, - ), - ); - } + // Find community-plugins repo for Red Hat owned plugins deployment + // Only when running from rhdh repo and processing community-plugins files + if (repoType === 'rhdh' && effectiveRepoType === 'community-plugins') { + communityPluginsRoot = getCommunityPluginsRepoRoot(repoRoot); + if (communityPluginsRoot) { + console.log( + chalk.gray( + ` ๐Ÿ“ฆ Community-plugins repo found: ${communityPluginsRoot}`, + ), + ); } } From a47d6e140a7ede7a63efa6ba5c62677cbed717d2 Mon Sep 17 00:00:00 2001 From: Yi Cai Date: Wed, 28 Jan 2026 13:04:21 -0500 Subject: [PATCH 30/30] fixed tsc:full errors Signed-off-by: Yi Cai --- .../packages/cli/src/commands/generate.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/workspaces/translations/packages/cli/src/commands/generate.ts b/workspaces/translations/packages/cli/src/commands/generate.ts index 3142a1a944..387248b668 100644 --- a/workspaces/translations/packages/cli/src/commands/generate.ts +++ b/workspaces/translations/packages/cli/src/commands/generate.ts @@ -1325,6 +1325,14 @@ async function extractKeysFromCorePluginRefs( } } + if (isCommunityPluginsRepo && filteredCount > 0) { + console.log( + chalk.gray( + ` Filtered out ${filteredCount} non-Red Hat owned plugin(s) from community-plugins repo`, + ), + ); + } + return structuredData; } @@ -1430,14 +1438,6 @@ async function extractKeysFromTranslatedFiles( } } - if (isCommunityPluginsRepo && filteredCount > 0) { - console.log( - chalk.gray( - ` Filtered out ${filteredCount} non-Red Hat owned plugin(s) from community-plugins repo`, - ), - ); - } - return structuredData; }