1
0
mirror of https://github.com/bitwarden/browser synced 2026-03-01 02:51:24 +00:00
Files
browser/scripts/migration/i18n/templates/template-parser.ts
Hinton 9d76924a6d tmp
2025-07-28 10:19:23 +02:00

372 lines
11 KiB
TypeScript

/* 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);
}
}