mirror of
https://github.com/bitwarden/browser
synced 2026-02-06 11:43:51 +00:00
Support mapping
This commit is contained in:
@@ -49,28 +49,39 @@
|
||||
|
||||
- [ ] 4. Implement template migration system
|
||||
|
||||
- [ ] 4.1 Create i18n pipe detection and parsing
|
||||
- [x] 4.1 Create i18n pipe detection and parsing
|
||||
|
||||
- Parse Angular templates to find | i18n pipe usage
|
||||
- Extract translation keys and parameters
|
||||
- Identify complex cases like nested expressions and pluralization
|
||||
- _Requirements: 3.1, 3.3_
|
||||
|
||||
- [ ] 4.2 Implement i18n attribute transformation
|
||||
- [x] 4.2 Implement i18n attribute transformation
|
||||
|
||||
- Transform | i18n pipes to i18n attributes with proper IDs
|
||||
- Handle parameter interpolation and ICU expressions
|
||||
- Generate proper i18n descriptions and meanings
|
||||
- Write unit tests for template transformation
|
||||
- Write unit tests for template transformation that verify actual output content
|
||||
- Test that transformTemplate produces correct HTML with i18n attributes
|
||||
- Validate that transformed templates maintain proper structure and syntax
|
||||
- _Requirements: 3.1, 3.2, 3.3_
|
||||
|
||||
- [ ] 4.3 Create automated template migration tool
|
||||
- [x] 4.3 Create automated template migration tool
|
||||
|
||||
- Build CLI tool to process HTML template files
|
||||
- Add validation for transformation accuracy
|
||||
- Generate before/after comparison reports
|
||||
- Test tool on sample template files
|
||||
- _Requirements: 3.1, 3.2_
|
||||
|
||||
- [x] 4.4 Create translation lookup system for accurate transformations
|
||||
- Combine all applications' en/messages.json files into a single lookup file
|
||||
- Create translation lookup service that maps keys to actual translated strings
|
||||
- Update template transformer to use real translation values instead of keys
|
||||
- Preserve original translation keys as i18n IDs while using actual text content
|
||||
- Write unit tests for translation lookup and enhanced transformations
|
||||
- _Requirements: 3.1, 3.2, 3.3_
|
||||
|
||||
- [ ] 5. Create runtime locale management service
|
||||
|
||||
- [ ] 5.1 Implement RuntimeLocaleService interface
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
{
|
||||
"bitwarden": {
|
||||
"message": "Bitwarden"
|
||||
},
|
||||
|
||||
@@ -8,11 +8,21 @@
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"cli": "ts-node typescript/cli.ts",
|
||||
"template-cli": "ts-node templates/cli.ts",
|
||||
"translation-cli": "ts-node shared/translation-cli.ts",
|
||||
"sample-test": "ts-node typescript/sample-test.ts",
|
||||
"analyze": "ts-node typescript/cli.ts analyze",
|
||||
"migrate": "ts-node typescript/cli.ts migrate",
|
||||
"validate": "ts-node typescript/cli.ts validate",
|
||||
"rollback": "ts-node typescript/cli.ts rollback"
|
||||
"rollback": "ts-node typescript/cli.ts rollback",
|
||||
"template-analyze": "ts-node templates/cli.ts analyze",
|
||||
"template-migrate": "ts-node templates/cli.ts migrate",
|
||||
"template-validate": "ts-node templates/cli.ts validate",
|
||||
"template-compare": "ts-node templates/cli.ts compare",
|
||||
"translations-combine": "ts-node shared/translation-cli.ts combine",
|
||||
"translations-validate": "ts-node shared/translation-cli.ts validate",
|
||||
"translations-search": "ts-node shared/translation-cli.ts search",
|
||||
"translations-stats": "ts-node shared/translation-cli.ts stats"
|
||||
},
|
||||
"bin": {
|
||||
"i18n-migrate": "./typescript/cli.ts"
|
||||
|
||||
236
scripts/migration/i18n/shared/translation-cli.ts
Normal file
236
scripts/migration/i18n/shared/translation-cli.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
#!/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 { TranslationCombiner } from "./translation-combiner";
|
||||
import { TranslationLookup } from "./translation-lookup";
|
||||
|
||||
const program = new Command();
|
||||
|
||||
program
|
||||
.name("translation-combiner")
|
||||
.description("CLI tool for combining translation files from all applications")
|
||||
.version("1.0.0");
|
||||
|
||||
program
|
||||
.command("combine")
|
||||
.description("Combine all application translation files into a single lookup file")
|
||||
.option(
|
||||
"-o, --output <path>",
|
||||
"Output file for combined translations",
|
||||
"./combined-translations.json",
|
||||
)
|
||||
.option("-r, --report <path>", "Output file for combination report")
|
||||
.option("-v, --verbose", "Enable verbose logging")
|
||||
.action(async (options) => {
|
||||
try {
|
||||
console.log(chalk.blue("🔄 Combining translation files..."));
|
||||
|
||||
const combiner = new TranslationCombiner();
|
||||
const result = combiner.combineTranslations();
|
||||
|
||||
// Save combined translations
|
||||
combiner.saveCombinedTranslations(result, options.output);
|
||||
console.log(chalk.green(`✅ Combined translations saved to: ${options.output}`));
|
||||
|
||||
// Generate and save report
|
||||
const report = combiner.generateCombinationReport(result);
|
||||
|
||||
if (options.report) {
|
||||
fs.writeFileSync(options.report, report);
|
||||
console.log(chalk.green(`📊 Combination report saved to: ${options.report}`));
|
||||
} else if (options.verbose) {
|
||||
console.log(report);
|
||||
}
|
||||
|
||||
// Display summary
|
||||
console.log(chalk.blue("\n📈 Summary:"));
|
||||
console.log(` Total unique keys: ${result.totalKeys}`);
|
||||
console.log(` Source applications: ${result.sources.length}`);
|
||||
console.log(` Conflicts found: ${result.conflicts.length}`);
|
||||
|
||||
if (result.conflicts.length > 0) {
|
||||
console.log(
|
||||
chalk.yellow(`\n⚠️ Found ${result.conflicts.length} conflicts between applications`),
|
||||
);
|
||||
if (!options.verbose) {
|
||||
console.log(chalk.gray("Use --verbose or --report to see conflict details"));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(chalk.red("❌ Failed to combine translations:"), error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
program
|
||||
.command("validate")
|
||||
.description("Validate translation keys against template usage")
|
||||
.option(
|
||||
"-c, --combined <path>",
|
||||
"Path to combined translations file",
|
||||
"./combined-translations.json",
|
||||
)
|
||||
.option("-p, --pattern <pattern>", "Glob pattern for template files", "**/*.html")
|
||||
.option("-o, --output <path>", "Output file for validation report")
|
||||
.option("-v, --verbose", "Enable verbose logging")
|
||||
.action(async (options) => {
|
||||
try {
|
||||
console.log(chalk.blue("🔍 Validating translation keys..."));
|
||||
|
||||
if (!fs.existsSync(options.combined)) {
|
||||
console.error(chalk.red(`❌ Combined translations file not found: ${options.combined}`));
|
||||
console.log(
|
||||
chalk.gray("Run 'combine' command first to generate the combined translations file"),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const lookup = new TranslationLookup();
|
||||
await lookup.loadTranslations(options.combined);
|
||||
|
||||
// Find template files (simplified - in real implementation would use proper glob)
|
||||
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`));
|
||||
|
||||
// This would require the enhanced transformer
|
||||
// For now, just show stats
|
||||
const stats = lookup.getStats();
|
||||
console.log(chalk.blue("\n📊 Translation Statistics:"));
|
||||
console.log(` Total translations loaded: ${stats.totalKeys}`);
|
||||
console.log(` Lookup service ready: ${stats.isLoaded ? "Yes" : "No"}`);
|
||||
} catch (error) {
|
||||
console.error(chalk.red("❌ Validation failed:"), error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
program
|
||||
.command("search")
|
||||
.description("Search for translation keys or values")
|
||||
.argument("<query>", "Search query")
|
||||
.option(
|
||||
"-c, --combined <path>",
|
||||
"Path to combined translations file",
|
||||
"./combined-translations.json",
|
||||
)
|
||||
.option("-l, --limit <number>", "Maximum number of results", "10")
|
||||
.option("-v, --verbose", "Enable verbose logging")
|
||||
.action(async (query, options) => {
|
||||
try {
|
||||
if (!fs.existsSync(options.combined)) {
|
||||
console.error(chalk.red(`❌ Combined translations file not found: ${options.combined}`));
|
||||
console.log(
|
||||
chalk.gray("Run 'combine' command first to generate the combined translations file"),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const lookup = new TranslationLookup();
|
||||
await lookup.loadTranslations(options.combined);
|
||||
|
||||
console.log(chalk.blue(`🔍 Searching for: "${query}"`));
|
||||
|
||||
const results = lookup.search(query);
|
||||
const limit = parseInt(options.limit);
|
||||
const displayResults = results.slice(0, limit);
|
||||
|
||||
if (displayResults.length === 0) {
|
||||
console.log(chalk.yellow("No results found"));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
chalk.green(
|
||||
`\n📋 Found ${results.length} results (showing top ${displayResults.length}):\n`,
|
||||
),
|
||||
);
|
||||
|
||||
displayResults.forEach((result, index) => {
|
||||
console.log(`${index + 1}. ${chalk.cyan(result.key)} (relevance: ${result.relevance})`);
|
||||
console.log(` "${result.message}"`);
|
||||
console.log();
|
||||
});
|
||||
|
||||
if (results.length > limit) {
|
||||
console.log(chalk.gray(`... and ${results.length - limit} more results`));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(chalk.red("❌ Search failed:"), error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
program
|
||||
.command("stats")
|
||||
.description("Show statistics about combined translations")
|
||||
.option(
|
||||
"-c, --combined <path>",
|
||||
"Path to combined translations file",
|
||||
"./combined-translations.json",
|
||||
)
|
||||
.action(async (options) => {
|
||||
try {
|
||||
if (!fs.existsSync(options.combined)) {
|
||||
console.error(chalk.red(`❌ Combined translations file not found: ${options.combined}`));
|
||||
console.log(
|
||||
chalk.gray("Run 'combine' command first to generate the combined translations file"),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(options.combined, "utf-8");
|
||||
const data = JSON.parse(content);
|
||||
|
||||
console.log(chalk.blue("📊 Translation Statistics\n"));
|
||||
|
||||
if (data.metadata) {
|
||||
console.log(`Generated: ${new Date(data.metadata.generatedAt).toLocaleString()}`);
|
||||
console.log(`Total keys: ${data.metadata.totalKeys}`);
|
||||
console.log(`Conflicts: ${data.metadata.conflictCount}`);
|
||||
console.log(`Sources: ${data.metadata.sources.length}\n`);
|
||||
|
||||
console.log(chalk.blue("📱 Source Applications:"));
|
||||
data.metadata.sources.forEach((source: any) => {
|
||||
console.log(` ${source.app}: ${source.keyCount} keys`);
|
||||
});
|
||||
} else {
|
||||
const keys = Object.keys(data.translations || data);
|
||||
console.log(`Total keys: ${keys.length}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(chalk.red("❌ Failed to show stats:"), error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Simple file finder (would be replaced with proper glob in real implementation)
|
||||
function findTemplateFiles(pattern: string): string[] {
|
||||
// This is a simplified implementation
|
||||
// In practice, you'd use the same logic as in the template CLI
|
||||
return [];
|
||||
}
|
||||
|
||||
// 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();
|
||||
256
scripts/migration/i18n/shared/translation-combiner.spec.ts
Normal file
256
scripts/migration/i18n/shared/translation-combiner.spec.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
|
||||
import { TranslationCombiner } from "./translation-combiner";
|
||||
|
||||
describe("TranslationCombiner", () => {
|
||||
let combiner: TranslationCombiner;
|
||||
let testDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
testDir = path.join(__dirname, "test-translations");
|
||||
combiner = new TranslationCombiner(testDir);
|
||||
|
||||
// Create test directory structure
|
||||
if (fs.existsSync(testDir)) {
|
||||
fs.rmSync(testDir, { recursive: true });
|
||||
}
|
||||
fs.mkdirSync(testDir, { recursive: true });
|
||||
|
||||
// Create mock translation files
|
||||
createMockTranslationFiles();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (fs.existsSync(testDir)) {
|
||||
fs.rmSync(testDir, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
function createMockTranslationFiles() {
|
||||
// Browser translations
|
||||
const browserDir = path.join(testDir, "apps/browser/src/_locales/en");
|
||||
fs.mkdirSync(browserDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(browserDir, "messages.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
appName: { message: "Bitwarden" },
|
||||
login: { message: "Log in" },
|
||||
password: { message: "Password" },
|
||||
browserSpecific: { message: "Browser Extension" },
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
// Desktop translations
|
||||
const desktopDir = path.join(testDir, "apps/desktop/src/locales/en");
|
||||
fs.mkdirSync(desktopDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(desktopDir, "messages.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
appName: { message: "Bitwarden" }, // Same as browser
|
||||
login: { message: "Sign in" }, // Different from browser (conflict)
|
||||
vault: { message: "Vault" },
|
||||
desktopSpecific: { message: "Desktop Application" },
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
// Web translations
|
||||
const webDir = path.join(testDir, "apps/web/src/locales/en");
|
||||
fs.mkdirSync(webDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(webDir, "messages.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
dashboard: { message: "Dashboard" },
|
||||
settings: { message: "Settings" },
|
||||
webSpecific: { message: "Web Vault" },
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
// CLI translations
|
||||
const cliDir = path.join(testDir, "apps/cli/src/locales/en");
|
||||
fs.mkdirSync(cliDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(cliDir, "messages.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
version: { message: "Version" },
|
||||
help: { message: "Help" },
|
||||
cliSpecific: { message: "Command Line Interface" },
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
describe("combineTranslations", () => {
|
||||
it("should combine translations from all applications", () => {
|
||||
const result = combiner.combineTranslations();
|
||||
|
||||
expect(result.totalKeys).toBeGreaterThan(0);
|
||||
expect(result.sources).toHaveLength(4); // browser, desktop, web, cli
|
||||
expect(result.translations).toHaveProperty("appName");
|
||||
expect(result.translations).toHaveProperty("browserSpecific");
|
||||
expect(result.translations).toHaveProperty("desktopSpecific");
|
||||
expect(result.translations).toHaveProperty("webSpecific");
|
||||
expect(result.translations).toHaveProperty("cliSpecific");
|
||||
});
|
||||
|
||||
it("should detect conflicts between applications", () => {
|
||||
const result = combiner.combineTranslations();
|
||||
|
||||
expect(result.conflicts.length).toBeGreaterThan(0);
|
||||
|
||||
// Should detect the login conflict between browser and desktop
|
||||
const loginConflict = result.conflicts.find((c) => c.key === "login");
|
||||
expect(loginConflict).toBeDefined();
|
||||
expect(loginConflict?.values).toContain("Log in");
|
||||
expect(loginConflict?.values).toContain("Sign in");
|
||||
});
|
||||
|
||||
it("should preserve first occurrence for conflicting keys", () => {
|
||||
const result = combiner.combineTranslations();
|
||||
|
||||
// Browser is processed first, so its value should be preserved
|
||||
expect(result.translations.appName.message).toBe("Bitwarden");
|
||||
expect(result.translations.login.message).toBe("Log in"); // Browser version
|
||||
});
|
||||
|
||||
it("should track source information", () => {
|
||||
const result = combiner.combineTranslations();
|
||||
|
||||
const browserSource = result.sources.find((s) => s.app === "browser");
|
||||
const desktopSource = result.sources.find((s) => s.app === "desktop");
|
||||
const webSource = result.sources.find((s) => s.app === "web");
|
||||
const cliSource = result.sources.find((s) => s.app === "cli");
|
||||
|
||||
expect(browserSource).toBeDefined();
|
||||
expect(desktopSource).toBeDefined();
|
||||
expect(webSource).toBeDefined();
|
||||
expect(cliSource).toBeDefined();
|
||||
|
||||
expect(browserSource?.keyCount).toBe(4);
|
||||
expect(desktopSource?.keyCount).toBe(4);
|
||||
expect(webSource?.keyCount).toBe(3);
|
||||
expect(cliSource?.keyCount).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("saveCombinedTranslations", () => {
|
||||
it("should save combined translations with metadata", () => {
|
||||
const result = combiner.combineTranslations();
|
||||
const outputPath = path.join(testDir, "combined.json");
|
||||
|
||||
combiner.saveCombinedTranslations(result, outputPath);
|
||||
|
||||
expect(fs.existsSync(outputPath)).toBe(true);
|
||||
|
||||
const saved = JSON.parse(fs.readFileSync(outputPath, "utf-8"));
|
||||
expect(saved).toHaveProperty("metadata");
|
||||
expect(saved).toHaveProperty("translations");
|
||||
expect(saved.metadata).toHaveProperty("generatedAt");
|
||||
expect(saved.metadata).toHaveProperty("sources");
|
||||
expect(saved.metadata).toHaveProperty("totalKeys");
|
||||
expect(saved.metadata).toHaveProperty("conflictCount");
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateCombinationReport", () => {
|
||||
it("should generate a comprehensive report", () => {
|
||||
const result = combiner.combineTranslations();
|
||||
const report = combiner.generateCombinationReport(result);
|
||||
|
||||
expect(report).toContain("Translation Combination Report");
|
||||
expect(report).toContain("Summary");
|
||||
expect(report).toContain("Sources");
|
||||
expect(report).toContain("Key Distribution");
|
||||
|
||||
if (result.conflicts.length > 0) {
|
||||
expect(report).toContain("Conflicts");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("utility methods", () => {
|
||||
it("should get translation message for existing key", () => {
|
||||
const result = combiner.combineTranslations();
|
||||
const message = combiner.getTranslationMessage(result.translations, "appName");
|
||||
|
||||
expect(message).toBe("Bitwarden");
|
||||
});
|
||||
|
||||
it("should return null for non-existing key", () => {
|
||||
const result = combiner.combineTranslations();
|
||||
const message = combiner.getTranslationMessage(result.translations, "nonExistentKey");
|
||||
|
||||
expect(message).toBeNull();
|
||||
});
|
||||
|
||||
it("should check if translation exists", () => {
|
||||
const result = combiner.combineTranslations();
|
||||
|
||||
expect(combiner.hasTranslation(result.translations, "appName")).toBe(true);
|
||||
expect(combiner.hasTranslation(result.translations, "nonExistentKey")).toBe(false);
|
||||
});
|
||||
|
||||
it("should get all keys sorted", () => {
|
||||
const result = combiner.combineTranslations();
|
||||
const keys = combiner.getAllKeys(result.translations);
|
||||
|
||||
expect(keys).toBeInstanceOf(Array);
|
||||
expect(keys.length).toBe(result.totalKeys);
|
||||
|
||||
// Should be sorted
|
||||
const sortedKeys = [...keys].sort();
|
||||
expect(keys).toEqual(sortedKeys);
|
||||
});
|
||||
|
||||
it("should search keys and messages", () => {
|
||||
const result = combiner.combineTranslations();
|
||||
const searchResults = combiner.searchKeys(result.translations, "app");
|
||||
|
||||
expect(searchResults).toBeInstanceOf(Array);
|
||||
expect(searchResults.length).toBeGreaterThan(0);
|
||||
expect(searchResults).toContain("appName");
|
||||
});
|
||||
});
|
||||
|
||||
describe("error handling", () => {
|
||||
it("should handle missing translation files gracefully", () => {
|
||||
const emptyCombiner = new TranslationCombiner("/non/existent/path");
|
||||
const result = emptyCombiner.combineTranslations();
|
||||
|
||||
expect(result.totalKeys).toBe(0);
|
||||
expect(result.sources).toHaveLength(0);
|
||||
expect(result.conflicts).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should handle malformed JSON files", () => {
|
||||
// Overwrite one of the existing files with malformed JSON
|
||||
const badPath = path.join(testDir, "apps/browser/src/_locales/en/messages.json");
|
||||
fs.writeFileSync(badPath, "{ invalid json }");
|
||||
|
||||
// Should not throw, but should log error
|
||||
const consoleSpy = jest.spyOn(console, "error").mockImplementation();
|
||||
|
||||
const result = combiner.combineTranslations();
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(consoleSpy).toHaveBeenCalled();
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
250
scripts/migration/i18n/shared/translation-combiner.ts
Normal file
250
scripts/migration/i18n/shared/translation-combiner.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
/* eslint-disable no-console */
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
|
||||
/**
|
||||
* Interface for a translation entry
|
||||
*/
|
||||
export interface TranslationEntry {
|
||||
message: string;
|
||||
description?: string;
|
||||
placeholders?: Record<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for combined translations
|
||||
*/
|
||||
export interface CombinedTranslations {
|
||||
[key: string]: TranslationEntry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for translation source information
|
||||
*/
|
||||
export interface TranslationSource {
|
||||
app: string;
|
||||
filePath: string;
|
||||
keyCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of combining translations
|
||||
*/
|
||||
export interface CombineResult {
|
||||
translations: CombinedTranslations;
|
||||
sources: TranslationSource[];
|
||||
conflicts: Array<{
|
||||
key: string;
|
||||
sources: string[];
|
||||
values: string[];
|
||||
}>;
|
||||
totalKeys: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service for combining translation files from multiple applications
|
||||
*/
|
||||
export class TranslationCombiner {
|
||||
private readonly appPaths = [
|
||||
"apps/browser/src/_locales/en/messages.json",
|
||||
"apps/desktop/src/locales/en/messages.json",
|
||||
"apps/web/src/locales/en/messages.json",
|
||||
"apps/cli/src/locales/en/messages.json",
|
||||
];
|
||||
|
||||
constructor(private rootPath: string = process.cwd()) {
|
||||
// If we're in the migration directory, go up to the project root
|
||||
if (this.rootPath.endsWith("scripts/migration/i18n")) {
|
||||
this.rootPath = path.join(this.rootPath, "../../..");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Combine all English translation files into a single lookup
|
||||
*/
|
||||
combineTranslations(): CombineResult {
|
||||
const combined: CombinedTranslations = {};
|
||||
const sources: TranslationSource[] = [];
|
||||
const conflicts: Array<{
|
||||
key: string;
|
||||
sources: string[];
|
||||
values: string[];
|
||||
}> = [];
|
||||
|
||||
for (const appPath of this.appPaths) {
|
||||
const fullPath = path.join(this.rootPath, appPath);
|
||||
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
console.warn(`Translation file not found: ${fullPath}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(fullPath, "utf-8");
|
||||
const translations = JSON.parse(content) as Record<string, TranslationEntry>;
|
||||
const appName = this.extractAppName(appPath);
|
||||
|
||||
let keyCount = 0;
|
||||
|
||||
for (const [key, entry] of Object.entries(translations)) {
|
||||
keyCount++;
|
||||
|
||||
if (combined[key]) {
|
||||
// Handle conflicts
|
||||
const existingMessage = combined[key].message;
|
||||
const newMessage = entry.message;
|
||||
|
||||
if (existingMessage !== newMessage) {
|
||||
const existingConflict = conflicts.find((c) => c.key === key);
|
||||
if (existingConflict) {
|
||||
if (!existingConflict.sources.includes(appName)) {
|
||||
existingConflict.sources.push(appName);
|
||||
existingConflict.values.push(newMessage);
|
||||
}
|
||||
} else {
|
||||
conflicts.push({
|
||||
key,
|
||||
sources: [this.findSourceForKey(key, sources), appName],
|
||||
values: [existingMessage, newMessage],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Keep the first occurrence (or could implement priority logic)
|
||||
continue;
|
||||
}
|
||||
|
||||
combined[key] = entry;
|
||||
}
|
||||
|
||||
sources.push({
|
||||
app: appName,
|
||||
filePath: fullPath,
|
||||
keyCount,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Error reading translation file ${fullPath}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
translations: combined,
|
||||
sources,
|
||||
conflicts,
|
||||
totalKeys: Object.keys(combined).length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Save combined translations to a file
|
||||
*/
|
||||
saveCombinedTranslations(result: CombineResult, outputPath: string): void {
|
||||
const outputData = {
|
||||
metadata: {
|
||||
generatedAt: new Date().toISOString(),
|
||||
sources: result.sources,
|
||||
totalKeys: result.totalKeys,
|
||||
conflictCount: result.conflicts.length,
|
||||
},
|
||||
translations: result.translations,
|
||||
};
|
||||
|
||||
fs.writeFileSync(outputPath, JSON.stringify(outputData, null, 2));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a report of the combination process
|
||||
*/
|
||||
generateCombinationReport(result: CombineResult): string {
|
||||
let report = `# Translation Combination Report\n\n`;
|
||||
|
||||
report += `## Summary\n`;
|
||||
report += `- **Total unique keys**: ${result.totalKeys}\n`;
|
||||
report += `- **Source applications**: ${result.sources.length}\n`;
|
||||
report += `- **Conflicts found**: ${result.conflicts.length}\n\n`;
|
||||
|
||||
report += `## Sources\n`;
|
||||
result.sources.forEach((source) => {
|
||||
report += `- **${source.app}**: ${source.keyCount} keys\n`;
|
||||
report += ` - Path: \`${source.filePath}\`\n`;
|
||||
});
|
||||
report += `\n`;
|
||||
|
||||
if (result.conflicts.length > 0) {
|
||||
report += `## Conflicts\n`;
|
||||
report += `The following keys have different values across applications:\n\n`;
|
||||
|
||||
result.conflicts.forEach((conflict) => {
|
||||
report += `### \`${conflict.key}\`\n`;
|
||||
conflict.sources.forEach((source, index) => {
|
||||
report += `- **${source}**: "${conflict.values[index]}"\n`;
|
||||
});
|
||||
report += `\n`;
|
||||
});
|
||||
}
|
||||
|
||||
report += `## Key Distribution\n`;
|
||||
const keysByApp = result.sources
|
||||
.map((s) => ({ app: s.app, count: s.keyCount }))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
|
||||
keysByApp.forEach((item) => {
|
||||
const percentage = ((item.count / result.totalKeys) * 100).toFixed(1);
|
||||
report += `- **${item.app}**: ${item.count} keys (${percentage}% of total)\n`;
|
||||
});
|
||||
|
||||
return report;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract app name from file path
|
||||
*/
|
||||
private extractAppName(filePath: string): string {
|
||||
const parts = filePath.split("/");
|
||||
const appIndex = parts.findIndex((part) => part === "apps");
|
||||
return appIndex !== -1 && appIndex + 1 < parts.length ? parts[appIndex + 1] : "unknown";
|
||||
}
|
||||
|
||||
/**
|
||||
* Find which source contains a specific key
|
||||
*/
|
||||
private findSourceForKey(key: string, sources: TranslationSource[]): string {
|
||||
// This is a simplified approach - in a real implementation,
|
||||
// we'd need to track which source each key came from
|
||||
return sources.length > 0 ? sources[sources.length - 1].app : "unknown";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get translation message for a key
|
||||
*/
|
||||
getTranslationMessage(translations: CombinedTranslations, key: string): string | null {
|
||||
const entry = translations[key];
|
||||
return entry ? entry.message : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a key exists in the combined translations
|
||||
*/
|
||||
hasTranslation(translations: CombinedTranslations, key: string): boolean {
|
||||
return key in translations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available translation keys
|
||||
*/
|
||||
getAllKeys(translations: CombinedTranslations): string[] {
|
||||
return Object.keys(translations).sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for keys containing a specific text
|
||||
*/
|
||||
searchKeys(translations: CombinedTranslations, searchText: string): string[] {
|
||||
const lowerSearch = searchText.toLowerCase();
|
||||
return Object.keys(translations).filter(
|
||||
(key) =>
|
||||
key.toLowerCase().includes(lowerSearch) ||
|
||||
translations[key].message.toLowerCase().includes(lowerSearch),
|
||||
);
|
||||
}
|
||||
}
|
||||
230
scripts/migration/i18n/shared/translation-lookup.ts
Normal file
230
scripts/migration/i18n/shared/translation-lookup.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
import * as fs from "fs";
|
||||
|
||||
import { CombinedTranslations, TranslationCombiner } from "./translation-combiner";
|
||||
|
||||
/**
|
||||
* Service for looking up translations during template migration
|
||||
*/
|
||||
export class TranslationLookup {
|
||||
private translations: CombinedTranslations = {};
|
||||
private combiner: TranslationCombiner;
|
||||
private isLoaded = false;
|
||||
|
||||
constructor(private rootPath: string = process.cwd()) {
|
||||
this.combiner = new TranslationCombiner(rootPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load translations from combined file or generate them
|
||||
*/
|
||||
async loadTranslations(combinedFilePath?: string): Promise<void> {
|
||||
if (combinedFilePath && fs.existsSync(combinedFilePath)) {
|
||||
// Load from existing combined file
|
||||
const content = fs.readFileSync(combinedFilePath, "utf-8");
|
||||
const data = JSON.parse(content);
|
||||
this.translations = data.translations || data; // Handle both formats
|
||||
} else {
|
||||
// Generate combined translations
|
||||
const result = this.combiner.combineTranslations();
|
||||
this.translations = result.translations;
|
||||
|
||||
// Optionally save the combined file
|
||||
if (combinedFilePath) {
|
||||
this.combiner.saveCombinedTranslations(result, combinedFilePath);
|
||||
}
|
||||
}
|
||||
|
||||
this.isLoaded = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the translated message for a key
|
||||
*/
|
||||
getTranslation(key: string): string | null {
|
||||
if (!this.isLoaded) {
|
||||
throw new Error("Translations not loaded. Call loadTranslations() first.");
|
||||
}
|
||||
|
||||
const entry = this.translations[key];
|
||||
return entry ? entry.message : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get translation with fallback to key if not found
|
||||
*/
|
||||
getTranslationOrKey(key: string): string {
|
||||
const translation = this.getTranslation(key);
|
||||
return translation || key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a translation exists for a key
|
||||
*/
|
||||
hasTranslation(key: string): boolean {
|
||||
if (!this.isLoaded) {
|
||||
return false;
|
||||
}
|
||||
return key in this.translations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available translation keys
|
||||
*/
|
||||
getAllKeys(): string[] {
|
||||
if (!this.isLoaded) {
|
||||
return [];
|
||||
}
|
||||
return Object.keys(this.translations).sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get translation statistics
|
||||
*/
|
||||
getStats(): { totalKeys: number; loadedKeys: number; isLoaded: boolean } {
|
||||
return {
|
||||
totalKeys: Object.keys(this.translations).length,
|
||||
loadedKeys: Object.keys(this.translations).length,
|
||||
isLoaded: this.isLoaded,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for keys or translations containing text
|
||||
*/
|
||||
search(searchText: string): Array<{ key: string; message: string; relevance: number }> {
|
||||
if (!this.isLoaded) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const lowerSearch = searchText.toLowerCase();
|
||||
const results: Array<{ key: string; message: string; relevance: number }> = [];
|
||||
|
||||
for (const [key, entry] of Object.entries(this.translations)) {
|
||||
let relevance = 0;
|
||||
const lowerKey = key.toLowerCase();
|
||||
const lowerMessage = entry.message.toLowerCase();
|
||||
|
||||
// Exact key match
|
||||
if (lowerKey === lowerSearch) {
|
||||
relevance = 100;
|
||||
}
|
||||
// Key starts with search
|
||||
else if (lowerKey.startsWith(lowerSearch)) {
|
||||
relevance = 80;
|
||||
}
|
||||
// Key contains search
|
||||
else if (lowerKey.includes(lowerSearch)) {
|
||||
relevance = 60;
|
||||
}
|
||||
// Message starts with search
|
||||
else if (lowerMessage.startsWith(lowerSearch)) {
|
||||
relevance = 40;
|
||||
}
|
||||
// Message contains search
|
||||
else if (lowerMessage.includes(lowerSearch)) {
|
||||
relevance = 20;
|
||||
}
|
||||
|
||||
if (relevance > 0) {
|
||||
results.push({
|
||||
key,
|
||||
message: entry.message,
|
||||
relevance,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results.sort((a, b) => b.relevance - a.relevance);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that required keys exist
|
||||
*/
|
||||
validateKeys(requiredKeys: string[]): { missing: string[]; found: string[] } {
|
||||
if (!this.isLoaded) {
|
||||
return { missing: requiredKeys, found: [] };
|
||||
}
|
||||
|
||||
const missing: string[] = [];
|
||||
const found: string[] = [];
|
||||
|
||||
for (const key of requiredKeys) {
|
||||
if (this.hasTranslation(key)) {
|
||||
found.push(key);
|
||||
} else {
|
||||
missing.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
return { missing, found };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get suggestions for a missing key based on similarity
|
||||
*/
|
||||
getSuggestions(
|
||||
key: string,
|
||||
maxSuggestions = 5,
|
||||
): Array<{ key: string; message: string; similarity: number }> {
|
||||
if (!this.isLoaded) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const suggestions: Array<{ key: string; message: string; similarity: number }> = [];
|
||||
const lowerKey = key.toLowerCase();
|
||||
|
||||
for (const [existingKey, entry] of Object.entries(this.translations)) {
|
||||
const similarity = this.calculateSimilarity(lowerKey, existingKey.toLowerCase());
|
||||
|
||||
if (similarity > 0.3) {
|
||||
// Only include reasonably similar keys
|
||||
suggestions.push({
|
||||
key: existingKey,
|
||||
message: entry.message,
|
||||
similarity,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return suggestions.sort((a, b) => b.similarity - a.similarity).slice(0, maxSuggestions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate string similarity using Levenshtein distance
|
||||
*/
|
||||
private calculateSimilarity(str1: string, str2: string): number {
|
||||
const matrix: number[][] = [];
|
||||
const len1 = str1.length;
|
||||
const len2 = str2.length;
|
||||
|
||||
if (len1 === 0) {
|
||||
return len2 === 0 ? 1 : 0;
|
||||
}
|
||||
if (len2 === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Initialize matrix
|
||||
for (let i = 0; i <= len1; i++) {
|
||||
matrix[i] = [i];
|
||||
}
|
||||
for (let j = 0; j <= len2; j++) {
|
||||
matrix[0][j] = j;
|
||||
}
|
||||
|
||||
// Fill matrix
|
||||
for (let i = 1; i <= len1; i++) {
|
||||
for (let j = 1; j <= len2; j++) {
|
||||
const cost = str1[i - 1] === str2[j - 1] ? 0 : 1;
|
||||
matrix[i][j] = Math.min(
|
||||
matrix[i - 1][j] + 1, // deletion
|
||||
matrix[i][j - 1] + 1, // insertion
|
||||
matrix[i - 1][j - 1] + cost, // substitution
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const maxLen = Math.max(len1, len2);
|
||||
return (maxLen - matrix[len1][len2]) / maxLen;
|
||||
}
|
||||
}
|
||||
268
scripts/migration/i18n/templates/README.md
Normal file
268
scripts/migration/i18n/templates/README.md
Normal 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
|
||||
115
scripts/migration/i18n/templates/cli.spec.ts
Normal file
115
scripts/migration/i18n/templates/cli.spec.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
450
scripts/migration/i18n/templates/cli.ts
Normal file
450
scripts/migration/i18n/templates/cli.ts
Normal 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();
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<div>
|
||||
<h1>Static Title</h1>
|
||||
<p>This template has no i18n pipes</p>
|
||||
<button>Static Button</button>
|
||||
</div>
|
||||
@@ -0,0 +1,5 @@
|
||||
<div class="container">
|
||||
<h1>{{ 'appTitle' | i18n }}</h1>
|
||||
<p>{{ 'welcomeMessage' | i18n }}</p>
|
||||
<button [title]="'clickMe' | i18n">{{ 'buttonText' | i18n }}</button>
|
||||
</div>
|
||||
@@ -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>
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
@@ -0,0 +1,5 @@
|
||||
<div>
|
||||
<h1>Static Title</h1>
|
||||
<p>This template has no i18n pipes</p>
|
||||
<button>Static Button</button>
|
||||
</div>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -4,7 +4,7 @@
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
|
||||
import chalk from "chalk";
|
||||
import * as chalk from "chalk";
|
||||
import { Command } from "commander";
|
||||
|
||||
import { MigrationConfig } from "../shared/types";
|
||||
|
||||
Reference in New Issue
Block a user