mirror of
https://github.com/bitwarden/browser
synced 2026-02-06 19:53:59 +00:00
202 lines
6.2 KiB
TypeScript
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);
|
|
}
|
|
}
|