1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-09 21:20:27 +00:00
This commit is contained in:
Hinton
2025-07-28 10:19:23 +02:00
parent 30af25cf15
commit 9d76924a6d
18 changed files with 2739 additions and 206 deletions

View File

@@ -0,0 +1,14 @@
# Angular Localize Migration Tools
This directory contains tools for migrating from the custom I18nService to Angular's @angular/localize system.
## Structure
- `typescript/` - TypeScript code transformation utilities using ts-morph
- `templates/` - Angular template transformation utilities using angular-eslint
- `shared/` - Shared utilities and types
- `tests/` - Unit tests for migration tools
## Usage
The migration tools are designed to be run as part of the overall migration process defined in the spec.

View File

@@ -0,0 +1,21 @@
const { createCjsPreset } = require("jest-preset-angular/presets");
const presetConfig = createCjsPreset({
tsconfig: "<rootDir>/tsconfig.spec.json",
astTransformers: {
before: ["<rootDir>/../../../libs/shared/es2020-transformer.ts"],
},
diagnostics: {
ignoreCodes: ["TS151001"],
},
});
module.exports = {
...presetConfig,
displayName: "i18n-migration-tools",
preset: "../../../jest.preset.js",
moduleFileExtensions: ["ts", "js", "html"],
coverageDirectory: "../../../coverage/scripts/migration/i18n",
testMatch: ["<rootDir>/tests/**/*.test.ts"],
collectCoverageFrom: ["typescript/**/*.ts", "templates/**/*.ts", "shared/**/*.ts", "!**/*.d.ts"],
};

View File

@@ -0,0 +1,38 @@
/**
* Shared types for the i18n migration tools
*/
export interface TransformationResult {
success: boolean;
filePath: string;
changes: TransformationChange[];
errors: string[];
}
export interface TransformationChange {
type: "replace" | "add" | "remove";
location: {
line: number;
column: number;
};
original?: string;
replacement?: string;
description: string;
}
export interface MigrationConfig {
sourceRoot: string;
tsConfigPath: string;
dryRun: boolean;
verbose: boolean;
}
export interface I18nUsage {
filePath: string;
line: number;
column: number;
method: "t" | "pipe";
key: string;
parameters?: string[];
context?: string;
}

View File

@@ -0,0 +1,206 @@
/* eslint-disable no-console */
import { readFileSync, writeFileSync } from "fs";
import { MigrationConfig, TransformationResult, I18nUsage } from "../shared/types";
import { TemplateTransformer } from "./template-transformer";
/**
* Main class for template migration from i18n pipes to i18n attributes
*/
export class TemplateMigrator {
private transformer: TemplateTransformer;
constructor(private config: MigrationConfig) {
this.transformer = new TemplateTransformer();
}
/**
* Analyze i18n pipe usage in a template file
*/
analyzeTemplate(filePath: string): I18nUsage[] {
try {
const templateContent = readFileSync(filePath, "utf-8");
return this.transformer.findI18nPipeUsage(templateContent, filePath);
} catch (error) {
if (this.config.verbose) {
console.error(`Error reading template file ${filePath}:`, error);
}
return [];
}
}
/**
* Migrate a single template file
*/
async migrateTemplate(filePath: string): Promise<TransformationResult> {
try {
const templateContent = readFileSync(filePath, "utf-8");
const result = this.transformer.transformTemplate(templateContent, filePath);
if (result.success && result.changes.length > 0) {
// Get the transformed content by applying all changes
const transformedContent = this.applyChangesToContent(templateContent, result.changes);
// Validate the transformation
if (this.transformer.validateTransformation(templateContent, transformedContent)) {
if (!this.config.dryRun) {
writeFileSync(filePath, transformedContent, "utf-8");
}
} else {
result.success = false;
result.errors.push("Transformation validation failed");
}
}
return result;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return {
success: false,
filePath,
changes: [],
errors: [`Error processing template file: ${errorMessage}`],
};
}
}
/**
* Migrate multiple template files
*/
async migrateTemplates(filePaths: string[]): Promise<TransformationResult[]> {
const results: TransformationResult[] = [];
for (const filePath of filePaths) {
if (this.config.verbose) {
console.log(`Processing template: ${filePath}`);
}
const result = await this.migrateTemplate(filePath);
results.push(result);
if (!result.success) {
console.error(`Failed to process ${filePath}:`, result.errors);
}
}
return results;
}
/**
* Generate analysis report for template usage
*/
generateTemplateAnalysisReport(filePaths: string[]): string {
const allUsages: I18nUsage[] = [];
for (const filePath of filePaths) {
const usages = this.analyzeTemplate(filePath);
allUsages.push(...usages);
}
const fileCount = new Set(allUsages.map((u) => u.filePath)).size;
const keyCount = new Set(allUsages.map((u) => u.key)).size;
let report = `# Template i18n Pipe Usage Analysis Report\n\n`;
report += `## Summary\n`;
report += `- Total pipe usage count: ${allUsages.length}\n`;
report += `- Template files affected: ${fileCount}\n`;
report += `- Unique translation keys: ${keyCount}\n\n`;
report += `## Usage by File\n`;
const usagesByFile = allUsages.reduce(
(acc, usage) => {
if (!acc[usage.filePath]) {
acc[usage.filePath] = [];
}
acc[usage.filePath].push(usage);
return acc;
},
{} as Record<string, I18nUsage[]>,
);
Object.entries(usagesByFile).forEach(([filePath, fileUsages]) => {
report += `\n### ${filePath}\n`;
fileUsages.forEach((usage) => {
report += `- Line ${usage.line}: \`${usage.key}\``;
if (usage.parameters) {
report += ` (with parameters: ${usage.parameters.join(", ")})`;
}
if (usage.context) {
report += ` - Context: \`${usage.context.trim()}\``;
}
report += `\n`;
});
});
report += `\n## Most Common Keys\n`;
const keyCounts = allUsages.reduce(
(acc, usage) => {
acc[usage.key] = (acc[usage.key] || 0) + 1;
return acc;
},
{} as Record<string, number>,
);
Object.entries(keyCounts)
.sort(([, a], [, b]) => b - a)
.slice(0, 10)
.forEach(([key, count]) => {
report += `- \`${key}\`: ${count} usage(s)\n`;
});
return report;
}
/**
* Generate migration statistics
*/
generateMigrationStats(results: TransformationResult[]): string {
const successful = results.filter((r) => r.success).length;
const failed = results.filter((r) => !r.success).length;
const totalChanges = results.reduce((sum, r) => sum + r.changes.length, 0);
let stats = `# Template Migration Statistics\n\n`;
stats += `- Templates processed: ${results.length}\n`;
stats += `- Successful: ${successful}\n`;
stats += `- Failed: ${failed}\n`;
stats += `- Total transformations: ${totalChanges}\n\n`;
if (failed > 0) {
stats += `## Failed Templates\n`;
results
.filter((r) => !r.success)
.forEach((result) => {
stats += `- ${result.filePath}\n`;
result.errors.forEach((error) => {
stats += ` - ${error}\n`;
});
});
}
return stats;
}
/**
* Apply transformation changes to content
*/
private applyChangesToContent(content: string, changes: TransformationResult["changes"]): string {
let transformedContent = content;
// Sort changes by position (descending) to avoid position shifts
const sortedChanges = changes.sort((a, b) => {
if (a.location.line !== b.location.line) {
return b.location.line - a.location.line;
}
return b.location.column - a.location.column;
});
for (const change of sortedChanges) {
if (change.type === "replace" && change.original && change.replacement) {
transformedContent = transformedContent.replace(change.original, change.replacement);
}
}
return transformedContent;
}
}

