1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-06 19:53:59 +00:00
Files
browser/scripts/migration/i18n/templates/template-parser.ts
2025-07-28 15:57:28 +02:00

202 lines
6.2 KiB
TypeScript

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[] = [];
// Parse template using Angular compiler
const parseResult = parseTemplate(templateContent, filePath);
if (parseResult.nodes) {
this.traverseNodes(parseResult.nodes, usages, filePath);
}
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 && typeof expression == "object" && "source" in expression) {
const expressionText = (expression.source as string) || "";
if (this.containsI18nPipe(expressionText)) {
const pipeUsage = this.extractI18nPipeUsage(expressionText);
if (pipeUsage) {
// Get the actual text from the source span instead of reconstructing it
const actualContext = node.sourceSpan.start.file.content.substring(
node.sourceSpan.start.offset,
node.sourceSpan.end.offset,
);
usages.push({
filePath,
line: node.sourceSpan.start.line + 1,
column: node.sourceSpan.start.col,
method: "pipe",
key: pipeUsage.key,
parameters: pipeUsage.parameters,
context: actualContext,
});
}
}
}
}
// Handle element nodes with attributes
if (this.isElement(node)) {
// Check bound attributes (property bindings)
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}"`,
});
}
}
}
}
}
/**
* 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);
}
}