mirror of
https://github.com/bitwarden/browser
synced 2026-02-09 21:20:27 +00:00
tmp
This commit is contained in:
14
scripts/migration/i18n/README.md
Normal file
14
scripts/migration/i18n/README.md
Normal 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.
|
||||
21
scripts/migration/i18n/jest.config.js
Normal file
21
scripts/migration/i18n/jest.config.js
Normal 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"],
|
||||
};
|
||||
38
scripts/migration/i18n/shared/types.ts
Normal file
38
scripts/migration/i18n/shared/types.ts
Normal 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;
|
||||
}
|
||||
206
scripts/migration/i18n/templates/template-migrator.ts
Normal file
206
scripts/migration/i18n/templates/template-migrator.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
371
scripts/migration/i18n/templates/template-parser.ts
Normal file
371
scripts/migration/i18n/templates/template-parser.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
235
scripts/migration/i18n/templates/template-transformer.ts
Normal file
235
scripts/migration/i18n/templates/template-transformer.ts
Normal 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("@@"));
|
||||
}
|
||||
}
|
||||
209
scripts/migration/i18n/tests/template-migrator.test.ts
Normal file
209
scripts/migration/i18n/tests/template-migrator.test.ts
Normal 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:");
|
||||
});
|
||||
});
|
||||
});
|
||||
194
scripts/migration/i18n/tests/typescript-migrator.test.ts
Normal file
194
scripts/migration/i18n/tests/typescript-migrator.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
19
scripts/migration/i18n/tsconfig.json
Normal file
19
scripts/migration/i18n/tsconfig.json
Normal 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"]
|
||||
}
|
||||
19
scripts/migration/i18n/tsconfig.spec.json
Normal file
19
scripts/migration/i18n/tsconfig.spec.json
Normal 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"]
|
||||
}
|
||||
197
scripts/migration/i18n/typescript/ast-transformer.ts
Normal file
197
scripts/migration/i18n/typescript/ast-transformer.ts
Normal 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",
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
79
scripts/migration/i18n/typescript/project-parser.ts
Normal file
79
scripts/migration/i18n/typescript/project-parser.ts
Normal 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");
|
||||
});
|
||||
}
|
||||
}
|
||||
174
scripts/migration/i18n/typescript/typescript-migrator.ts
Normal file
174
scripts/migration/i18n/typescript/typescript-migrator.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user