1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-17 18:09:17 +00:00

Support mapping

This commit is contained in:
Hinton
2025-07-28 15:57:28 +02:00
parent 77a89c2e31
commit 2de08377ee
25 changed files with 2594 additions and 9 deletions

View File

@@ -0,0 +1,268 @@
# Template Migration Tool
This tool migrates Angular templates from using i18n pipes (`{{ 'key' | i18n }}`) to Angular's standard i18n attributes (`<span i18n="@@key">text</span>`).
## Features
- **Analysis**: Analyze current i18n pipe usage in templates
- **Migration**: Transform i18n pipes to i18n attributes
- **Validation**: Check for remaining i18n pipe usage after migration
- **Comparison**: Generate before/after comparison reports
- **Backup**: Create backup files before migration
- **Dry-run**: Preview changes without applying them
## Usage
### Prerequisites
Make sure you're in the `scripts/migration/i18n` directory:
```bash
cd scripts/migration/i18n
```
### Commands
#### Analyze Templates
Analyze current i18n pipe usage in templates:
```bash
npm run template-analyze -- --pattern "**/*.html" --verbose
```
Options:
- `--pattern <pattern>`: Glob pattern for template files (default: `**/*.html`)
- `--output <path>`: Save analysis report to file
- `--verbose`: Enable verbose logging
#### Migrate Templates
Migrate templates from i18n pipes to i18n attributes:
```bash
npm run template-migrate -- --pattern "**/*.html" --dry-run --verbose
```
Options:
- `--pattern <pattern>`: Glob pattern for template files (default: `**/*.html`)
- `--file <path>`: Migrate specific file only
- `--dry-run`: Preview changes without applying them
- `--output <path>`: Output directory for migration reports
- `--backup`: Create backup files before migration
- `--verbose`: Enable verbose logging
#### Validate Migration
Check for remaining i18n pipe usage after migration:
```bash
npm run template-validate -- --pattern "**/*.html" --verbose
```
Options:
- `--pattern <pattern>`: Glob pattern for template files (default: `**/*.html`)
- `--verbose`: Enable verbose logging
#### Compare Templates
Generate before/after comparison for a single template:
```bash
npm run template-compare -- --file "path/to/template.html"
```
Options:
- `--file <path>`: Template file to compare (required)
- `--output <path>`: Save comparison report to file
- `--verbose`: Enable verbose logging
#### Rollback Migration
Restore files from backup:
```bash
npm run template-cli -- rollback --backup-dir "./migration-reports/backups"
```
Options:
- `--backup-dir <path>`: Path to backup directory (default: `./migration-reports/backups`)
- `--verbose`: Enable verbose logging
## Examples
### Basic Migration Workflow
1. **Analyze current usage**:
```bash
npm run template-analyze -- --pattern "src/**/*.html" --output analysis-report.md
```
2. **Preview migration (dry-run)**:
```bash
npm run template-migrate -- --pattern "src/**/*.html" --dry-run --verbose
```
3. **Perform migration with backup**:
```bash
npm run template-migrate -- --pattern "src/**/*.html" --backup --output ./migration-reports
```
4. **Validate results**:
```bash
npm run template-validate -- --pattern "src/**/*.html"
```
### Single File Migration
1. **Compare a single file**:
```bash
npm run template-compare -- --file "src/app/component.html" --output comparison.md
```
2. **Migrate a single file**:
```bash
npm run template-migrate -- --file "src/app/component.html" --backup
```
## Transformation Examples
### Interpolation
**Before:**
```html
<h1>{{ 'welcome' | i18n }}</h1>
```
**After:**
```html
<h1><span i18n="@@welcome">welcome</span></h1>
```
### Attribute Binding
**Before:**
```html
<button [title]="'clickMe' | i18n">Click</button>
```
**After:**
```html
<button [title]="clickMe" i18n-title="@@click-me">Click</button>
```
### Complex Templates
**Before:**
```html
<div>
<h1>{{ 'appTitle' | i18n }}</h1>
<nav>
<a [title]="'homeLink' | i18n" href="/">{{ 'home' | i18n }}</a>
</nav>
</div>
```
**After:**
```html
<div>
<h1><span i18n="@@app-title">appTitle</span></h1>
<nav>
<a [title]="homeLink" i18n-title="@@home-link" href="/"><span i18n="@@home">home</span></a>
</nav>
</div>
```
## Key Transformations
- **Translation keys**: Converted from camelCase/snake_case to kebab-case IDs
- `camelCaseKey` → `@@camel-case-key`
- `snake_case_key` → `@@snake-case-key`
- `dotted.key.name` → `@@dotted-key-name`
- **Interpolations**: Wrapped in `<span>` elements with `i18n` attributes
- **Attribute bindings**: Converted to `i18n-{attribute}` attributes
- **Parameters**: Currently preserved as-is (may need manual review)
## Output Files
When using `--output` option, the tool generates:
- **Analysis reports**: Markdown files with usage statistics
- **Migration reports**: Detailed change logs with before/after comparisons
- **Backup files**: Original files with `.backup` extension
- **Comparison reports**: Side-by-side before/after views
## Error Handling
The tool includes comprehensive error handling:
- **File not found**: Graceful handling of missing files
- **Parse errors**: Detailed error messages for malformed templates
- **Validation failures**: Automatic rollback on transformation errors
- **Backup creation**: Automatic backup before destructive operations
## Testing
Run the CLI tests:
```bash
npm test -- templates/cli.spec.ts
```
The test suite covers:
- Analysis functionality
- Dry-run migration
- Actual file migration
- Validation of results
- Comparison report generation
- Error scenarios
## Integration
This tool is part of the larger Angular i18n migration suite. Use it in conjunction with:
- **TypeScript migrator**: For migrating `I18nService.t()` calls to `$localize`
- **Build system updates**: For configuring Angular's i18n build process
- **Translation file conversion**: For converting JSON to XLIFF format
## Troubleshooting
### Common Issues
1. **No files found**: Check your pattern and current directory
2. **Permission errors**: Ensure write permissions for target files
3. **Parse errors**: Check for malformed HTML in templates
4. **Validation failures**: Review transformation accuracy
### Debug Mode
Use `--verbose` flag for detailed logging:
```bash
npm run template-migrate -- --pattern "**/*.html" --verbose --dry-run
```
This will show:
- Files being processed
- Transformations being applied
- Validation results
- Error details