View File

@@ -0,0 +1,371 @@
/* eslint-disable no-console */
import { parseTemplate, TmplAstNode, TmplAstElement, TmplAstBoundText } from "@angular/compiler";
import { I18nUsage } from "../shared/types";
/**
* Utility class for parsing Angular templates using Angular compiler
*/
export class TemplateParser {
/**
* Find all i18n pipe usage in a template
*/
findI18nPipeUsage(templateContent: string, filePath: string): I18nUsage[] {
const usages: I18nUsage[] = [];
try {
// Parse template using Angular compiler
const parseResult = parseTemplate(templateContent, filePath);
if (parseResult.nodes) {
this.traverseNodes(parseResult.nodes, usages, filePath);
}
// Also use regex as fallback for edge cases
this.findWithRegex(templateContent, filePath, usages);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.warn(`Warning: Could not parse template ${filePath}:`, errorMessage);
// Fallback to regex parsing
this.findWithRegex(templateContent, filePath, usages);
}
return usages;
}
/**
* Recursively traverse template AST nodes to find i18n pipe usage
*/
private traverseNodes(nodes: TmplAstNode[], usages: I18nUsage[], filePath: string): void {
for (const node of nodes) {
this.processNode(node, usages, filePath);
// Recursively process child nodes
if ("children" in node && Array.isArray(node.children)) {
this.traverseNodes(node.children, usages, filePath);
}
}
}
/**
* Process a single template AST node to find i18n pipe usage
*/
private processNode(node: TmplAstNode, usages: I18nUsage[], filePath: string): void {
// Handle bound text nodes (interpolations)
if (this.isBoundText(node)) {
const expression = node.value;
if (expression && "source" in expression) {
const expressionText = (expression.source as string) || "";
if (this.containsI18nPipe(expressionText)) {
const pipeUsage = this.extractI18nPipeUsage(expressionText);
if (pipeUsage) {
usages.push({
filePath,
line: node.sourceSpan.start.line + 1,
column: node.sourceSpan.start.col,
method: "pipe",
key: pipeUsage.key,
parameters: pipeUsage.parameters,
context: `{{ ${expressionText} }}`,
});
}
}
}
}
// Handle element nodes with attributes
if (this.isElement(node)) {
// Check bound attributes (property bindinxgs)
for (const input of node.inputs || []) {
if (input.value && "source" in input.value) {
const inputValue = (input.value.source as string) || "";
if (this.containsI18nPipe(inputValue)) {
const pipeUsage = this.extractI18nPipeUsage(inputValue);
if (pipeUsage) {
usages.push({
filePath,
line: input.sourceSpan.start.line + 1,
column: input.sourceSpan.start.col,
method: "pipe",
key: pipeUsage.key,
parameters: pipeUsage.parameters,
context: `[${input.name}]="${inputValue}"`,
});
}
}
}
}
// Check regular attributes
for (const attr of node.attributes || []) {
if (attr.value && this.containsI18nPipe(attr.value)) {
const pipeUsage = this.extractI18nPipeUsage(attr.value);
if (pipeUsage) {
usages.push({
filePath,
line: attr.sourceSpan.start.line + 1,
column: attr.sourceSpan.start.col,
method: "pipe",
key: pipeUsage.key,
parameters: pipeUsage.parameters,
context: `${attr.name}="${attr.value}"`,
});
}
}
}
}
}
/**
* Fallback regex-based parsing for edge cases
*/
private findWithRegex(templateContent: string, filePath: string, usages: I18nUsage[]): void {
// Find interpolation usage: {{ 'key' | i18n }}
this.findInterpolationUsage(templateContent, filePath, usages);
// Find attribute usage: [attr]="'key' | i18n"
this.findAttributeUsage(templateContent, filePath, usages);
}
/**
* Find i18n pipe usage in interpolations {{ }}
*/
private findInterpolationUsage(
templateContent: string,
filePath: string,
usages: I18nUsage[],
): void {
// Pattern to match {{ 'key' | i18n }} or {{ "key" | i18n }} with optional parameters
const interpolationPattern = /\{\{\s*['"`]([^'"`]+)['"`]\s*\|\s*i18n(?::([^}]+))?\s*\}\}/g;
let match;
while ((match = interpolationPattern.exec(templateContent)) !== null) {
const key = match[1];
const paramString = match[2];
const parameters = paramString
? paramString
.split(":")
.map((p) => p.trim())
.filter((p) => p)
: undefined;
// Check if we already found this usage via AST parsing
const position = this.getPositionInfo(templateContent, match.index);
const alreadyFound = usages.some(
(usage) =>
usage.line === position.line && usage.column === position.column && usage.key === key,
);
if (!alreadyFound) {
usages.push({
filePath,
line: position.line,
column: position.column,
method: "pipe",
key,
parameters,
context: match[0],
});
}
}
// Also handle variable interpolations: {{ variable | i18n }}
const variableInterpolationPattern =
/\{\{\s*([a-zA-Z_$][a-zA-Z0-9_$]*(?:\.[a-zA-Z_$][a-zA-Z0-9_$]*)*)\s*\|\s*i18n(?::([^}]+))?\s*\}\}/g;
while ((match = variableInterpolationPattern.exec(templateContent)) !== null) {
const key = match[1];
const paramString = match[2];
const parameters = paramString
? paramString
.split(":")
.map((p) => p.trim())
.filter((p) => p)
: undefined;
const position = this.getPositionInfo(templateContent, match.index);
const alreadyFound = usages.some(
(usage) =>
usage.line === position.line && usage.column === position.column && usage.key === key,
);
if (!alreadyFound) {
usages.push({
filePath,
line: position.line,
column: position.column,
method: "pipe",
key,
parameters,
context: match[0],
});
}
}
}
/**
* Find i18n pipe usage in attributes
*/
private findAttributeUsage(templateContent: string, filePath: string, usages: I18nUsage[]): void {
// Pattern to match [attr]="'key' | i18n" or attr="{{ 'key' | i18n }}"
const attributePattern = /(\[?[\w-]+\]?)\s*=\s*["']([^"']*\|\s*i18n[^"']*)["']/g;
let match;
while ((match = attributePattern.exec(templateContent)) !== null) {
const attrValue = match[2];
// Extract the key from the pipe expression
const keyMatch = attrValue.match(/['"`]([^'"`]+)['"`]\s*\|\s*i18n(?::([^"'|]+))?/);
if (keyMatch) {
const key = keyMatch[1];
const paramString = keyMatch[2];
const parameters = paramString
? paramString
.split(":")
.map((p) => p.trim())
.filter((p) => p)
: undefined;
const position = this.getPositionInfo(templateContent, match.index);
const alreadyFound = usages.some(
(usage) =>
usage.line === position.line && usage.column === position.column && usage.key === key,
);
if (!alreadyFound) {
usages.push({
filePath,
line: position.line,
column: position.column,
method: "pipe",
key,
parameters,
context: match[0],
});
}
}
// Also handle variable attributes
const variableMatch = attrValue.match(
/([a-zA-Z_$][a-zA-Z0-9_$]*(?:\.[a-zA-Z_$][a-zA-Z0-9_$]*)*)\s*\|\s*i18n(?::([^"'|]+))?/,
);
if (variableMatch && !keyMatch) {
const key = variableMatch[1];
const paramString = variableMatch[2];
const parameters = paramString
? paramString
.split(":")
.map((p) => p.trim())
.filter((p) => p)
: undefined;
const position = this.getPositionInfo(templateContent, match.index);
const alreadyFound = usages.some(
(usage) =>
usage.line === position.line && usage.column === position.column && usage.key === key,
);
if (!alreadyFound) {
usages.push({
filePath,
line: position.line,
column: position.column,
method: "pipe",
key,
parameters,
context: match[0],
});
}
}
}
}
/**
* Check if a node is a bound text node
*/
private isBoundText(node: TmplAstNode): node is TmplAstBoundText {
return node.constructor.name === "BoundText" || "value" in node;
}
/**
* Check if a node is an element node
*/
private isElement(node: TmplAstNode): node is TmplAstElement {
return node.constructor.name === "Element" || ("inputs" in node && "attributes" in node);
}
/**
* Check if an expression contains i18n pipe usage
*/
private containsI18nPipe(expression: string): boolean {
return /\|\s*i18n\b/.test(expression);
}
/**
* Extract i18n pipe usage details from an expression
*/
private extractI18nPipeUsage(expression: string): { key: string; parameters?: string[] } | null {
// Match patterns like: 'key' | i18n or 'key' | i18n:param1:param2
const pipeMatch = expression.match(/['"`]([^'"`]+)['"`]\s*\|\s*i18n(?::([^|}]+))?/);
if (pipeMatch) {
const key = pipeMatch[1];
const paramString = pipeMatch[2];
const parameters = paramString
? paramString
.split(":")
.map((p) => p.trim())
.filter((p) => p)
: undefined;
return { key, parameters };
}
// Match more complex patterns with variables
const complexMatch = expression.match(/([^|]+)\s*\|\s*i18n(?::([^|}]+))?/);
if (complexMatch) {
const keyExpression = complexMatch[1].trim();
const paramString = complexMatch[2];
const parameters = paramString
? paramString
.split(":")
.map((p) => p.trim())
.filter((p) => p)
: undefined;
// For complex expressions, use the full expression as the key
return { key: keyExpression, parameters };
}
return null;
}
/**
* Get line and column information for a position in the template
*/
getPositionInfo(templateContent: string, position: number): { line: number; column: number } {
const lines = templateContent.substring(0, position).split("\n");
return {
line: lines.length,
column: lines[lines.length - 1].length + 1,
};
}
/**
* Check if a template contains any i18n pipe usage
*/
hasI18nPipeUsage(templateContent: string): boolean {
return /\|\s*i18n\b/.test(templateContent);
}
/**
* Extract all unique translation keys from a template
*/
extractTranslationKeys(templateContent: string): string[] {
const usages = this.findI18nPipeUsage(templateContent, "");
const keys = new Set(usages.map((usage) => usage.key));
return Array.from(keys);
}
}

View File

@@ -0,0 +1,235 @@
import { TransformationResult, TransformationChange, I18nUsage } from "../shared/types";
import { TemplateParser } from "./template-parser";
/**
* Template transformation utilities for migrating i18n pipes to i18n attributes
*/
export class TemplateTransformer {
private parser: TemplateParser;
constructor() {
this.parser = new TemplateParser();
}
/**
* 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
*/
transformTemplate(templateContent: string, filePath: string): TransformationResult {
const changes: TransformationChange[] = [];
const errors: string[] = [];
try {
let transformedContent = templateContent;
// Transform interpolations: {{ 'key' | i18n }} -> <span i18n="@@key">key</span>
transformedContent = this.transformInterpolations(transformedContent, changes);
// Transform attributes: [title]="'key' | i18n" -> [title]="'key'" i18n-title="@@key"
transformedContent = this.transformAttributes(transformedContent, changes);
return {
success: true,
filePath,
changes,
errors,
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
errors.push(`Error transforming template: ${errorMessage}`);
return {
success: false,
filePath,
changes,
errors,
};
}
}
/**
* Transform interpolation usage: {{ 'key' | i18n }} -> <span i18n="@@key">key</span>
*/
private transformInterpolations(
templateContent: string,
changes: TransformationChange[],
): string {
let transformedContent = templateContent;
// Pattern for string literal interpolations
const stringInterpolationPattern =
/\{\{\s*['"`]([^'"`]+)['"`]\s*\|\s*i18n(?::([^}]+))?\s*\}\}/g;
let match;
while ((match = stringInterpolationPattern.exec(templateContent)) !== null) {
const original = match[0];
const key = match[1];
const i18nId = this.generateI18nId(key);
const replacement = `<span i18n="@@${i18nId}">${key}</span>`;
transformedContent = transformedContent.replace(original, replacement);
const position = this.getPositionInfo(templateContent, match.index);
changes.push({
type: "replace",
location: position,
original,
replacement,
description: `Transformed interpolation '${key}' to i18n attribute`,
});
}
// Pattern for variable interpolations
const variableInterpolationPattern =
/\{\{\s*([a-zA-Z_$][a-zA-Z0-9_$]*(?:\.[a-zA-Z_$][a-zA-Z0-9_$]*)*)\s*\|\s*i18n(?::([^}]+))?\s*\}\}/g;
while ((match = variableInterpolationPattern.exec(templateContent)) !== null) {
const original = match[0];
const variable = match[1];
const i18nId = this.generateI18nId(variable);
const replacement = `<span i18n="@@${i18nId}">{{${variable}}}</span>`;
transformedContent = transformedContent.replace(original, replacement);
const position = this.getPositionInfo(templateContent, match.index);
changes.push({
type: "replace",
location: position,
original,
replacement,
description: `Transformed variable interpolation '${variable}' to i18n attribute`,
});
}
return transformedContent;
}
/**
* Transform attribute usage: [attr]="'key' | i18n" -> [attr]="'key'" i18n-attr="@@key"
*/
private transformAttributes(templateContent: string, changes: TransformationChange[]): string {
let transformedContent = templateContent;
// Pattern for attributes with i18n pipe
const attributePattern = /(\[?[\w-]+\]?)\s*=\s*["']([^"']*\|\s*i18n[^"']*)["']/g;
let match;
while ((match = attributePattern.exec(templateContent)) !== null) {
const original = match[0];
const attrName = match[1];
const attrValue = match[2];
// Extract the key from the pipe expression
const keyMatch = attrValue.match(/['"`]([^'"`]+)['"`]\s*\|\s*i18n(?::([^"'|]+))?/);
if (keyMatch) {
const key = keyMatch[1];
const i18nId = this.generateI18nId(key);
// Remove brackets if present for i18n attribute
const baseAttrName = attrName.replace(/[\[\]]/g, "");
const replacement = `${attrName}="${key}" i18n-${baseAttrName}="@@${i18nId}"`;
transformedContent = transformedContent.replace(original, replacement);
const position = this.getPositionInfo(templateContent, match.index);
changes.push({
type: "replace",
location: position,
original,
replacement,
description: `Transformed attribute '${attrName}' with key '${key}' to i18n attribute`,
});
}
// Handle variable attributes
const variableMatch = attrValue.match(
/([a-zA-Z_$][a-zA-Z0-9_$]*(?:\.[a-zA-Z_$][a-zA-Z0-9_$]*)*)\s*\|\s*i18n(?::([^"'|]+))?/,
);
if (variableMatch && !keyMatch) {
const variable = variableMatch[1];
const i18nId = this.generateI18nId(variable);
const baseAttrName = attrName.replace(/[\[\]]/g, "");
const replacement = `${attrName}="${variable}" i18n-${baseAttrName}="@@${i18nId}"`;
transformedContent = transformedContent.replace(original, replacement);
const position = this.getPositionInfo(templateContent, match.index);
changes.push({
type: "replace",
location: position,
original,
replacement,
description: `Transformed variable attribute '${attrName}' with variable '${variable}' to i18n attribute`,
});
}
}
return transformedContent;
}
/**
* 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();
}
/**
* Get line and column information for a position in the template
*/
private getPositionInfo(
templateContent: string,
position: number,
): { line: number; column: number } {
const lines = templateContent.substring(0, position).split("\n");
return {
line: lines.length,
column: lines[lines.length - 1].length + 1,
};
}
/**
* 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 (error) {
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-]+)?="@@[\w-]+"/g) || [];
return i18nAttrs.every((attr) => attr.includes("@@"));
}
}

View File

@@ -0,0 +1,209 @@
import { TemplateParser } from "../templates/template-parser";
import { TemplateTransformer } from "../templates/template-transformer";
describe("Template Migration Tools", () => {
describe("TemplateParser", () => {
let parser: TemplateParser;
beforeEach(() => {
parser = new TemplateParser();
});
it("should find i18n pipe usage in interpolations", () => {
const template = `
<div>
<h1>{{ 'welcome' | i18n }}</h1>
<p>{{ 'itemCount' | i18n:count }}</p>
</div>
`;
const usages = parser.findI18nPipeUsage(template, "test.html");
expect(usages).toHaveLength(2);
expect(usages[0].key).toBe("welcome");
expect(usages[0].method).toBe("pipe");
expect(usages[1].key).toBe("itemCount");
expect(usages[1].parameters).toEqual(["count"]);
});
it("should find i18n pipe usage in attributes", () => {
const template = `
<button [title]="'clickMe' | i18n">
Click
</button>
<input placeholder="{{ 'enterText' | i18n }}">
`;
const usages = parser.findI18nPipeUsage(template, "test.html");
expect(usages).toHaveLength(2);
expect(usages[0].key).toBe("clickMe");
expect(usages[1].key).toBe("enterText");
});
it("should handle templates without i18n pipe usage", () => {
const template = `
<div>
<h1>Static Text</h1>
<p>{{ someVariable }}</p>
</div>
`;
const usages = parser.findI18nPipeUsage(template, "test.html");
expect(usages).toHaveLength(0);
});
it("should handle malformed templates gracefully", () => {
const template = `
<div>
<h1>{{ 'test' | i18n
<p>Incomplete template
`;
// Should not throw an error
const usages = parser.findI18nPipeUsage(template, "test.html");
// May or may not find usages depending on parser robustness
expect(Array.isArray(usages)).toBe(true);
});
});
describe("TemplateTransformer", () => {
let transformer: TemplateTransformer;
beforeEach(() => {
transformer = new TemplateTransformer();
});
it("should transform simple interpolation to i18n attribute", () => {
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].replacement).toContain('i18n="@@welcome"');
expect(result.changes[0].replacement).toContain("<span");
});
it("should transform attribute with i18n pipe", () => {
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);
expect(result.changes[0].replacement).toContain('i18n-title="@@click-me"');
});
it("should handle multiple i18n pipe usages", () => {
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);
});
it("should generate proper i18n IDs from keys", () => {
const template = `{{ 'camelCaseKey' | i18n }}`;
const result = transformer.transformTemplate(template, "test.html");
expect(result.success).toBe(true);
expect(result.changes[0].replacement).toContain("@@camel-case-key");
});
it("should handle templates without i18n pipes", () => {
const template = `
<div>
<h1>Static Title</h1>
<p>{{ someVariable }}</p>
</div>
`;
const result = transformer.transformTemplate(template, "test.html");
expect(result.success).toBe(true);
expect(result.changes).toHaveLength(0);
});
it("should validate transformations", () => {
const original = `<h1>{{ 'test' | i18n }}</h1>`;
const validTransformed = `<h1><span i18n="@@test">test</span></h1>`;
const invalidTransformed = `<h1><span i18n="invalid">test</span></h1>`;
expect(transformer.validateTransformation(original, validTransformed)).toBe(true);
expect(transformer.validateTransformation(original, invalidTransformed)).toBe(false);
});
});
describe("Integration Tests", () => {
it("should handle complex template transformation", () => {
const transformer = new TemplateTransformer();
const template = `
<div class="container">
<header>
<h1>{{ 'appTitle' | i18n }}</h1>
<nav>
<a [title]="'homeLink' | i18n" href="/">{{ 'home' | i18n }}</a>
<a [title]="'aboutLink' | i18n" href="/about">{{ 'about' | i18n }}</a>
</nav>
</header>
<main>
<p>{{ 'welcomeMessage' | i18n }}</p>
<button [disabled]="loading" [title]="'submitButton' | i18n">
{{ 'submit' | i18n }}
</button>
</main>
</div>
`;
const result = transformer.transformTemplate(template, "complex-test.html");
expect(result.success).toBe(true);
expect(result.changes.length).toBeGreaterThan(0);
// Verify that all i18n pipes were found and transformed
const originalPipeCount = (template.match(/\|\s*i18n/g) || []).length;
expect(result.changes.length).toBe(originalPipeCount);
});
it("should preserve template structure during transformation", () => {
const transformer = new TemplateTransformer();
const template = `
<div>
<p>Before: {{ 'message' | i18n }}</p>
<span>Static content</span>
<p>After: {{ 'anotherMessage' | i18n }}</p>
</div>
`;
const result = transformer.transformTemplate(template, "structure-test.html");
expect(result.success).toBe(true);
// Apply transformations to see the result
let transformedContent = template;
for (const change of result.changes.reverse()) {
if (change.original && change.replacement) {
transformedContent = transformedContent.replace(change.original, change.replacement);
}
}
// Verify structure is preserved
expect(transformedContent).toContain("<div>");
expect(transformedContent).toContain("</div>");
expect(transformedContent).toContain("Static content");
expect(transformedContent).toContain("Before:");
expect(transformedContent).toContain("After:");
});
});
});

View File

@@ -0,0 +1,194 @@
import { Project, SourceFile } from "ts-morph";
import { ASTTransformer } from "../typescript/ast-transformer";
import { MigrationConfig } from "../shared/types";
describe("TypeScript Migration Tools", () => {
let project: Project;
beforeEach(() => {
project = new Project({
useInMemoryFileSystem: true,
});
});
describe("ASTTransformer", () => {
let transformer: ASTTransformer;
let sourceFile: SourceFile;
beforeEach(() => {
transformer = new ASTTransformer();
});
it("should find I18nService.t() calls", () => {
const code = `
import { I18nService } from '@bitwarden/common/platform/services/i18n.service';
class TestComponent {
constructor(private i18nService: I18nService) {}
test() {
const message = this.i18nService.t('loginWithDevice');
const countMessage = this.i18nService.t('itemsCount', count.toString());
}
}
`;
sourceFile = project.createSourceFile("test.ts", code);
const usages = transformer.findI18nServiceCalls(sourceFile);
expect(usages).toHaveLength(2);
expect(usages[0].key).toBe("loginWithDevice");
expect(usages[0].method).toBe("t");
expect(usages[1].key).toBe("itemsCount");
expect(usages[1].parameters).toEqual(["count.toString()"]);
});
it("should transform I18nService.t() to $localize but keep import due to constructor usage", () => {
const code = `
import { I18nService } from '@bitwarden/common/platform/services/i18n.service';
class TestComponent {
constructor(private i18nService: I18nService) {}
test() {
const message = this.i18nService.t('loginWithDevice');
}
}
`;
sourceFile = project.createSourceFile("test.ts", code);
const result = transformer.transformI18nServiceCalls(sourceFile);
expect(result.success).toBe(true);
expect(result.changes).toHaveLength(1); // Only transformation, import kept due to constructor usage
expect(result.changes[0].replacement).toBe("$localize`loginWithDevice`");
expect(sourceFile.getFullText()).toContain("$localize`loginWithDevice`");
expect(sourceFile.getFullText()).toContain("I18nService"); // Import should still be there
});
it("should handle parameters in I18nService.t() calls", () => {
const code = `
class TestComponent {
test() {
const message = this.i18nService.t('itemsCount', count.toString());
}
}
`;
sourceFile = project.createSourceFile("test.ts", code);
const result = transformer.transformI18nServiceCalls(sourceFile);
expect(result.success).toBe(true);
expect(result.changes[0].replacement).toBe(
"$localize`itemsCount\${count.toString()}:param0:`",
);
});
it("should handle files without I18nService usage", () => {
const code = `
import { Component } from '@angular/core';
@Component({})
class TestComponent {
test() {
console.log('no i18n here');
}
}
`;
sourceFile = project.createSourceFile("test.ts", code);
const result = transformer.transformI18nServiceCalls(sourceFile);
expect(result.success).toBe(true);
expect(result.changes).toHaveLength(0);
});
it("should remove I18nService import when no longer used", () => {
const code = `
import { I18nService } from '@bitwarden/common/platform/services/i18n.service';
import { Component } from '@angular/core';
@Component({})
class TestComponent {
test() {
const message = this.i18nService.t('loginWithDevice');
}
}
`;
sourceFile = project.createSourceFile("test.ts", code);
const result = transformer.transformI18nServiceCalls(sourceFile);
expect(result.success).toBe(true);
expect(result.changes).toHaveLength(2); // One for transformation, one for import removal
expect(sourceFile.getFullText()).not.toContain("I18nService");
});
});
describe("Integration Tests", () => {
it("should handle complex transformation scenarios", () => {
const transformer = new ASTTransformer();
const code = `
import { I18nService } from '@bitwarden/common/platform/services/i18n.service';
import { Component } from '@angular/core';
@Component({})
class TestComponent {
constructor(private i18nService: I18nService) {}
getMessage() {
return this.i18nService.t('simpleMessage');
}
getParameterizedMessage(count: number) {
return this.i18nService.t('itemCount', count.toString());
}
getMultipleMessages() {
const msg1 = this.i18nService.t('message1');
const msg2 = this.i18nService.t('message2', 'param');
return [msg1, msg2];
}
}
`;
const sourceFile = project.createSourceFile("complex-test.ts", code);
const result = transformer.transformI18nServiceCalls(sourceFile);
expect(result.success).toBe(true);
expect(result.changes.length).toBe(4); // 4 transformations, no import removal due to constructor
const transformedCode = sourceFile.getFullText();
expect(transformedCode).toContain("$localize`simpleMessage`");
expect(transformedCode).toContain("$localize`itemCount\${count.toString()}:param0:`");
expect(transformedCode).toContain("$localize`message1`");
expect(transformedCode).toContain("$localize`message2\${'param'}:param0:`");
// Should keep the I18nService import due to constructor usage
expect(transformedCode).toContain("I18nService");
});
it("should remove import when only method calls are used (no constructor)", () => {
const transformer = new ASTTransformer();
const code = `
import { I18nService } from '@bitwarden/common/platform/services/i18n.service';
class TestComponent {
test() {
const message = this.i18nService.t('testMessage');
}
}
`;
const sourceFile = project.createSourceFile("no-constructor-test.ts", code);
const result = transformer.transformI18nServiceCalls(sourceFile);
expect(result.success).toBe(true);
expect(result.changes.length).toBe(2); // 1 transformation + 1 import removal
const transformedCode = sourceFile.getFullText();
expect(transformedCode).toContain("$localize`testMessage`");
expect(transformedCode).not.toContain("I18nService");
});
});
});

View File

@@ -0,0 +1,19 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"module": "commonjs",
"target": "es2020",
"lib": ["es2020"],
"declaration": false,
"strict": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"moduleResolution": "node",
"allowSyntheticDefaultImports": true,
"experimentalDecorators": true,
"importHelpers": true,
"types": ["node", "jest"]
},
"include": ["**/*.ts"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -0,0 +1,19 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"module": "commonjs",
"target": "es2020",
"lib": ["es2020"],
"declaration": false,
"strict": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"moduleResolution": "node",
"allowSyntheticDefaultImports": true,
"experimentalDecorators": true,
"importHelpers": true,
"types": ["node", "jest"]
},
"include": ["**/*.ts"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -0,0 +1,197 @@
import { SourceFile, Node } from "ts-morph";
import { TransformationResult, TransformationChange, I18nUsage } from "../shared/types";
/**
* AST transformation utilities for TypeScript code migration
*/
export class ASTTransformer {
/**
* Find all I18nService.t() method calls in a source file
*/
findI18nServiceCalls(sourceFile: SourceFile): I18nUsage[] {
const usages: I18nUsage[] = [];
sourceFile.forEachDescendant((node) => {
if (Node.isCallExpression(node)) {
const expression = node.getExpression();
if (Node.isPropertyAccessExpression(expression)) {
const object = expression.getExpression();
const property = expression.getName();
// Check if this is a call to i18nService.t() or this.i18n.t()
if (property === "t" && this.isI18nServiceAccess(object)) {
const args = node.getArguments();
if (args.length > 0) {
const keyArg = args[0];
const key = this.extractStringLiteral(keyArg);
if (key) {
const parameters = args.slice(1).map((arg) => arg.getText());
const { line, column } = sourceFile.getLineAndColumnAtPos(node.getStart());
usages.push({
filePath: sourceFile.getFilePath(),
line,
column,
method: "t",
key,
parameters: parameters.length > 0 ? parameters : undefined,
});
}
}
}
}
}
});
return usages;
}
/**
* Transform I18nService.t() calls to $localize calls
*/
transformI18nServiceCalls(sourceFile: SourceFile): TransformationResult {
const changes: TransformationChange[] = [];
const errors: string[] = [];
try {
// Find and replace I18nService calls
sourceFile.forEachDescendant((node) => {
if (Node.isCallExpression(node)) {
const expression = node.getExpression();
if (Node.isPropertyAccessExpression(expression)) {
const object = expression.getExpression();
const property = expression.getName();
if (property === "t" && this.isI18nServiceAccess(object)) {
const args = node.getArguments();
if (args.length > 0) {
const keyArg = args[0];
const key = this.extractStringLiteral(keyArg);
if (key) {
const { line, column } = sourceFile.getLineAndColumnAtPos(node.getStart());
const original = node.getText();
// Generate $localize replacement
const replacement = this.generateLocalizeCall(key, args.slice(1));
// Replace the node
node.replaceWithText(replacement);
changes.push({
type: "replace",
location: { line, column },
original,
replacement,
description: `Replaced i18nService.t('${key}') with $localize`,
});
}
}
}
}
}
});
// Remove I18nService imports if no longer used
this.removeUnusedI18nImports(sourceFile, changes);
return {
success: true,
filePath: sourceFile.getFilePath(),
changes,
errors,
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
errors.push(`Error transforming file: ${errorMessage}`);
return {
success: false,
filePath: sourceFile.getFilePath(),
changes,
errors,
};
}
}
/**
* Check if a node represents access to I18nService
*/
private isI18nServiceAccess(node: Node): boolean {
const text = node.getText();
return text.includes("i18nService") || text.includes("i18n") || text.includes("this.i18n");
}
/**
* Extract string literal value from a node
*/
private extractStringLiteral(node: Node): string | null {
if (Node.isStringLiteral(node)) {
return node.getLiteralValue();
}
if (Node.isNoSubstitutionTemplateLiteral(node)) {
return node.getLiteralValue();
}
return null;
}
/**
* Generate $localize call with parameters
*/
private generateLocalizeCall(key: string, paramArgs: Node[]): string {
if (paramArgs.length === 0) {
return `$localize\`${key}\``;
}
// For now, handle simple parameter substitution
// This will need to be enhanced for complex cases
const params = paramArgs.map((arg, index) => `\${${arg.getText()}}:param${index}:`);
return `$localize\`${key}${params.join("")}\``;
}
/**
* Remove unused I18nService imports
*/
private removeUnusedI18nImports(sourceFile: SourceFile, changes: TransformationChange[]): void {
const imports = sourceFile.getImportDeclarations();
imports.forEach((importDecl) => {
const moduleSpecifier = importDecl.getModuleSpecifierValue();
if (moduleSpecifier.includes("i18n.service")) {
// Check if I18nService is still used in the file
const text = sourceFile.getFullText();
// Look for actual I18nService usage (constructor parameters, type annotations, etc.)
// but exclude the .t() method calls since we've transformed those
const hasI18nServiceType =
text.includes(": I18nService") ||
text.includes("<I18nService>") ||
text.includes("I18nService>") ||
text.includes("I18nService,") ||
text.includes("I18nService)");
// Check for remaining .t() calls that weren't transformed
const hasRemainingTCalls = text.match(/\bi18nService\.t\s*\(/);
// Only remove if there are no type references and no remaining method calls
if (!hasI18nServiceType && !hasRemainingTCalls) {
const { line, column } = sourceFile.getLineAndColumnAtPos(importDecl.getStart());
const original = importDecl.getText();
importDecl.remove();
changes.push({
type: "remove",
location: { line, column },
original,
description: "Removed unused I18nService import",
});
}
}
});
}
}

View File

@@ -0,0 +1,79 @@
import { Project, SourceFile } from "ts-morph";
import { MigrationConfig } from "../shared/types";
/**
* Utility class for parsing TypeScript projects using ts-morph
*/
export class ProjectParser {
private project: Project;
constructor(private config: MigrationConfig) {
this.project = new Project({
tsConfigFilePath: config.tsConfigPath,
skipAddingFilesFromTsConfig: false,
});
}
/**
* Get all source files in the project
*/
getSourceFiles(): SourceFile[] {
return this.project.getSourceFiles();
}
/**
* Get a specific source file by path
*/
getSourceFile(filePath: string): SourceFile | undefined {
return this.project.getSourceFile(filePath);
}
/**
* Add a source file to the project
*/
addSourceFile(filePath: string): SourceFile {
return this.project.addSourceFileAtPath(filePath);
}
/**
* Save all changes to disk
*/
async saveChanges(): Promise<void> {
if (!this.config.dryRun) {
await this.project.save();
}
}
/**
* Get the underlying ts-morph Project instance
*/
getProject(): Project {
return this.project;
}
/**
* Find files that import I18nService
*/
findI18nServiceImports(): SourceFile[] {
return this.project.getSourceFiles().filter((sourceFile) => {
return sourceFile.getImportDeclarations().some((importDecl) => {
const moduleSpecifier = importDecl.getModuleSpecifierValue();
return (
moduleSpecifier.includes("i18n.service") ||
moduleSpecifier.includes("@bitwarden/common/platform/services/i18n.service")
);
});
});
}
/**
* Find files that use the i18n pipe in template strings
*/
findI18nPipeUsage(): SourceFile[] {
return this.project.getSourceFiles().filter((sourceFile) => {
const text = sourceFile.getFullText();
return text.includes("| i18n") || text.includes("|i18n");
});
}
}

View File

@@ -0,0 +1,174 @@
/* eslint-disable no-console */
import { MigrationConfig, TransformationResult, I18nUsage } from "../shared/types";
import { ASTTransformer } from "./ast-transformer";
import { ProjectParser } from "./project-parser";
/**
* Main class for TypeScript code migration from I18nService to $localize
*/
export class TypeScriptMigrator {
private parser: ProjectParser;
private transformer: ASTTransformer;
constructor(private config: MigrationConfig) {
this.parser = new ProjectParser(config);
this.transformer = new ASTTransformer();
}
/**
* Analyze current I18nService usage across the project
*/
analyzeUsage(): I18nUsage[] {
const sourceFiles = this.parser.findI18nServiceImports();
const allUsages: I18nUsage[] = [];
sourceFiles.forEach((sourceFile) => {
const usages = this.transformer.findI18nServiceCalls(sourceFile);
allUsages.push(...usages);
});
return allUsages;
}
/**
* Generate analysis report of current usage patterns
*/
generateAnalysisReport(): string {
const usages = this.analyzeUsage();
const fileCount = new Set(usages.map((u) => u.filePath)).size;
const keyCount = new Set(usages.map((u) => u.key)).size;
let report = `# I18nService Usage Analysis Report\n\n`;
report += `## Summary\n`;
report += `- Total usage count: ${usages.length}\n`;
report += `- Files affected: ${fileCount}\n`;
report += `- Unique translation keys: ${keyCount}\n\n`;
report += `## Usage by File\n`;
const usagesByFile = usages.reduce(
(acc, usage) => {
if (!acc[usage.filePath]) {
acc[usage.filePath] = [];
}
acc[usage.filePath].push(usage);
return acc;
},
{} as Record<string, I18nUsage[]>,
);
Object.entries(usagesByFile).forEach(([filePath, fileUsages]) => {
report += `\n### ${filePath}\n`;
fileUsages.forEach((usage) => {
report += `- Line ${usage.line}: \`${usage.key}\``;
if (usage.parameters) {
report += ` (with parameters: ${usage.parameters.join(", ")})`;
}
report += `\n`;
});
});
report += `\n## Most Common Keys\n`;
const keyCounts = usages.reduce(
(acc, usage) => {
acc[usage.key] = (acc[usage.key] || 0) + 1;
return acc;
},
{} as Record<string, number>,
);
Object.entries(keyCounts)
.sort(([, a], [, b]) => b - a)
.slice(0, 10)
.forEach(([key, count]) => {
report += `- \`${key}\`: ${count} usage(s)\n`;
});
return report;
}
/**
* Migrate all TypeScript files in the project
*/
async migrateAll(): Promise<TransformationResult[]> {
const sourceFiles = this.parser.findI18nServiceImports();
const results: TransformationResult[] = [];
if (this.config.verbose) {
console.log(`Found ${sourceFiles.length} files with I18nService imports`);
}
for (const sourceFile of sourceFiles) {
if (this.config.verbose) {
console.log(`Processing: ${sourceFile.getFilePath()}`);
}
const result = this.transformer.transformI18nServiceCalls(sourceFile);
results.push(result);
if (!result.success) {
console.error(`Failed to process ${result.filePath}:`, result.errors);
}
}
// Save changes if not in dry run mode
if (!this.config.dryRun) {
await this.parser.saveChanges();
}
return results;
}
/**
* Migrate a specific file
*/
async migrateFile(filePath: string): Promise<TransformationResult> {
const sourceFile = this.parser.getSourceFile(filePath);
if (!sourceFile) {
return {
success: false,
filePath,
changes: [],
errors: [`File not found: ${filePath}`],
};
}
const result = this.transformer.transformI18nServiceCalls(sourceFile);
if (!this.config.dryRun && result.success) {
await this.parser.saveChanges();
}
return result;
}
/**
* Generate migration statistics
*/
generateMigrationStats(results: TransformationResult[]): string {
const successful = results.filter((r) => r.success).length;
const failed = results.filter((r) => !r.success).length;
const totalChanges = results.reduce((sum, r) => sum + r.changes.length, 0);
let stats = `# Migration Statistics\n\n`;
stats += `- Files processed: ${results.length}\n`;
stats += `- Successful: ${successful}\n`;
stats += `- Failed: ${failed}\n`;
stats += `- Total changes: ${totalChanges}\n\n`;
if (failed > 0) {
stats += `## Failed Files\n`;
results
.filter((r) => !r.success)
.forEach((result) => {
stats += `- ${result.filePath}\n`;
result.errors.forEach((error) => {
stats += ` - ${error}\n`;
});
});
}
return stats;
}
}