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/.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/.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/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..e1d3d47191 --- /dev/null +++ b/workspaces/translations/packages/cli/bin/translations-cli @@ -0,0 +1,34 @@ +#!/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('node: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')); + +// Prefer built version if available, otherwise use source with transform +if (hasDist) { + require('..'); +} else if (isLocal) { + require('@backstage/cli/config/nodeTransform.cjs'); + require('../src'); +} else { + require('..'); +} + diff --git a/workspaces/translations/packages/cli/cli-report.md b/workspaces/translations/packages/cli/cli-report.md new file mode 100644 index 0000000000..dd86f46d93 --- /dev/null +++ b/workspaces/translations/packages/cli/cli-report.md @@ -0,0 +1,187 @@ +## 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] + list [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: + --include-incomplete + --job-ids + --languages + --output-dir + --project-id + --status + -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 + --sprint + -h, --help +``` + +### `translations-cli i18n init` + +``` +Usage: translations-cli i18n init [options] + +Options: + --memsource-venv + --setup-memsource + -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` + +``` +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 + --sprint + --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/docs/download-deploy-usage.md b/workspaces/translations/packages/cli/docs/download-deploy-usage.md new file mode 100644 index 0000000000..6a8105459d --- /dev/null +++ b/workspaces/translations/packages/cli/docs/download-deploy-usage.md @@ -0,0 +1,456 @@ +# 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` + +**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 +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" } } }` + +**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. + +#### 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) + - 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`: 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:** + +- **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: + +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/i18n-commands.md b/workspaces/translations/packages/cli/docs/i18n-commands.md new file mode 100644 index 0000000000..f63fe6efbc --- /dev/null +++ b/workspaces/translations/packages/cli/docs/i18n-commands.md @@ -0,0 +1,1061 @@ +# 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 + +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 +``` + +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. + +--- + +**📝 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 + +### 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 + +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. + +### 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 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. + +--- + +## 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:** + +If you haven't completed the Memsource setup yet, see the [Prerequisites](#prerequisites) section above for detailed setup instructions. + +**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 +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. Complete Prerequisites (One-Time Setup) + +**For Memsource Users:** + +If you haven't completed the Memsource setup yet, follow the [Prerequisites](#prerequisites) section above: + +```bash +# Complete the one-time setup (see Prerequisites section for details) +npx translations-cli i18n setup-memsource +source ~/.memsourcerc +``` + +#### 2. Initialize Project Configuration + +```bash +# Initialize project configuration file +npx translations-cli i18n init +``` + +**For Memsource Users (Daily Workflow):** + +1. **One-time setup** (already completed in Prerequisites): + + ```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/multi-repo-deployment.md b/workspaces/translations/packages/cli/docs/multi-repo-deployment.md new file mode 100644 index 0000000000..0664e74a35 --- /dev/null +++ b/workspaces/translations/packages/cli/docs/multi-repo-deployment.md @@ -0,0 +1,259 @@ +# 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 +# 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 + +### 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 +``` + +### 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 + +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 intelligently searches for plugins using repo-specific patterns: + +- **rhdh-plugins/community-plugins**: `workspaces/*/plugins/{plugin}/src/translations/` +- **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 + +### "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 + +### 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/package.json b/workspaces/translations/packages/cli/package.json new file mode 100644 index 0000000000..01dcaa71e6 --- /dev/null +++ b/workspaces/translations/packages/cli/package.json @@ -0,0 +1,67 @@ +{ + "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": "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: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 --", + "dev": "ts-node src/index.ts", + "dev:help": "ts-node src/index.ts --help", + "test:local": "npm run build && node bin/translations-cli" + }, + "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", + "tsx": "^4.21.0", + "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..76c5fdf570 --- /dev/null +++ b/workspaces/translations/packages/cli/src/commands/clean.ts @@ -0,0 +1,196 @@ +/* + * 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 'node: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 [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); +} + +/** + * 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) { + 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.')); + } + } 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..45bb07db75 --- /dev/null +++ b/workspaces/translations/packages/cli/src/commands/deploy.ts @@ -0,0 +1,73 @@ +/* + * 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 fs from 'fs-extra'; + +import { deployTranslations } from '../lib/i18n/deployTranslations'; + +export async function deployCommand(opts: OptionValues): Promise { + console.log( + chalk.blue( + '🚀 Deploying translated strings to application language files...', + ), + ); + + const { sourceDir = 'i18n/downloads' } = opts as { + sourceDir?: string; + }; + + try { + const sourceDirStr = String(sourceDir || 'i18n/downloads'); + const repoRoot = process.cwd(); + + if (!(await fs.pathExists(sourceDirStr))) { + throw new Error(`Source directory not found: ${sourceDirStr}`); + } + + // 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 (jsonFiles.length === 0) { + console.log( + 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 ${jsonFiles.length} translation file(s) to deploy`, + ), + ); + + // Deploy translations using library function + await deployTranslations(sourceDirStr, repoRoot); + + 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 new file mode 100644 index 0000000000..07f75cd661 --- /dev/null +++ b/workspaces/translations/packages/cli/src/commands/download.ts @@ -0,0 +1,480 @@ +/* + * 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 fs from 'fs-extra'; +import path from 'node:path'; + +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']; +} + +/** + * 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 + */ +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) { + // 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: cleanFilename || actualFile, + lang: job.target_lang, + }; + } + return null; + } catch (error: any) { + console.warn( + chalk.yellow( + `⚠️ Warning: Could not download job ${jobId}: ${error.message}`, + ), + ); + return null; + } +} + +/** + * Validate prerequisites for Memsource CLI download + */ +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', + ); + } +} + +/** + * 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; + filename: string; + lang: string; + }> = []; + + 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 jobs by status and language + */ +function listJobs( + projectId: string, + languages?: 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', + env: { ...process.env }, + }); + const jobs = JSON.parse(listOutput); + const jobArray = Array.isArray(jobs) ? jobs : [jobs]; + + let filteredJobs = jobArray; + + // Filter by status + if (statusFilter && statusFilter !== 'ALL') { + filteredJobs = filteredJobs.filter( + (job: any) => job.status === statusFilter, + ); + } + + // 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 jobs filtered by status and language + */ +async function downloadFilteredJobs( + projectId: string, + outputDir: string, + languages?: string[], + statusFilter?: string, +): Promise> { + console.log(chalk.yellow('📋 Listing available jobs...')); + + try { + const jobsToDownload = listJobs(projectId, languages, statusFilter); + + const statusDisplay = + statusFilter === 'ALL' ? 'all statuses' : statusFilter || 'COMPLETED'; + console.log( + chalk.yellow( + `📥 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; + lang: string; + }> = []; + + for (const job of jobsToDownload) { + const result = await downloadJob(projectId, job.uid, outputDir); + if (result) { + downloadResults.push(result); + const statusIcon = job.status === 'COMPLETED' ? '✅' : '⚠️'; + console.log( + chalk.green( + `${statusIcon} Downloaded: ${result.filename} (${result.lang}) [${job.status}]`, + ), + ); + } + } + + return downloadResults; + } catch (error: any) { + throw new Error(`Failed to list jobs: ${error.message}`); + } +} + +/** + * Download translations using Memsource CLI + */ +async function downloadWithMemsourceCLI( + projectId: string, + outputDir: string, + jobIds?: string[], + languages?: string[], + statusFilter?: string, +): Promise> { + validateMemsourcePrerequisites(); + await fs.ensureDir(outputDir); + + if (jobIds && jobIds.length > 0) { + return downloadSpecificJobs(projectId, jobIds, outputDir); + } + + return downloadFilteredJobs(projectId, outputDir, languages, statusFilter); +} + +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(); + const mergedOpts = await mergeConfigWithOptions(config, opts); + + const { + projectId, + 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:')); + 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', + ), + ); + 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(' 3. Source ~/.memsourcerc: source ~/.memsourcerc'), + ); + 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 { + // Parse job IDs if provided (comma-separated) + const jobIdArray = + jobIds && typeof jobIds === 'string' + ? jobIds.split(',').map((id: string) => id.trim()) + : undefined; + + // Parse languages if provided (comma-separated) + const languageArray = + languages && typeof languages === 'string' + ? languages.split(',').map((lang: string) => lang.trim()) + : undefined; + + const downloadResults = await downloadWithMemsourceCLI( + projectId, + String(outputDir), + jobIdArray, + languageArray, + statusFilter, + ); + + // 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.filename} (${result.lang}) - Job ID: ${result.jobId}`, + ), + ); + } + } + } catch (error: any) { + console.error(chalk.red('❌ Error downloading from TMS:'), error.message); + 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..387248b668 --- /dev/null +++ b/workspaces/translations/packages/cli/src/commands/generate.ts @@ -0,0 +1,1900 @@ +/* + * 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 'node: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'; +import { safeExecSyncOrThrow } from '../lib/utils/exec'; +import { isRedHatOwnedPlugin } from '../lib/i18n/deployTranslations'; + +/** + * 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( + 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 + ); +} + +/** + * 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 (TypeScript) + */ +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; +} + +/** + * 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 { + // 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/')) { + return normalizePluginName(simplePluginsMatch[1]); + } + + // Pattern 1: plugins/{name}/packages/plugin-{name}/... + const pluginsMatch = /plugins\/([^/]+)\/packages\/plugin-([^/]+)/.exec( + filePath, + ); + if (pluginsMatch) { + return normalizePluginName(pluginsMatch[2]); + } + + // Pattern 2: packages/plugin-{name}/... + const packagesPluginMatch = /packages\/plugin-([^/]+)/.exec(filePath); + if (packagesPluginMatch) { + return normalizePluginName(packagesPluginMatch[1]); + } + + // 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) { + return normalizePluginName(workspacesMatch[2]); + } + + // Pattern 5: Legacy node_modules/@backstage/plugin-{name}/... (backward compatibility) + const nodeModulesPluginMatch = /@backstage\/plugin-([^/]+)/.exec(filePath); + if (nodeModulesPluginMatch) { + return normalizePluginName(nodeModulesPluginMatch[1]); + } + + // 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 + */ +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" + return pluginName.startsWith('core-') + ? `@backstage/${pluginName}` + : `@backstage/plugin-${pluginName}`; +} + +async function checkPackageJsonDependencies( + packageJsonPath: string, + packageName: string, +): Promise { + if (!(await fs.pathExists(packageJsonPath))) { + return false; + } + + try { + const packageJson = await fs.readJson(packageJsonPath); + const allDeps = { + ...packageJson.dependencies, + ...packageJson.devDependencies, + ...packageJson.peerDependencies, + }; + return packageName in allDeps; + } catch { + return false; + } +} + +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'); + } + + 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}`) + ); +} + +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'); + } + } + } + } + + // 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))) { + return false; + } + + 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; + } +} + +async function checkSourceCodeImports( + repoRoot: string, + packageName: string, +): Promise { + 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, + }); + + for (const file of files.slice(0, 50)) { + try { + const content = await fs.readFile(file, 'utf-8'); + 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 { + continue; + } + } + } catch { + 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 + 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) + * 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 nestedResult = extractNestedStructure( + data as Record, + isCorePlugins, + ); + if (Object.keys(nestedResult).length > 0) { + return nestedResult; + } + } + + // Handle flat structure: { key: value } or { translations: { key: value } } + return extractFlatStructure(data, filePath); + } catch (error) { + console.warn( + chalk.yellow( + `⚠️ Warning: Could not extract keys from JSON file ${filePath}: ${error}`, + ), + ); + return {}; + } +} + +/** + * 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 + */ +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, + )}`, + ), + ); + return false; + } + + return true; +} + +function mergeKeysIntoPluginGroup( + pluginGroups: Record>, + pluginName: string, + keys: Record, + filePath: string, +): void { + if (!pluginGroups[pluginName]) { + pluginGroups[pluginName] = {}; + } + + 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; + } + + 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 + */ +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(''); +} + +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)', + ); + } + + 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, + 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); + + if (!pluginName || Object.keys(keys).length === 0) { + continue; + } + + 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; + } + + 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}`, + ), + ); + } + } + + if (isCommunityPluginsRepo && filteredCount > 0) { + console.log( + chalk.gray( + ` Filtered out ${filteredCount} non-Red Hat owned plugin(s) from community-plugins repo`, + ), + ); + } + + 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)...`, + ), + ); + } + + // 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); + + 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, + 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, + ); + + // 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: {} }; + } + + for (const [key, value] of Object.entries(pluginKeys)) { + if (!structuredData[pluginName].en[key]) { + structuredData[pluginName].en[key] = value; + } + } + } + + 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)`, + ), + ); + } + + 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( + 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); + + 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 { + await fs.ensureDir(outputDir); + + const translationKeys: Record }> = {}; + const backstageRepoPath = getBackstageRepoPath(opts, config); + + if (extractKeys) { + const repoRoot = process.cwd(); + let structuredData: Record }>; + + if (corePlugins) { + 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); + } + + structuredData = await processCorePlugins( + backstageRepoPath, + repoRoot, + outputDir, + ); + + console.log( + chalk.gray( + ` Including all ${ + Object.keys(structuredData).length + } core plugins (including React versions with unique translations)`, + ), + ); + } else { + structuredData = await processRhdhPlugins( + sourceDir, + includePattern, + excludePattern, + ); + } + + 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(structuredData).length + } plugins`, + ), + ); + + Object.assign(translationKeys, structuredData); + } + + const formatStr = String(format || 'json'); + const filename = generateFilename( + outputFilename, + sprint, + corePlugins, + backstageRepoPath, + ); + const finalOutputDir = getOutputDirectory( + corePlugins, + backstageRepoPath, + outputDir, + ); + + if (corePlugins && backstageRepoPath) { + await fs.ensureDir(finalOutputDir); + } + + const outputPath = path.join(finalOutputDir, `${filename}.${formatStr}`); + + // Always pass as nested structure to match reference.json format + await generateOrMergeFiles( + translationKeys as Record }>, + outputPath, + formatStr, + mergeExisting, + ); + + await validateGeneratedFile(outputPath, formatStr); + + if (extractKeys && isNestedStructure(translationKeys)) { + displaySummary( + translationKeys as Record }>, + ); + } + + 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..9b3139a39f --- /dev/null +++ b/workspaces/translations/packages/cli/src/commands/index.ts @@ -0,0 +1,274 @@ +/* + * 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'; +import { listCommand } from './list'; + +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') + .requiredOption( + '--sprint ', + 'Sprint value for filename (e.g., s3285). Format: -reference-.json', + ) + .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) + .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 + 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}-{sprint}.json, extracts sprint from source filename)', + ) + .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)); + + // 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') + .description('Download translated strings from TMS using Memsource CLI') + .option('--project-id ', 'TMS project ID') + .option( + '--output-dir ', + 'Output directory for downloaded translations', + 'i18n/downloads', + ) + .option( + '--languages ', + 'Comma-separated list of languages to download (e.g., "it,ja,fr")', + ) + .option( + '--job-ids ', + '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)); + + // Deploy command - deploy translated strings back to language files + command + .command('deploy') + .description( + 'Deploy downloaded translations to TypeScript translation files (it.ts, ja.ts, etc.)', + ) + .option( + '--source-dir ', + 'Source directory containing downloaded translations (from Memsource)', + 'i18n/downloads', + ) + .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', + ) + .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') + .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 (will auto-detect or prompt if not provided)', + ) + .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 (will auto-detect or prompt if not provided)', + ) + .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..b3d7def86d --- /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 'node:os'; +import path from 'node: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/list.ts b/workspaces/translations/packages/cli/src/commands/list.ts new file mode 100644 index 0000000000..8ac41375b2 --- /dev/null +++ b/workspaces/translations/packages/cli/src/commands/list.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/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( + (a, b) => a[0].localeCompare(b[0]), + )) { + // 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/commands/setupMemsource.ts b/workspaces/translations/packages/cli/src/commands/setupMemsource.ts new file mode 100644 index 0000000000..53959677eb --- /dev/null +++ b/workspaces/translations/packages/cli/src/commands/setupMemsource.ts @@ -0,0 +1,435 @@ +/* + * 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 'node:path'; +import os from 'node:os'; +import * as readline from 'node:readline'; +import { stdin, stdout } from 'process'; + +import { OptionValues } from 'commander'; +import chalk from 'chalk'; +import fs from 'fs-extra'; + +/** + * Check if terminal is interactive + */ +function isInteractiveTerminal(): boolean { + return stdin.isTTY && stdout.isTTY; +} + +/** + * 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; +} + +/** + * 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; + + if (stdin.isTTY) { + stdin.setRawMode(true); + } + stdin.resume(); + stdin.setEncoding('utf8'); + + stdout.write(query); + + let inputPassword = ''; + + // 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'); + } + return; + } + + if (char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127) { + return; + } + + inputPassword += char; + stdout.write('*'); + }; + + cleanup = () => { + stdin.removeListener('data', onData); + if (stdin.isTTY) { + stdin.setRawMode(wasRawMode); + } + stdin.pause(); + }; + + stdin.on('data', onData); + }); + }; + + 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, + }); + + 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=${username} + +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}'); +} + +/** + * 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.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', + ), + ); +} + +/** + * 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 + */ +async function checkVirtualEnvironment(memsourceVenv: string): Promise { + const expandedVenvPath = memsourceVenv.replaceAll( + /\$\{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.', + ), + ); + console.log( + chalk.gray( + ' You can edit ~/.memsourcerc and update the "source" line with the correct path.', + ), + ); + } +} + +/** + * 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, + memsourceUrl = 'https://cloud.memsource.com/web', + username, + password, + } = opts; + + try { + 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( + finalMemsourceVenv, + memsourceUrl, + finalUsername, + finalPassword, + ); + + const memsourceRcPath = path.join(os.homedir(), '.memsourcerc'); + await fs.writeFile(memsourceRcPath, memsourceRcContent, { mode: 0o600 }); + + displaySetupInstructions(memsourceRcPath); + 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/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..eb26d48441 --- /dev/null +++ b/workspaces/translations/packages/cli/src/commands/sync.ts @@ -0,0 +1,365 @@ +/* + * 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 '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'; +import { downloadCommand } from './download'; +import { deployCommand } from './deploy'; + +interface SyncOptions { + sourceDir: string; + outputDir: string; + localesDir: string; + sprint?: 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 a step (actually perform the action) + */ +async function executeStep( + _stepName: string, + action: () => Promise, +): Promise { + await action(); +} + +/** + * Simulate a step (show what would be done) + */ +function simulateStep(stepName: string): void { + console.log(chalk.yellow(`🔍 Dry run: Would ${stepName}`)); +} + +/** + * Step 1: Generate translation reference files + */ +async function stepGenerate( + sourceDir: string, + outputDir: string, + sprint: string | undefined, + dryRun: boolean, +): Promise<{ step: string; generatedFile?: string }> { + console.log( + chalk.blue('\n📝 Step 1: Generating translation reference files...'), + ); + + if (dryRun) { + simulateStep('generate translation files'); + 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 + } + + // Fallback: use directory name + return path.basename(targetPath); +} + +/** + * Step 2: Upload to TMS + */ +async function stepUpload( + options: SyncOptions, + generatedFile?: string, +): 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; + } + + const tmsUrl = options.tmsUrl; + const tmsToken = options.tmsToken; + const projectId = options.projectId; + + 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 { + await executeStep('upload to TMS', async () => { + await uploadCommand({ + tmsUrl, + tmsToken, + projectId, + sourceFile, + 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; + } + + const tmsUrl = options.tmsUrl; + const tmsToken = options.tmsToken; + const projectId = options.projectId; + + console.log(chalk.blue('\n📥 Step 3: Downloading from TMS...')); + + 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'; +} + +/** + * 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...')); + + 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'; +} + +/** + * 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'), + sprint: + mergedOpts.sprint && typeof mergedOpts.sprint === 'string' + ? mergedOpts.sprint + : undefined, + 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 generateResult = await stepGenerate( + options.sourceDir, + options.outputDir, + options.sprint, + options.dryRun, + ); + + const allSteps = [ + generateResult.step, + await stepUpload(options, generateResult.generatedFile), + await stepDownload(options), + await stepDeploy(options), + ]; + + const steps = allSteps.filter((step): step is string => Boolean(step)); + + 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..34a6b8171e --- /dev/null +++ b/workspaces/translations/packages/cli/src/commands/upload.ts @@ -0,0 +1,749 @@ +/* + * 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 'node:path'; + +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'; +import { commandExists, safeExecSyncOrThrow } from '../lib/utils/exec'; +import { countTranslationKeys } from '../lib/utils/translationUtils'; + +/** + * 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) + // 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 { + // Git not available or not a git repo + } + + // Fallback: use directory name + 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 + */ +function generateUploadFileName( + sourceFile: string, + customName?: string, + sprint?: 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}`; + } + + // Try to extract sprint from source filename if not provided + 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 + const sourceFileAbs = path.resolve(sourceFile); + const sourceDir = path.dirname(sourceFileAbs); + const repoRoot = findGitRoot(sourceDir); + const repoName = repoRoot ? detectRepoName(repoRoot) : detectRepoName(); + + const identifier = formatIdentifier(sprintValue); + const ext = path.extname(sourceFile); + return `${repoName}-${identifier}${ext}`; +} + +/** + * Create temporary file with custom name if needed + */ +async function prepareUploadFile( + filePath: string, + uploadFileName?: string, +): Promise<{ fileToUpload: string; tempFile: string | null }> { + const absoluteFilePath = path.resolve(filePath); + let fileToUpload = absoluteFilePath; + let tempFile: string | null = null; + + if (uploadFileName && path.basename(absoluteFilePath) !== uploadFileName) { + 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}`), + ); + } + + 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 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', + ); + } + + 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'; + } + + 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 fileContent = await fs.readFile(filePath, 'utf-8'); + const data = JSON.parse(fileContent); + return countTranslationKeys(data); + } catch { + return 0; + } +} + +/** + * Clean up temporary file and directory + */ +async function cleanupTempFile(tempFile: string): Promise { + try { + if (await fs.pathExists(tempFile)) { + await fs.remove(tempFile); + } + + 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); + + try { + await executeMemsourceUpload(args, fileToUpload); + + const keyCount = await countKeysFromFile(fileToUpload); + + return { + fileName: uploadFileName || path.basename(absoluteFilePath), + keyCount, + }; + } catch (error: unknown) { + const errorMessage = extractErrorMessage(error); + throw new Error(`memsource CLI upload failed: ${errorMessage}`); + } finally { + if (tempFile) { + 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...')); + + const config = await loadI18nConfig(); + const mergedOpts = await mergeConfigWithOptions(config, opts); + + const { + tmsUrl, + tmsToken, + projectId, + sourceFile, + targetLanguages, + uploadFileName, + uploadFilename, // Commander.js converts --upload-filename to uploadFilename + dryRun = false, + force = false, + } = mergedOpts as { + tmsUrl?: string; + tmsToken?: string; + projectId?: string; + 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); + 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 { + await validateSourceFile(sourceFileStr); + + // Try to extract sprint from source filename or use provided upload filename + const finalUploadFileName = + finalUploadFileNameOption && typeof finalUploadFileNameOption === 'string' + ? generateUploadFileName(sourceFileStr, finalUploadFileNameOption) + : generateUploadFileName(sourceFileStr); + + const cachedEntry = await getCachedUpload( + sourceFileStr, + tmsConfig.projectId, + tmsConfig.tmsUrl, + ); + + const shouldProceed = await checkFileChangeAndWarn( + sourceFileStr, + tmsConfig.projectId, + tmsConfig.tmsUrl, + finalUploadFileName, + force, + cachedEntry, + ); + + if (!shouldProceed) { + return; + } + + if (dryRun) { + simulateUpload( + tmsConfig.tmsUrl, + tmsConfig.projectId, + sourceFileStr, + finalUploadFileName, + targetLanguages, + cachedEntry, + ); + return; + } + + 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; + } +} + +/** + * 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.gray( + ` Last uploaded: ${new Date( + cachedEntry.uploadedAt, + ).toLocaleString()}`, + ), + ); + } +} + +/** + * 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); + } + + // Load config for language fallback + const config = await loadI18nConfig(); + + // 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(', ')}`, + ), + ); + } + + // 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 ${sourceFile}...`)); + console.log(chalk.gray(` Upload filename: ${uploadFileName}`)); + if (languages.length > 0) { + console.log(chalk.gray(` Target languages: ${languages.join(', ')}`)); + } + + const uploadResult = await uploadWithMemsourceCLI( + sourceFile, + projectId, + languages, + uploadFileName, + ); + + // 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; + } + } + + // 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/index.ts b/workspaces/translations/packages/cli/src/index.ts new file mode 100644 index 0000000000..f73f7c985c --- /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 => { + 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 new file mode 100644 index 0000000000..1ae900f045 --- /dev/null +++ b/workspaces/translations/packages/cli/src/lib/errors.ts @@ -0,0 +1,45 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 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 { + 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/analyzeStatus.ts b/workspaces/translations/packages/cli/src/lib/i18n/analyzeStatus.ts new file mode 100644 index 0000000000..ab0efd5055 --- /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 'node: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..10dc3c3bba --- /dev/null +++ b/workspaces/translations/packages/cli/src/lib/i18n/config.ts @@ -0,0 +1,449 @@ +/* + * 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 'node:path'; +import os from 'node:os'; +import { commandExists, safeExecSyncOrThrow } from '../utils/exec'; + +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; + }; + backstageRepoPath?: 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 directory configuration from config to merged options + */ +function mergeDirectoryConfig( + config: I18nConfig, + options: Record, + merged: MergedOptions, +): void { + 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; + } +} + +/** + * 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')) + ); +} + +/** + * Generate or retrieve TMS token from config + */ +async function getTmsToken( + config: I18nConfig, + options: Record, +): Promise { + let token = config.auth?.tms?.token; + + const shouldGenerateToken = + !token && + 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!, + ); + } + + 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; + } + + 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) { + 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 + return { ...merged, ...options }; +} + +/** + * 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 + if (!commandExists('memsource')) { + return undefined; + } + + // Generate token using memsource CLI + // 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'], + 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..536f0c3ce6 --- /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 'node: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(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 + 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 + .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/deployTranslations.ts b/workspaces/translations/packages/cli/src/lib/i18n/deployTranslations.ts new file mode 100644 index 0000000000..6b838da042 --- /dev/null +++ b/workspaces/translations/packages/cli/src/lib/i18n/deployTranslations.ts @@ -0,0 +1,1896 @@ +/* + * 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'; + +interface TranslationData { + [pluginName: string]: { + en: { + [key: string]: 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*(\w+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+(\w+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; +} + +/** + * 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 + */ +function inferRefInfo( + pluginName: string, + lang: string, + repoType: string, + translationDir?: string, +): { + refImportName: string; + refImportPath: string; + variableName: string; +} { + // Sanitize plugin name to create valid identifier + const sanitized = sanitizePluginName(pluginName); + + const refImportName = `${sanitized}TranslationRef`; + const langCapitalized = lang.charAt(0).toUpperCase() + lang.slice(1); + const variableName = `${sanitized}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' | '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 + // 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'; + } + } + + // 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'; +} + +/** + * Find plugin translation directory in workspace-based repos (rhdh-plugins, community-plugins) + */ +function findPluginInWorkspaces( + pluginName: string, + repoRoot: string, +): string | null { + const workspacesDir = path.join(repoRoot, 'workspaces'); + if (!fs.existsSync(workspacesDir)) { + 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', + cleanPluginName, + 'src', + 'translations', + ); + + if (fs.existsSync(pluginsDir)) { + return pluginsDir; + } + } + + return null; +} + +/** + * 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 { + // 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', + 'src', + 'translations', + pluginName, + ); + + const translationsDir = path.join( + repoRoot, + 'packages', + 'app', + 'src', + 'translations', + ); + + if (fs.existsSync(translationsDir)) { + fs.ensureDirSync(standardPluginDir); + return standardPluginDir; + } + + return null; +} + +/** + * 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 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 is Red Hat owned by checking package.json for "author": "Red Hat" + */ +export 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)) { + // 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 + } + } + } + } + + return false; +} + +/** + * Find plugin translation directory (supports multiple repo structures) + */ +function findPluginTranslationDir( + pluginName: string, + repoRoot: string, + repoType: string, +): string | null { + // 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'); + + // 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') { + // 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); + } + + 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 + */ +function generateTranslationFile( + pluginName: string, + lang: string, + messages: { [key: string]: string }, + refImportName: string, + refImportPath: string, + variableName: string, + translationDir?: 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 + .replaceAll(/\\/g, '\\\\') + .replaceAll(/'/g, "\\'") + .replaceAll(/\n/g, '\\n'); + return ` '${key}': '${escapedValue}',`; + }) + .join('\n'); + + // 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. + * 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. + */`; + + return `${copyrightHeader} + +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')); + + // 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) { + // 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]; + // 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 + // 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') || + // 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 + // 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 + } + } + } + + return files; +} + +/** + * Determine target file path for translation file + * Intelligently determines the filename pattern based on existing files + */ +function determineTargetFile( + pluginName: string, + lang: string, + repoType: string, + translationDir: string, +): string { + // 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`); + } + + // 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, 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`, + ); + const langFile = path.join(translationDir, `${lang}.ts`); + + if (fs.existsSync(pluginLangFile)) { + return pluginLangFile; + } + if (fs.existsSync(langFile)) { + return langFile; + } + // 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`); +} + +/** + * 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; +} + +/** + * 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 + */ +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), + }; + } + } + + // 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); +} + +/** + * 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, + repoType, + ); + + if (!translationDir) { + console.warn( + chalk.yellow(` ⚠️ Plugin "${pluginName}" not found, skipping...`), + ); + return null; + } + + console.log(chalk.gray(` 📦 Plugin: ${pluginName} → ${translationDir}`)); + + const targetFile = determineTargetFile( + pluginName, + lang, + repoType, + translationDir, + ); + const exists = fs.existsSync(targetFile); + + const refInfo = getRefInfoForPlugin( + pluginName, + lang, + repoType, + translationDir, + targetFile, + 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, + filteredTranslations, + refInfo.refImportName, + refInfo.refImportPath, + refInfo.variableName, + translationDir, + ); + + fs.writeFileSync(targetFile, content, 'utf-8'); + + const relativePath = path.relative(repoRoot, targetFile); + const keyCount = Object.keys(filteredTranslations).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, + communityPluginsRoot?: string | null, +): { updated: number; created: number } { + let updated = 0; + let created = 0; + + for (const [pluginName, pluginData] of Object.entries(data)) { + // Use the language-specific translations (fr, it, ja, etc.) + // 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; + } + + // 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, + lang, + repoType, + repoRoot, + ); + + if (result) { + if (result.updated) updated++; + if (result.created) created++; + } + } + + 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 + } + + // Helper: Check if character is whitespace (without regex) + const isWhitespace = (char: string): boolean => { + return char === ' ' || char === '\t' || char === '\n' || char === '\r'; + }; + + // 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 === '$' + ); + }; + + // Helper: Check if character is valid identifier part (without regex) + const isIdentifierPart = (char: string): boolean => { + 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, + startIndex: number, + ): string | null => { + const valueStart = textContent.indexOf('{', startIndex); + if (valueStart === -1) { + return null; + } + + 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); + } + } + } + return null; + }; + + // 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) { + console.warn( + `Content too large (${nestedContent.length} chars), skipping extraction`, + ); + return; + } + + // 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; + index: number; + endIndex: number; + }> = []; + + 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) + i = skipWhitespace(textToParse, 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) + i = skipWhitespace(textToParse, i); + + // Look for colon + if (i >= textToParse.length || textToParse[i] !== ':') { + i = keyEnd + 1; + continue; + } + i++; // Skip colon + + // Skip whitespace after colon (no regex) + i = skipWhitespace(textToParse, 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); + + // 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.length > 0 && + (matchData.value[0] === "'" || matchData.value[0] === '"') + ) { + keys.add(fullKey); + } + } + }; + + 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 + */ +export async function deployTranslations( + downloadDir: string, + repoRoot: string, +): Promise { + console.log(chalk.blue('🚀 Deploying translations...\n')); + + 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, rhdh, or backstage', + ); + } + + 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}-*-{lang}.json or ${repoType}-*-{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, 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: ${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-/, '-'); + + // 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 repos only + // Note: community-plugins JSON files are NOT copied to rhdh_root/translations/ + let communityPluginsRoot: string | null = null; + if (effectiveRepoType === 'backstage') { + 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); + + // 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 + // 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}`, + ), + ); + } + } + + const { updated, created } = processLanguageTranslations( + data, + lang, + effectiveRepoType, + repoRoot, + communityPluginsRoot, + ); + + totalUpdated += updated; + totalCreated += created; + } + + 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!`)); +} 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..adb6d5b073 --- /dev/null +++ b/workspaces/translations/packages/cli/src/lib/i18n/extractKeys.ts @@ -0,0 +1,736 @@ +/* + * 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; +} + +export interface ExtractResult { + keys: Record; + pluginId: string | null; +} + +/** + * Extract translation keys from TypeScript/JavaScript source code + */ +export function extractTranslationKeys( + content: string, + filePath: string, +): 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 + const sourceFile = ts.createSourceFile( + filePath, + content, + ts.ScriptTarget.Latest, + 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 + + /** + * 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; + }; + + /** + * 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, + ); + }; + + /** + * 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 + */ + const extractFromObjectLiteral = (node: ts.Node, prefix = ''): void => { + const objectNode = unwrapTypeAssertion(node); + + if (!ts.isObjectLiteralExpression(objectNode)) { + return; + } + + 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; + + if (!isValidKey(fullKey)) { + trackInvalidKey(fullKey); + continue; + } + + const initializer = property.initializer; + if (!initializer) { + continue; + } + + const unwrappedInitializer = unwrapTypeAssertion(initializer); + + // 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); + } + } + }; + + /** + * Extract messages from object literal property + */ + const extractMessagesFromProperty = ( + property: ts.ObjectLiteralElementLike, + propertyName: string, + ): void => { + 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; + } + + const messagesNode = unwrapTypeAssertion(property.initializer); + if (ts.isObjectLiteralExpression(messagesNode)) { + extractFromObjectLiteral(messagesNode); + } + }; + + /** + * Extract from createTranslationRef calls + * Pattern: createTranslationRef({ id: 'plugin-id', messages: { key: 'value' } }) + * Returns the plugin ID from the 'id' field and extracts messages + */ + const extractFromCreateTranslationRef = ( + node: ts.CallExpression, + ): string | null => { + const args = node.arguments; + if (args.length === 0 || !ts.isObjectLiteralExpression(args[0])) { + return null; + } + + let extractedPluginId: string | null = null; + + for (const property of args[0].properties) { + // 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 + if (property.name) { + const propName = extractPropertyKeyName(property.name); + if (propName === 'messages') { + extractMessagesFromProperty(property, 'messages'); + } + } + } + + return extractedPluginId; + }; + + /** + * 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; + } + + for (const property of args[0].properties) { + extractMessagesFromProperty(property, 'messages'); + } + }; + + /** + * 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 + */ + 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; + } + + 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; + }; + + /** + * 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' + ); + }; + + /** + * Extract from i18n.t() method calls + */ + const extractFromI18nT = (node: ts.CallExpression): void => { + if (!isI18nTCall(node)) { + return; + } + extractFromTranslationCall(node.arguments); + }; + + /** + * 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; + } + + 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 + ); + }; + + /** + * 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); + 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 (isCallExpressionWithName(node, 't')) { + extractFromTFunction(node); + } else if (isI18nTCall(node)) { + extractFromI18nT(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)) { + handleJsxNode(node); + } + + // Recursively visit child nodes + ts.forEachChild(node, visit); + }; + + visit(sourceFile); + + // 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 + console.warn( + `⚠️ Warning: AST parsing failed for ${filePath}, falling back to regex: ${error}`, + ); + return { keys: extractKeysWithRegex(content), pluginId: null }; + } + + 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 + */ +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 + // 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 + /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, + ]; + + for (const pattern of patterns) { + 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; + // 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; + } + } + } + + 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..fa0a5149d5 --- /dev/null +++ b/workspaces/translations/packages/cli/src/lib/i18n/formatReport.ts @@ -0,0 +1,223 @@ +/* + * 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}`); + } +} + +/** + * Add header section to report + */ +function addHeader(lines: string[]): void { + lines.push(chalk.blue('📊 Translation Status Report')); + lines.push(chalk.gray('═'.repeat(50))); +} + +/** + * 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)}%`); +} + +/** + * Add language breakdown section to report + */ +function addLanguageBreakdown( + lines: string[], + status: TranslationStatus, +): void { + if (status.languages.length === 0) { + return; + } + + 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 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) { + 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`)); + } + } +} + +/** + * 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, + ) / 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'); +} + +/** + * 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..dac6958f6a --- /dev/null +++ b/workspaces/translations/packages/cli/src/lib/i18n/generateFiles.ts @@ -0,0 +1,181 @@ +/* + * 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 'node: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 { + // 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 = keys[0]; + 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 + .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)) { + // 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)) { + 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(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 + 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 + .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 new file mode 100644 index 0000000000..3d4bea3512 --- /dev/null +++ b/workspaces/translations/packages/cli/src/lib/i18n/loadFile.ts @@ -0,0 +1,200 @@ +/* + * 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 'node:path'; + +import fs from 'fs-extra'; + +import { unescapePoString } from '../utils/translationUtils'; + +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}`); + } +} + +/** + * 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 + */ +export 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 ')) { + const result = processMsgIdLine( + trimmed, + currentKey, + currentValue, + data, + ); + currentKey = result.key; + currentValue = result.value; + inMsgId = result.inMsgId; + inMsgStr = result.inMsgStr; + } else if (trimmed.startsWith('msgstr ')) { + const result = processMsgStrLine(trimmed); + currentValue = result.value; + inMsgId = result.inMsgId; + inMsgStr = result.inMsgStr; + } else if (trimmed.startsWith('"') && (inMsgId || inMsgStr)) { + const result = processContinuationLine( + trimmed, + currentKey, + currentValue, + inMsgId, + inMsgStr, + ); + currentKey = result.key; + currentValue = result.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}`); + } +} 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..ec078d2b64 --- /dev/null +++ b/workspaces/translations/packages/cli/src/lib/i18n/mergeFiles.ts @@ -0,0 +1,240 @@ +/* + * 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 { escapePoString } from '../utils/translationUtils'; +import { loadPoFile } from './loadFile'; + +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 + ); +} + +/** + * Load existing translation file + */ +async function loadExistingFile( + existingPath: string, + format: string, +): Promise { + try { + switch (format.toLowerCase()) { + case 'json': + return await loadJsonFile(existingPath); + case 'po': + return await loadPoFile(existingPath); + default: + throw new Error(`Unsupported format: ${format}`); + } + } catch (error) { + console.warn( + `Warning: Could not load existing file ${existingPath}: ${error}`, + ); + return {}; + } +} + +/** + * 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; + } + + // 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 { + // New plugin + mergedData[pluginName] = pluginData; + } + } + + return mergedData; +} + +/** + * 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 + */ +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); + } +} + +/** + * 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 }); +} + +/** + * Save PO translation file + */ +async function savePoFile( + data: TranslationData, + filePath: string, +): Promise { + // PO file header + 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 + 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'); +} 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..0c77577b38 --- /dev/null +++ b/workspaces/translations/packages/cli/src/lib/i18n/saveFile.ts @@ -0,0 +1,94 @@ +/* + * 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 'node:path'; + +import fs from 'fs-extra'; + +import { escapePoString } from '../utils/translationUtils'; + +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(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 + 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'); +} 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..690372eb7b --- /dev/null +++ b/workspaces/translations/packages/cli/src/lib/i18n/tmsClient.ts @@ -0,0 +1,232 @@ +/* + * 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 readonly client: AxiosInstance; + private readonly 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 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 domainRegex = /^(https?:\/\/[^/]+)/; + const domainMatch = domainRegex.exec(normalizedUrl); + 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; + } + + /** + * 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..e4c80be5d7 --- /dev/null +++ b/workspaces/translations/packages/cli/src/lib/i18n/uploadCache.ts @@ -0,0 +1,194 @@ +/* + * 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 'node:crypto'; +import path from 'node: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.replaceAll(/[^a-zA-Z0-9]/g, '_'); + // 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); + 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..4e1c1a0d03 --- /dev/null +++ b/workspaces/translations/packages/cli/src/lib/i18n/validateData.ts @@ -0,0 +1,161 @@ +/* + * 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 + // 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 + // 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`, + ); + } + + // 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]) => + /[\u2018\u2019]/.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..4e60523f01 --- /dev/null +++ b/workspaces/translations/packages/cli/src/lib/i18n/validateFile.ts @@ -0,0 +1,299 @@ +/* + * 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 'node: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 file content (UTF-8 and null bytes) + */ +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 { + data = JSON.parse(content) as Record; + } catch (parseError) { + throw new Error( + `JSON parse error: ${ + parseError instanceof Error ? parseError.message : 'Unknown error' + }`, + ); + } + + 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}`, + ); + } + + 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`); + } + + 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 TypeError(`Plugin "${pluginName}".en must be an object`); + } + + for (const [key, value] of Object.entries(enData)) { + validateTranslationValue(value, `${pluginName}.en.${key}`); + totalKeys++; + } + } + + return totalKeys; +} + +/** + * Validate flat structure and count keys + */ +function validateFlatStructure(data: Record): number { + const translations = data.translations || data; + + if (typeof translations !== 'object' || translations === null) { + throw new TypeError('Translations must be an object'); + } + + let totalKeys = 0; + for (const [key, value] of Object.entries(translations)) { + validateTranslationValue(value, key); + totalKeys++; + } + + return totalKeys; +} + +/** + * 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; +} + +/** + * Count keys in flat structure + */ +function countFlatKeys(data: Record): number { + const translations = data.translations || data; + return Object.keys(translations).length; +} + +/** + * 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`, + ); + } +} + +/** + * Validate JSON translation file + */ +async function validateJsonFile(filePath: string): Promise { + try { + const content = await fs.readFile(filePath, 'utf-8'); + validateFileContent(content); + + const data = parseJsonContent(content); + const totalKeys = isNestedStructure(data) + ? validateNestedStructure(data) + : validateFlatStructure(data); + + validateRoundTrip(data, totalKeys); + + 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..635613bdeb --- /dev/null +++ b/workspaces/translations/packages/cli/src/lib/paths.ts @@ -0,0 +1,77 @@ +/* + * 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 '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: findProjectRoot(), + resolveOwn: (relativePath: string) => { + // Use project root as base + return path.resolve(findProjectRoot(), relativePath); + }, +}; 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..4f2ff39f6b --- /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 'node: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..37ba97578a --- /dev/null +++ b/workspaces/translations/packages/cli/src/lib/utils/translationUtils.ts @@ -0,0 +1,76 @@ +/* + * 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; +} + +/** + * 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/src/lib/version.ts b/workspaces/translations/packages/cli/src/lib/version.ts new file mode 100644 index 0000000000..1f23c405c2 --- /dev/null +++ b/workspaces/translations/packages/cli/src/lib/version.ts @@ -0,0 +1,52 @@ +/* + * 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 'node:path'; + +import fs from 'fs-extra'; + +function findVersion(): string { + try { + // Try to find package.json in multiple possible locations + // Use process.cwd() and known package structure to avoid import.meta.url issues + const possiblePaths = [ + // 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) { + 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/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/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/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..f7594acc57 --- /dev/null +++ b/workspaces/translations/packages/cli/test/generate.test.ts @@ -0,0 +1,98 @@ +/* + * 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 'node: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'); + // 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 --sprint s9999 --source-dir ${fixture.path} --output-dir ${outputDir}`, + ); + + expect(result.exitCode).toBe(0); + + // 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'); + + runCLI( + `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); + + // 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'); + + runCLI( + `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 + 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..0d05b1384d --- /dev/null +++ b/workspaces/translations/packages/cli/test/test-helpers.ts @@ -0,0 +1,162 @@ +/* + * 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 { spawnSync } 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 + * Uses spawnSync with separate command and args to prevent command injection + */ +export function runCLI( + command: string, + cwd?: string, +): { + stdout: string; + stderr: string; + exitCode: number; +} { + try { + const binPath = path.join(process.cwd(), 'bin', 'translations-cli'); + // 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', + }); + + 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() || '', + 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/test/workflow-verification.ts b/workspaces/translations/packages/cli/test/workflow-verification.ts new file mode 100755 index 0000000000..cec3f2f078 --- /dev/null +++ b/workspaces/translations/packages/cli/test/workflow-verification.ts @@ -0,0 +1,741 @@ +#!/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'); + +/** + * 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) + */ +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 + // Only exact matches are allowed to prevent subdirectory injection + 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 exact matches to system directories + // This prevents subdirectory injection attacks + const pathDirs = currentPath.split(path.delimiter); + const safePathDirs = pathDirs.filter(dir => { + if (!dir || dir.trim() === '') return false; + const normalizedDir = normalizePathForComparison(dir); + return systemPaths.some(systemPath => { + const normalizedSystemPath = normalizePathForComparison(systemPath); + // Only allow exact matches to prevent subdirectory injection + return normalizedDir === normalizedSystemPath; + }); + }); + + // If no safe paths found, use minimal safe defaults that exist + 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; +} + +/** + * 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, + 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 + 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; + 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, '')) || []; + + // 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 { + 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 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-plugins-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 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 + // 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')) { + // 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}`); + } + } + } +} + +// 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...')); + // 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 + // Pass safePath to avoid recreating the environment + const yarnPath = findCommandInSafePath('yarn', safePath); + 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, + }); + 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); 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/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/'], + }, + }, +}); 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({ diff --git a/workspaces/translations/yarn.lock b/workspaces/translations/yarn.lock index 58ad456773..1fdbc85e5a 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: @@ -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,20 @@ __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" + 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 +4901,20 @@ __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" + 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 +4922,20 @@ __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" + 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 +4943,20 @@ __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" + 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 +4964,20 @@ __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" + 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 +4985,20 @@ __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" + 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 +5006,20 @@ __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" + 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 +5027,20 @@ __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" + 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 +5048,20 @@ __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" + 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 +5069,20 @@ __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" + 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 +5090,20 @@ __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" + 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 +5111,20 @@ __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" + 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 +5132,20 @@ __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" + 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 +5153,20 @@ __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" + 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 +5174,20 @@ __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" + 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 +5195,20 @@ __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" + 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" @@ -4985,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" @@ -4992,6 +5230,20 @@ __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" + 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" @@ -4999,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" @@ -5006,6 +5265,20 @@ __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" + 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" @@ -5013,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" @@ -5020,6 +5300,20 @@ __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" + 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 +5321,20 @@ __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" + 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 +5342,20 @@ __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" + 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 +5363,20 @@ __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" + 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" @@ -5048,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" @@ -10425,6 +10768,27 @@ __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 + tsx: ^4.21.0 + 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 +10964,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 +13575,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 +13822,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 +13880,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 +14561,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 +14785,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 +15433,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 +15599,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 +16371,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 +16564,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 +16666,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 +17224,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 +18244,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 +19215,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" @@ -18799,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" @@ -19232,6 +19906,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 +19994,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 +20818,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 +20911,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 +20930,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 +21053,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 +21123,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" @@ -20427,12 +21141,12 @@ __metadata: languageName: node linkType: hard -"get-tsconfig@npm:^4.10.0": - version: 4.10.1 - resolution: "get-tsconfig@npm:4.10.1" +"get-tsconfig@npm:^4.10.0, 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: 22925debda6bd0992171a44ee79a22c32642063ba79534372c4d744e0c9154abe2c031659da0fb86bc9e73fc56a3b76b053ea5d24ca3ac3da43d2e6f7d1c3c33 + checksum: b3cfa1316dd8842e038f6a3dc02ae87d9f3a227f14b79ac4b1c81bf6fc75de4dfc3355c4117612e183f5147dad49c8132841c7fdd7a4508531d820a9b90acc51 languageName: node linkType: hard @@ -20553,7 +21267,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 +22192,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 +23064,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 +23908,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 +24792,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 +25081,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 +25192,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 +26111,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 +26393,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 +27163,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 +27379,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 +27703,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 +28069,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 +28138,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 +28421,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 +28927,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 +30889,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 +30958,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 +31526,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 +31540,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 +31918,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" @@ -31141,6 +31990,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" @@ -31440,6 +32296,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 +32333,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 +32977,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 +32994,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 +33325,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: @@ -32510,6 +33403,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" @@ -32558,6 +33467,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 +33687,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 +34373,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 +34851,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 +35216,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"