View File

@@ -0,0 +1,115 @@
import { execSync } from "child_process";
import * as fs from "fs";
import * as path from "path";
describe("Template Migration CLI", () => {
const testDir = path.join(__dirname, "test-cli");
const sampleTemplate = `<div>
<h1>{{ 'title' | i18n }}</h1>
<p>{{ 'description' | i18n }}</p>
<button [title]="'buttonTitle' | i18n">{{ 'buttonText' | i18n }}</button>
</div>`;
beforeEach(() => {
// Create test directory and sample file
if (fs.existsSync(testDir)) {
fs.rmSync(testDir, { recursive: true });
}
fs.mkdirSync(testDir, { recursive: true });
fs.writeFileSync(path.join(testDir, "test.html"), sampleTemplate);
});
afterEach(() => {
// Clean up test directory
if (fs.existsSync(testDir)) {
fs.rmSync(testDir, { recursive: true });
}
});
it("should analyze template files and generate report", () => {
const result = execSync(`npm run template-analyze -- --pattern "templates/test-cli/*.html"`, {
cwd: path.join(__dirname, ".."),
encoding: "utf-8",
});
expect(result).toContain("Template i18n Pipe Usage Analysis Report");
expect(result).toContain("Total pipe usage count: 4");
expect(result).toContain("Template files affected: 1");
expect(result).toContain("Unique translation keys: 4");
});
it("should perform dry-run migration without modifying files", () => {
const originalContent = fs.readFileSync(path.join(testDir, "test.html"), "utf-8");
const result = execSync(
`npm run template-migrate -- --pattern "templates/test-cli/*.html" --dry-run`,
{ cwd: path.join(__dirname, ".."), encoding: "utf-8" },
);
expect(result).toContain("Migration completed successfully");
expect(result).toContain("1 files processed, 1 files modified");
// File should not be modified in dry-run
const currentContent = fs.readFileSync(path.join(testDir, "test.html"), "utf-8");
expect(currentContent).toBe(originalContent);
});
it("should migrate template files and apply transformations", () => {
const result = execSync(`npm run template-migrate -- --pattern "templates/test-cli/*.html"`, {
cwd: path.join(__dirname, ".."),
encoding: "utf-8",
});
expect(result).toContain("Migration completed successfully");
// Check that file was modified
const migratedContent = fs.readFileSync(path.join(testDir, "test.html"), "utf-8");
expect(migratedContent).toContain('i18n="@@title"');
expect(migratedContent).toContain('i18n="@@description"');
expect(migratedContent).toContain('i18n-title="@@button-title"');
expect(migratedContent).toContain('i18n="@@button-text"');
expect(migratedContent).not.toContain("| i18n");
});
it("should validate migration results", () => {
// First migrate the file
execSync(`npm run template-migrate -- --pattern "templates/test-cli/*.html"`, {
cwd: path.join(__dirname, ".."),
encoding: "utf-8",
});
// Then validate
const result = execSync(`npm run template-validate -- --pattern "templates/test-cli/*.html"`, {
cwd: path.join(__dirname, ".."),
encoding: "utf-8",
});
expect(result).toContain("No remaining i18n pipe usage found");
});
it("should detect remaining i18n pipes in validation", () => {
// Don't migrate, just validate original file
try {
execSync(`npm run template-validate -- --pattern "templates/test-cli/*.html"`, {
cwd: path.join(__dirname, ".."),
encoding: "utf-8",
});
fail("Should have failed validation");
} catch (error: any) {
expect(error.stdout.toString()).toContain("Found 4 remaining i18n pipe usages");
}
});
it("should generate comparison report for a single file", () => {
const result = execSync(`npm run template-compare -- --file templates/test-cli/test.html`, {
cwd: path.join(__dirname, ".."),
encoding: "utf-8",
});
expect(result).toContain("Template Migration Comparison");
expect(result).toContain("**Changes:** 4");
expect(result).toContain("## Before");
expect(result).toContain("## After");
expect(result).toContain("## Changes");
});
});

View File

@@ -0,0 +1,450 @@
#!/usr/bin/env node
/* eslint-disable no-console */
import * as fs from "fs";
import * as path from "path";
import * as chalk from "chalk";
import { Command } from "commander";
import { MigrationConfig } from "../shared/types";
import { TemplateMigrator } from "./template-migrator";
/**
* Find template files matching a pattern
*/
function findTemplateFiles(pattern: string, rootDir: string = process.cwd()): string[] {
const files: string[] = [];
// Handle specific directory patterns like "templates/sample-templates/*.html"
if (pattern.includes("/") && pattern.includes("*")) {
const parts = pattern.split("/");
const dirParts = parts.slice(0, -1);
const filePart = parts[parts.length - 1];
const targetDir = path.join(rootDir, ...dirParts);
if (fs.existsSync(targetDir)) {
const entries = fs.readdirSync(targetDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isFile()) {
if (filePart === "*.html" && entry.name.endsWith(".html")) {
files.push(path.join(targetDir, entry.name));
} else if (filePart.includes("*")) {
const regex = new RegExp(filePart.replace(/\*/g, ".*"));
if (regex.test(entry.name)) {
files.push(path.join(targetDir, entry.name));
}
}
}
}
}
return files;
}
// Default recursive search
function walkDir(dir: string) {
if (!fs.existsSync(dir)) {
return;
}
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
// Skip common directories that shouldn't contain templates
if (!["node_modules", "dist", "coverage", ".git", ".angular"].includes(entry.name)) {
walkDir(fullPath);
}
} else if (entry.isFile()) {
// Simple pattern matching - for now just check if it ends with .html
if (pattern === "**/*.html" && entry.name.endsWith(".html")) {
files.push(fullPath);
} else if (pattern.includes("*")) {
const regex = new RegExp(pattern.replace(/\*\*/g, ".*").replace(/\*/g, "[^/]*"));
if (regex.test(fullPath)) {
files.push(fullPath);
}
}
}
}
}
walkDir(rootDir);
return files;
}
const program = new Command();
program
.name("i18n-template-migrator")
.description("CLI tool for migrating Angular templates from i18n pipes to i18n attributes")
.version("1.0.0");
program
.command("analyze")
.description("Analyze current i18n pipe usage in templates")
.option("-p, --pattern <pattern>", "Glob pattern for template files", "**/*.html")
.option("-o, --output <path>", "Output file for analysis report")
.option("-v, --verbose", "Enable verbose logging")
.action(async (options) => {
try {
const config: MigrationConfig = {
sourceRoot: process.cwd(),
tsConfigPath: "./tsconfig.json",
dryRun: true,
verbose: options.verbose || false,
};
console.log(chalk.blue("🔍 Analyzing i18n pipe usage in templates..."));
const migrator = new TemplateMigrator(config);
const templateFiles = findTemplateFiles(options.pattern);
if (templateFiles.length === 0) {
console.log(chalk.yellow("⚠️ No template files found matching pattern"));
return;
}
console.log(chalk.gray(`Found ${templateFiles.length} template files`));
const report = migrator.generateTemplateAnalysisReport(templateFiles);
if (options.output) {
fs.writeFileSync(options.output, report);
console.log(chalk.green(`✅ Analysis report saved to: ${options.output}`));
} else {
console.log(report);
}
} catch (error) {
console.error(chalk.red("❌ Analysis failed:"), error);
process.exit(1);
}
});
program
.command("migrate")
.description("Migrate template files from i18n pipes to i18n attributes")
.option("-p, --pattern <pattern>", "Glob pattern for template files", "**/*.html")
.option("-f, --file <path>", "Migrate specific file only")
.option("-d, --dry-run", "Preview changes without applying them")
.option("-o, --output <path>", "Output directory for migration reports")
.option("-v, --verbose", "Enable verbose logging")
.option("--backup", "Create backup files before migration")
.action(async (options) => {
try {
const config: MigrationConfig = {
sourceRoot: process.cwd(),
tsConfigPath: "./tsconfig.json",
dryRun: options.dryRun || false,
verbose: options.verbose || false,
};
const migrator = new TemplateMigrator(config);
let templateFiles: string[];
if (options.file) {
templateFiles = [path.resolve(options.file)];
console.log(chalk.blue(`📄 Migrating file: ${options.file}`));
} else {
templateFiles = findTemplateFiles(options.pattern);
console.log(
chalk.blue(`🚀 Starting template migration for ${templateFiles.length} files...`),
);
}
if (templateFiles.length === 0) {
console.log(chalk.yellow("⚠️ No template files found matching pattern"));
return;
}
if (options.backup && !options.dryRun) {
console.log(chalk.yellow("📦 Creating backups..."));
await createBackups(templateFiles, options.output || "./migration-reports");
}
const results = await migrator.migrateTemplates(templateFiles);
const stats = migrator.generateMigrationStats(results);
console.log(stats);
// Save detailed report
if (options.output) {
const reportDir = options.output;
if (!fs.existsSync(reportDir)) {
fs.mkdirSync(reportDir, { recursive: true });
}
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const reportPath = path.join(reportDir, `template-migration-report-${timestamp}.md`);
let detailedReport = stats + "\n\n## Detailed Changes\n\n";
results.forEach((result) => {
detailedReport += `### ${result.filePath}\n`;
if (result.success) {
if (result.changes.length > 0) {
result.changes.forEach((change) => {
detailedReport += `- ${change.description}\n`;
if (change.original) {
detailedReport += ` - **Before:** \`${change.original}\`\n`;
}
if (change.replacement) {
detailedReport += ` - **After:** \`${change.replacement}\`\n`;
}
});
} else {
detailedReport += "No changes needed\n";
}
} else {
detailedReport += "**Errors:**\n";
result.errors.forEach((error) => {
detailedReport += `- ${error}\n`;
});
}
detailedReport += "\n";
});
fs.writeFileSync(reportPath, detailedReport);
console.log(chalk.green(`📊 Detailed report saved to: ${reportPath}`));
}
const successful = results.filter((r) => r.success).length;
const failed = results.filter((r) => !r.success).length;
const withChanges = results.filter((r) => r.success && r.changes.length > 0).length;
if (failed === 0) {
console.log(
chalk.green(
`✅ Migration completed successfully! ${successful} files processed, ${withChanges} files modified.`,
),
);
} else {
console.log(
chalk.yellow(
`⚠️ Migration completed with warnings. ${successful} successful, ${failed} failed.`,
),
);
process.exit(1);
}
} catch (error) {
console.error(chalk.red("❌ Migration failed:"), error);
process.exit(1);
}
});
program
.command("validate")
.description("Validate migration results and check for remaining i18n pipes")
.option("-p, --pattern <pattern>", "Glob pattern for template files", "**/*.html")
.option("-v, --verbose", "Enable verbose logging")
.action(async (options) => {
try {
const config: MigrationConfig = {
sourceRoot: process.cwd(),
tsConfigPath: "./tsconfig.json",
dryRun: true,
verbose: options.verbose || false,
};
console.log(chalk.blue("🔍 Validating migration results..."));
const migrator = new TemplateMigrator(config);
const templateFiles = findTemplateFiles(options.pattern);
if (templateFiles.length === 0) {
console.log(chalk.yellow("⚠️ No template files found matching pattern"));
return;
}
let totalUsages = 0;
const filesWithUsages: string[] = [];
for (const filePath of templateFiles) {
const usages = migrator.analyzeTemplate(filePath);
if (usages.length > 0) {
totalUsages += usages.length;
filesWithUsages.push(filePath);
if (options.verbose) {
console.log(chalk.yellow(` ${filePath}: ${usages.length} remaining usages`));
usages.forEach((usage) => {
console.log(chalk.gray(` Line ${usage.line}: ${usage.key}`));
});
}
}
}
if (totalUsages === 0) {
console.log(chalk.green("✅ No remaining i18n pipe usage found!"));
} else {
console.log(
chalk.yellow(
`⚠️ Found ${totalUsages} remaining i18n pipe usages in ${filesWithUsages.length} files`,
),
);
if (!options.verbose) {
console.log(chalk.gray("Use --verbose to see detailed usage information"));
}
process.exit(1);
}
} catch (error) {
console.error(chalk.red("❌ Validation failed:"), error);
process.exit(1);
}
});
program
.command("rollback")
.description("Rollback migration using backup files")
.option("-b, --backup-dir <path>", "Path to backup directory", "./migration-reports/backups")
.option("-v, --verbose", "Enable verbose logging")
.action(async (options) => {
try {
console.log(chalk.blue("🔄 Rolling back template migration..."));
const backupDir = options.backup_dir;
if (!fs.existsSync(backupDir)) {
console.error(chalk.red(`❌ Backup directory not found: ${backupDir}`));
process.exit(1);
}
const backupFiles = fs.readdirSync(backupDir).filter((f) => f.endsWith(".backup"));
if (backupFiles.length === 0) {
console.error(chalk.red("❌ No backup files found"));
process.exit(1);
}
let restoredCount = 0;
for (const backupFile of backupFiles) {
const backupPath = path.join(backupDir, backupFile);
const originalPath = backupFile.replace(".backup", "");
if (fs.existsSync(originalPath)) {
fs.copyFileSync(backupPath, originalPath);
restoredCount++;
if (options.verbose) {
console.log(chalk.gray(`Restored: ${originalPath}`));
}
}
}
console.log(chalk.green(`✅ Rollback completed! ${restoredCount} files restored.`));
} catch (error) {
console.error(chalk.red("❌ Rollback failed:"), error);
process.exit(1);
}
});
program
.command("compare")
.description("Generate before/after comparison reports")
.option("-f, --file <path>", "Template file to compare")
.option("-o, --output <path>", "Output file for comparison report")
.option("-v, --verbose", "Enable verbose logging")
.action(async (options) => {
try {
if (!options.file) {
console.error(chalk.red("❌ File path is required for comparison"));
process.exit(1);
}
const filePath = path.resolve(options.file);
if (!fs.existsSync(filePath)) {
console.error(chalk.red(`❌ File not found: ${filePath}`));
process.exit(1);
}
const config: MigrationConfig = {
sourceRoot: process.cwd(),
tsConfigPath: "./tsconfig.json",
dryRun: true,
verbose: options.verbose || false,
};
console.log(chalk.blue(`🔍 Generating comparison for: ${options.file}`));
const migrator = new TemplateMigrator(config);
const originalContent = fs.readFileSync(filePath, "utf-8");
const result = await migrator.migrateTemplate(filePath);
if (!result.success) {
console.error(chalk.red("❌ Migration failed:"), result.errors);
process.exit(1);
}
// Apply changes to get transformed content
let transformedContent = originalContent;
for (const change of result.changes.reverse()) {
if (change.original && change.replacement) {
transformedContent = transformedContent.replace(change.original, change.replacement);
}
}
let report = `# Template Migration Comparison\n\n`;
report += `**File:** ${filePath}\n`;
report += `**Changes:** ${result.changes.length}\n\n`;
report += `## Before\n\`\`\`html\n${originalContent}\n\`\`\`\n\n`;
report += `## After\n\`\`\`html\n${transformedContent}\n\`\`\`\n\n`;
if (result.changes.length > 0) {
report += `## Changes\n`;
result.changes.forEach((change, index) => {
report += `### Change ${index + 1}\n`;
report += `**Description:** ${change.description}\n`;
if (change.original) {
report += `**Before:** \`${change.original}\`\n`;
}
if (change.replacement) {
report += `**After:** \`${change.replacement}\`\n`;
}
report += `\n`;
});
}
if (options.output) {
fs.writeFileSync(options.output, report);
console.log(chalk.green(`✅ Comparison report saved to: ${options.output}`));
} else {
console.log(report);
}
} catch (error) {
console.error(chalk.red("❌ Comparison failed:"), error);
process.exit(1);
}
});
async function createBackups(templateFiles: string[], outputDir: string): Promise<void> {
const backupDir = path.join(outputDir, "backups");
if (!fs.existsSync(backupDir)) {
fs.mkdirSync(backupDir, { recursive: true });
}
for (const filePath of templateFiles) {
if (fs.existsSync(filePath)) {
const backupPath = path.join(backupDir, path.basename(filePath) + ".backup");
fs.copyFileSync(filePath, backupPath);
}
}
console.log(chalk.green(`📦 Created backups for ${templateFiles.length} files`));
}
// Handle uncaught errors
process.on("uncaughtException", (error) => {
console.error(chalk.red("❌ Uncaught Exception:"), error);
process.exit(1);
});
process.on("unhandledRejection", (reason, promise) => {
console.error(chalk.red("❌ Unhandled Rejection at:"), promise, "reason:", reason);
process.exit(1);
});
program.parse();

View File

@@ -0,0 +1,245 @@
import { TranslationLookup } from "../shared/translation-lookup";
import { EnhancedTemplateTransformer } from "./enhanced-template-transformer";
describe("EnhancedTemplateTransformer", () => {
let transformer: EnhancedTemplateTransformer;
let mockTranslationLookup: jest.Mocked<TranslationLookup>;
beforeEach(() => {
// Create mock translation lookup
mockTranslationLookup = {
loadTranslations: jest.fn(),
getTranslation: jest.fn(),
getTranslationOrKey: jest.fn(),
hasTranslation: jest.fn(),
getAllKeys: jest.fn(),
getStats: jest.fn(),
search: jest.fn(),
validateKeys: jest.fn(),
getSuggestions: jest.fn(),
} as any;
transformer = new EnhancedTemplateTransformer(mockTranslationLookup);
});
describe("transformTemplate with real translations", () => {
beforeEach(() => {
// Setup mock translations
mockTranslationLookup.getTranslation.mockImplementation((key: string) => {
const translations: Record<string, string> = {
welcome: "Welcome to Bitwarden",
login: "Log in",
password: "Password",
clickMe: "Click me",
buttonText: "Submit",
appTitle: "Bitwarden Password Manager",
description: "Secure your digital life",
};
return translations[key] || null;
});
mockTranslationLookup.hasTranslation.mockImplementation((key: string) => {
return [
"welcome",
"login",
"password",
"clickMe",
"buttonText",
"appTitle",
"description",
].includes(key);
});
});
it("should transform interpolation with real translation values", () => {
const template = `<h1>{{ 'welcome' | i18n }}</h1>`;
const result = transformer.transformTemplate(template, "test.html");
expect(result.success).toBe(true);
expect(result.changes).toHaveLength(1);
// Apply the transformation
let transformedContent = template;
for (const change of result.changes.reverse()) {
if (change.original && change.replacement) {
transformedContent = transformedContent.replace(change.original, change.replacement);
}
}
expect(transformedContent).toBe(
`<h1><span i18n="@@welcome">Welcome to Bitwarden</span></h1>`,
);
});
it("should transform attribute binding with real translation values", () => {
const template = `<button [title]="'clickMe' | i18n">Click</button>`;
const result = transformer.transformTemplate(template, "test.html");
expect(result.success).toBe(true);
expect(result.changes).toHaveLength(1);
// Apply the transformation
let transformedContent = template;
for (const change of result.changes.reverse()) {
if (change.original && change.replacement) {
transformedContent = transformedContent.replace(change.original, change.replacement);
}
}
expect(transformedContent).toBe(
`<button [title]="Click me" i18n-title="@@click-me">Click</button>`,
);
});
it("should handle missing translations gracefully", () => {
const template = `<h1>{{ 'missingKey' | i18n }}</h1>`;
const result = transformer.transformTemplate(template, "test.html");
expect(result.success).toBe(true);
expect(result.changes).toHaveLength(1);
expect(result.errors.length).toBeGreaterThan(0);
expect(result.errors[0]).toContain("Translation not found for key: missingKey");
// Apply the transformation
let transformedContent = template;
for (const change of result.changes.reverse()) {
if (change.original && change.replacement) {
transformedContent = transformedContent.replace(change.original, change.replacement);
}
}
// Should fall back to using the key as display text
expect(transformedContent).toBe(`<h1><span i18n="@@missing-key">missingKey</span></h1>`);
});
it("should transform complex template with multiple translations", () => {
const template = `
<div>
<h1>{{ 'appTitle' | i18n }}</h1>
<p>{{ 'description' | i18n }}</p>
<button [title]="'clickMe' | i18n">{{ 'buttonText' | i18n }}</button>
</div>
`;
const result = transformer.transformTemplate(template, "test.html");
expect(result.success).toBe(true);
expect(result.changes.length).toBeGreaterThan(0);
// Apply the transformations
let transformedContent = template;
for (const change of result.changes.reverse()) {
if (change.original && change.replacement) {
transformedContent = transformedContent.replace(change.original, change.replacement);
}
}
expect(transformedContent).toContain(
'<span i18n="@@app-title">Bitwarden Password Manager</span>',
);
expect(transformedContent).toContain(
'<span i18n="@@description">Secure your digital life</span>',
);
expect(transformedContent).toContain('[title]="Click me" i18n-title="@@click-me"');
expect(transformedContent).toContain('<span i18n="@@button-text">Submit</span>');
});
it("should generate enhanced replacement descriptions", () => {
const template = `<h1>{{ 'welcome' | i18n }}</h1>`;
const result = transformer.transformTemplate(template, "test.html");
expect(result.success).toBe(true);
expect(result.changes).toHaveLength(1);
expect(result.changes[0].description).toContain("with real translation");
});
});
describe("generateMissingTranslationsReport", () => {
beforeEach(() => {
// Mock file system for testing
const fs = require("fs");
jest.spyOn(fs, "readFileSync").mockImplementation((filePath: any) => {
if (filePath.includes("template1.html")) {
return `<h1>{{ 'welcome' | i18n }}</h1><p>{{ 'missingKey1' | i18n }}</p>`;
}
if (filePath.includes("template2.html")) {
return `<button [title]="'login' | i18n">{{ 'missingKey2' | i18n }}</button>`;
}
return "";
});
mockTranslationLookup.hasTranslation.mockImplementation((key: string) => {
return ["welcome", "login"].includes(key);
});
mockTranslationLookup.getTranslation.mockImplementation((key: string) => {
const translations: Record<string, string> = {
welcome: "Welcome",
login: "Log in",
};
return translations[key] || null;
});
mockTranslationLookup.getSuggestions.mockImplementation((key: string) => {
if (key === "missingKey1") {
return [{ key: "welcome", message: "Welcome", similarity: 0.5 }];
}
return [];
});
});
afterEach(() => {
jest.restoreAllMocks();
});
it("should generate report of missing translations", () => {
const templateFiles = ["template1.html", "template2.html"];
const report = transformer.generateMissingTranslationsReport(templateFiles);
expect(report).toContain("Missing Translations Report");
expect(report).toContain("**Total unique keys found**: 4");
expect(report).toContain("**Keys with translations**: 2");
expect(report).toContain("**Missing translations**: 2");
expect(report).toContain("**Coverage**: 50.0%");
expect(report).toContain("Missing Translation Keys");
expect(report).toContain("Keys with Translations");
expect(report).toContain("missingKey1");
expect(report).toContain("missingKey2");
expect(report).toContain("Suggestions");
});
});
describe("initialization", () => {
it("should initialize with translation data", async () => {
await transformer.initialize("./combined-translations.json");
expect(mockTranslationLookup.loadTranslations).toHaveBeenCalledWith(
"./combined-translations.json",
);
});
it("should initialize without specific path", async () => {
await transformer.initialize();
expect(mockTranslationLookup.loadTranslations).toHaveBeenCalledWith(undefined);
});
});
describe("validation", () => {
it("should validate transformations correctly", () => {
const original = `<h1>{{ 'test' | i18n }}</h1>`;
const validTransformed = `<h1><span i18n="@@test">Test Message</span></h1>`;
const invalidTransformed = `<h1><span i18n="invalid">Test Message</span></h1>`;
expect(transformer.validateTransformation(original, validTransformed)).toBe(true);
expect(transformer.validateTransformation(original, invalidTransformed)).toBe(false);
});
});
describe("getTranslationLookup", () => {
it("should return the translation lookup service", () => {
const lookup = transformer.getTranslationLookup();
expect(lookup).toBe(mockTranslationLookup);
});
});
});

View File

@@ -0,0 +1,249 @@
import { TransformationResult, TransformationChange, I18nUsage } from "../shared/types";
import { TranslationLookup } from "../shared/translation-lookup";
import { TemplateParser } from "./template-parser";
/**
* Enhanced template transformation utilities that use real translation values
*/
export class EnhancedTemplateTransformer {
private parser: TemplateParser;
private translationLookup: TranslationLookup;
constructor(translationLookup?: TranslationLookup) {
this.parser = new TemplateParser();
this.translationLookup = translationLookup || new TranslationLookup();
}
/**
* Initialize the transformer with translation data
*/
async initialize(combinedTranslationsPath?: string): Promise<void> {
await this.translationLookup.loadTranslations(combinedTranslationsPath);
}
/**
* Find all i18n pipe usage in a template file
*/
findI18nPipeUsage(templateContent: string, filePath: string): I18nUsage[] {
return this.parser.findI18nPipeUsage(templateContent, filePath);
}
/**
* Transform i18n pipes to i18n attributes in a template using real translation values
*/
transformTemplate(templateContent: string, filePath: string): TransformationResult {
const changes: TransformationChange[] = [];
const errors: string[] = [];
const warnings: string[] = [];
try {
// Use the parser to find all i18n pipe usages via AST
const usages = this.parser.findI18nPipeUsage(templateContent, filePath);
let transformedContent = templateContent;
// Process each usage found by the AST parser (reverse order to handle replacements from end to start)
for (const usage of usages.reverse()) {
if (!usage.context) {
continue; // Skip usages without context
}
const replacement = this.generateEnhancedReplacement(usage, warnings);
transformedContent = this.replaceAtPosition(transformedContent, usage, replacement);
changes.push({
type: "replace",
location: { line: usage.line, column: usage.column },
original: usage.context,
replacement,
description: `Transformed ${usage.method} usage '${usage.key}' to i18n attribute with real translation`,
});
}
return {
success: true,
filePath,
changes,
errors: [...errors, ...warnings],
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
errors.push(`Error transforming template: ${errorMessage}`);
return {
success: false,
filePath,
changes,
errors,
};
}
}
/**
* Generate enhanced replacement text using real translation values
*/
private generateEnhancedReplacement(usage: I18nUsage, warnings: string[]): string {
const i18nId = this.generateI18nId(usage.key);
const context = usage.context || "";
// Get the real translation value
const translationValue = this.translationLookup.getTranslation(usage.key);
if (!translationValue) {
warnings.push(`Translation not found for key: ${usage.key}`);
}
// Use translation value if available, otherwise fall back to key
const displayText = translationValue || usage.key;
if (context.startsWith("{{") && context.endsWith("}}")) {
// Interpolation: {{ 'key' | i18n }} -> <span i18n="@@key">Actual Translation</span>
return `<span i18n="@@${i18nId}">${displayText}</span>`;
} else if (context.includes("[") && context.includes("]")) {
// Attribute binding: [title]="'key' | i18n" -> [title]="'Actual Translation'" i18n-title="@@key"
const attrMatch = context.match(/\[([^\]]+)\]/);
if (attrMatch) {
const attrName = attrMatch[1];
return `[${attrName}]="${displayText}" i18n-${attrName}="@@${i18nId}"`;
}
}
return context; // fallback
}
/**
* Replace usage at specific position in template content
*/
private replaceAtPosition(content: string, usage: I18nUsage, replacement: string): string {
// Find the exact position of the usage.context in the content and replace it
const context = usage.context || "";
const contextIndex = content.indexOf(context);
if (contextIndex !== -1) {
return (
content.substring(0, contextIndex) +
replacement +
content.substring(contextIndex + context.length)
);
}
return content;
}
/**
* Generate i18n ID from a translation key
*/
private generateI18nId(key: string): string {
// Convert camelCase or snake_case to kebab-case for i18n IDs
return key
.replace(/([a-z])([A-Z])/g, "$1-$2")
.replace(/_/g, "-")
.replace(/\./g, "-")
.toLowerCase();
}
/**
* Validate that a transformation is correct
*/
validateTransformation(original: string, transformed: string): boolean {
try {
// Basic validation - ensure the transformed template is still valid HTML-like
const hasMatchingBrackets = this.validateBrackets(transformed);
const hasValidI18nAttributes = this.validateI18nAttributes(transformed);
const hasNoRemainingPipes = !this.parser.hasI18nPipeUsage(transformed);
return hasMatchingBrackets && hasValidI18nAttributes && hasNoRemainingPipes;
} catch {
return false;
}
}
/**
* Validate that brackets are properly matched
*/
private validateBrackets(content: string): boolean {
const openBrackets = (content.match(/\{/g) || []).length;
const closeBrackets = (content.match(/\}/g) || []).length;
return openBrackets === closeBrackets;
}
/**
* Validate that i18n attributes are properly formatted
*/
private validateI18nAttributes(content: string): boolean {
const i18nAttrs = content.match(/i18n(-[\w-]+)?="[^"]*"/g) || [];
return i18nAttrs.every((attr) => {
const valueMatch = attr.match(/="([^"]*)"/);
return valueMatch && valueMatch[1].startsWith("@@");
});
}
/**
* Generate a report of missing translations
*/
generateMissingTranslationsReport(templateFiles: string[]): string {
const allUsages: I18nUsage[] = [];
const missingKeys = new Set<string>();
const foundKeys = new Set<string>();
// Collect all i18n usage from template files
for (const filePath of templateFiles) {
try {
const content = require("fs").readFileSync(filePath, "utf-8");
const usages = this.findI18nPipeUsage(content, filePath);
allUsages.push(...usages);
} catch (error) {
console.warn(`Could not read template file: ${filePath}`);
}
}
// Check which keys have translations
for (const usage of allUsages) {
if (this.translationLookup.hasTranslation(usage.key)) {
foundKeys.add(usage.key);
} else {
missingKeys.add(usage.key);
}
}
let report = `# Missing Translations Report\n\n`;
report += `## Summary\n`;
report += `- **Total unique keys found**: ${foundKeys.size + missingKeys.size}\n`;
report += `- **Keys with translations**: ${foundKeys.size}\n`;
report += `- **Missing translations**: ${missingKeys.size}\n`;
report += `- **Coverage**: ${((foundKeys.size / (foundKeys.size + missingKeys.size)) * 100).toFixed(1)}%\n\n`;
if (missingKeys.size > 0) {
report += `## Missing Translation Keys\n`;
const sortedMissing = Array.from(missingKeys).sort();
for (const key of sortedMissing) {
report += `- \`${key}\`\n`;
// Get suggestions for missing keys
const suggestions = this.translationLookup.getSuggestions(key, 3);
if (suggestions.length > 0) {
report += ` - Suggestions: ${suggestions.map((s) => `\`${s.key}\``).join(", ")}\n`;
}
}
report += `\n`;
}
if (foundKeys.size > 0) {
report += `## Keys with Translations\n`;
const sortedFound = Array.from(foundKeys).sort();
for (const key of sortedFound) {
const translation = this.translationLookup.getTranslation(key);
report += `- \`${key}\`: "${translation}"\n`;
}
}
return report;
}
/**
* Get translation lookup service
*/
getTranslationLookup(): TranslationLookup {
return this.translationLookup;
}
}

View File

@@ -0,0 +1,5 @@
<div>
<h1>Static Title</h1>
<p>This template has no i18n pipes</p>
<button>Static Button</button>
</div>

View File

@@ -0,0 +1,5 @@
<div class="container">
<h1>{{ 'appTitle' | i18n }}</h1>
<p>{{ 'welcomeMessage' | i18n }}</p>
<button [title]="'clickMe' | i18n">{{ 'buttonText' | i18n }}</button>
</div>

View File

@@ -0,0 +1,9 @@
<nav>
<a [title]="'homeLink' | i18n" href="/">{{ 'home' | i18n }}</a>
<a [title]="'aboutLink' | i18n" href="/about">{{ 'about' | i18n }}</a>
</nav>
<main>
<p>{{ 'itemCount' | i18n:count }}</p>
<span>{{ 'camelCaseKey' | i18n }}</span>
<div>{{ 'snake_case_key' | i18n }}</div>
</main>

View File

@@ -206,4 +206,135 @@ describe("Template Migration Tools", () => {
expect(transformedContent).toContain("After:");
});
});
describe("Template Output Validation", () => {
let transformer: TemplateTransformer;
beforeEach(() => {
transformer = new TemplateTransformer();
});
// Helper function to apply transformations to template content
function applyTransformations(template: string, changes: any[]): string {
let transformedContent = template;
// Apply changes in reverse order to handle position shifts correctly
for (const change of changes.reverse()) {
if (change.original && change.replacement) {
transformedContent = transformedContent.replace(change.original, change.replacement);
}
}
return transformedContent;
}
it("should produce correct HTML output for simple interpolation", () => {
const template = `<h1>{{ 'welcome' | i18n }}</h1>`;
const result = transformer.transformTemplate(template, "test.html");
expect(result.success).toBe(true);
expect(result.changes).toHaveLength(1);
const transformedContent = applyTransformations(template, result.changes);
expect(transformedContent).toBe(`<h1><span i18n="@@welcome">welcome</span></h1>`);
});
it("should produce correct HTML output for attribute binding", () => {
const template = `<button [title]="'clickMe' | i18n">Click</button>`;
const result = transformer.transformTemplate(template, "test.html");
expect(result.success).toBe(true);
expect(result.changes).toHaveLength(1);
const transformedContent = applyTransformations(template, result.changes);
expect(transformedContent).toBe(
`<button [title]="clickMe" i18n-title="@@click-me">Click</button>`,
);
});
it("should produce correct HTML output for multiple transformations", () => {
const template = `
<div>
<h1>{{ 'title' | i18n }}</h1>
<p>{{ 'description' | i18n }}</p>
<button [title]="'buttonTitle' | i18n">{{ 'buttonText' | i18n }}</button>
</div>
`;
const result = transformer.transformTemplate(template, "test.html");
expect(result.success).toBe(true);
expect(result.changes.length).toBeGreaterThan(0);
const transformedContent = applyTransformations(template, result.changes);
const expectedOutput = `
<div>
<h1><span i18n="@@title">title</span></h1>
<p><span i18n="@@description">description</span></p>
<button [title]="buttonTitle" i18n-title="@@button-title"><span i18n="@@button-text">buttonText</span></button>
</div>
`;
expect(transformedContent.trim()).toBe(expectedOutput.trim());
});
it("should produce correct HTML output for camelCase key conversion", () => {
const template = `{{ 'camelCaseKey' | i18n }}`;
const result = transformer.transformTemplate(template, "test.html");
expect(result.success).toBe(true);
expect(result.changes).toHaveLength(1);
const transformedContent = applyTransformations(template, result.changes);
expect(transformedContent).toBe(`<span i18n="@@camel-case-key">camelCaseKey</span>`);
});
it("should produce correct HTML output for snake_case key conversion", () => {
const template = `{{ 'snake_case_key' | i18n }}`;
const result = transformer.transformTemplate(template, "test.html");
expect(result.success).toBe(true);
expect(result.changes).toHaveLength(1);
const transformedContent = applyTransformations(template, result.changes);
expect(transformedContent).toBe(`<span i18n="@@snake-case-key">snake_case_key</span>`);
});
it("should produce correct HTML output for dotted key conversion", () => {
const template = `{{ 'dotted.key.name' | i18n }}`;
const result = transformer.transformTemplate(template, "test.html");
expect(result.success).toBe(true);
expect(result.changes).toHaveLength(1);
const transformedContent = applyTransformations(template, result.changes);
expect(transformedContent).toBe(`<span i18n="@@dotted-key-name">dotted.key.name</span>`);
});
it("should produce valid HTML that passes validation", () => {
const template = `
<div class="container">
<header>
<h1>{{ 'appTitle' | i18n }}</h1>
<nav>
<a [title]="'homeLink' | i18n" href="/">{{ 'home' | i18n }}</a>
</nav>
</header>
</div>
`;
const result = transformer.transformTemplate(template, "validation-test.html");
expect(result.success).toBe(true);
expect(result.changes.length).toBeGreaterThan(0);
const transformedContent = applyTransformations(template, result.changes);
// Verify the transformation is valid according to the transformer's own validation
expect(transformer.validateTransformation(template, transformedContent)).toBe(true);
// Verify specific output characteristics
expect(transformedContent).toContain('i18n="@@app-title"');
expect(transformedContent).toContain('i18n-title="@@home-link"');
expect(transformedContent).toContain('i18n="@@home"');
expect(transformedContent).not.toContain("| i18n");
});
});
});

View File

@@ -50,6 +50,11 @@ export class TemplateParser {
if (this.containsI18nPipe(expressionText)) {
const pipeUsage = this.extractI18nPipeUsage(expressionText);
if (pipeUsage) {
// Get the actual text from the source span instead of reconstructing it
const actualContext = node.sourceSpan.start.file.content.substring(
node.sourceSpan.start.offset,
node.sourceSpan.end.offset,
);
usages.push({
filePath,
line: node.sourceSpan.start.line + 1,
@@ -57,7 +62,7 @@ export class TemplateParser {
method: "pipe",
key: pipeUsage.key,
parameters: pipeUsage.parameters,
context: `{{ ${expressionText} }}`,
context: actualContext,
});
}
}

View File

@@ -79,7 +79,7 @@ export class TemplateTransformer {
// Interpolation: {{ 'key' | i18n }} -> <span i18n="@@key">key</span>
return `<span i18n="@@${i18nId}">${usage.key}</span>`;
} else if (context.includes("[") && context.includes("]")) {
// Attribute binding: [title]="'key' | i18n" -> [title]="'key'" i18n-title="@@key"
// Attribute binding: [title]="'key' | i18n" -> [title]="key" i18n-title="@@key"
const attrMatch = context.match(/\[([^\]]+)\]/);
if (attrMatch) {
const attrName = attrMatch[1];

View File

@@ -0,0 +1,7 @@
<div class="login-form">
<h1><span i18n="@@log-in">Log in</span></h1>
<p><span i18n="@@login-or-create-new-account">Log in or create a new account to access your secure vault.</span></p>
<input [placeholder]="Email address" i18n-placeholder="@@email-address" type="email" />
<input [placeholder]="Master password" i18n-placeholder="@@master-pass" type="password" />
<button [title]="Log in" i18n-title="@@log-in"><span i18n="@@log-in">Log in</span></button>
</div>

View File

@@ -0,0 +1,7 @@
<div class="login-form">
<h1>{{ 'logIn' | i18n }}</h1>
<p>{{ 'loginOrCreateNewAccount' | i18n }}</p>
<input [placeholder]="'emailAddress' | i18n" type="email" />
<input [placeholder]="'masterPass' | i18n" type="password" />
<button [title]="'logIn' | i18n">{{ 'logIn' | i18n }}</button>
</div>

View File

@@ -0,0 +1,77 @@
#!/usr/bin/env node
/* eslint-disable no-console */
import * as fs from "fs";
import * as chalk from "chalk";
import { EnhancedTemplateTransformer } from "./enhanced-template-transformer";
async function testEnhancedTransformer() {
console.log(chalk.blue("🧪 Testing Enhanced Template Transformer\n"));
try {
// Initialize the enhanced transformer
const transformer = new EnhancedTemplateTransformer();
await transformer.initialize("./test-combined.json");
console.log(chalk.green("✅ Initialized with combined translations"));
// Read the test template
const templatePath = "./templates/test-enhanced-sample.html";
const templateContent = fs.readFileSync(templatePath, "utf-8");
console.log(chalk.blue("\n📄 Original Template:"));
console.log(templateContent);
// Transform the template
const result = transformer.transformTemplate(templateContent, templatePath);
if (result.success) {
console.log(
chalk.green(`\n✅ Transformation successful! ${result.changes.length} changes made`),
);
// Apply the transformations
let transformedContent = templateContent;
for (const change of result.changes.reverse()) {
if (change.original && change.replacement) {
transformedContent = transformedContent.replace(change.original, change.replacement);
}
}
console.log(chalk.blue("\n📄 Transformed Template:"));
console.log(transformedContent);
console.log(chalk.blue("\n📋 Changes Made:"));
result.changes.forEach((change, index) => {
console.log(`${index + 1}. ${change.description}`);
console.log(` Before: ${chalk.red(change.original)}`);
console.log(` After: ${chalk.green(change.replacement)}`);
console.log();
});
if (result.errors.length > 0) {
console.log(chalk.yellow("\n⚠ Warnings:"));
result.errors.forEach((error) => {
console.log(` ${error}`);
});
}
// Save the transformed template
const outputPath = "./templates/test-enhanced-sample-transformed.html";
fs.writeFileSync(outputPath, transformedContent);
console.log(chalk.green(`💾 Transformed template saved to: ${outputPath}`));
} else {
console.log(chalk.red("\n❌ Transformation failed:"));
result.errors.forEach((error) => {
console.log(` ${error}`);
});
}
} catch (error) {
console.error(chalk.red("❌ Test failed:"), error);
process.exit(1);
}
}
testEnhancedTransformer();

View File

@@ -0,0 +1,5 @@
<div>
<h1>Static Title</h1>
<p>This template has no i18n pipes</p>
<button>Static Button</button>
</div>

View File

@@ -0,0 +1,5 @@
<div class="container">
<h1><span i18n="@@app-title">appTitle</span></h1>
<p><span i18n="@@welcome-message">welcomeMessage</span></p>
<button [title]="clickMe" i18n-title="@@click-me"><span i18n="@@button-text">buttonText</span></button>
</div>

View File

@@ -0,0 +1,9 @@
<nav>
<a [title]="homeLink" i18n-title="@@home-link" href="/"><span i18n="@@home">home</span></a>
<a [title]="aboutLink" i18n-title="@@about-link" href="/about"><span i18n="@@about">about</span></a>
</nav>
<main>
<p><span i18n="@@item-count">itemCount</span></p>
<span><span i18n="@@camel-case-key">camelCaseKey</span></span>
<div><span i18n="@@snake-case-key">snake_case_key</span></div>
</main>