import * as fs from 'fs';
import * as path from 'path';
import { ControlFlowTransformer } from '../../helpers/control-flow-transformer';
export interface TransformResult {
content: string;
modified: boolean;
}
export class TemplateTransformer {
private controlFlowTransformer = new ControlFlowTransformer();
transformInterpolation(content: string): string {
let result = content;
result = this.transformAttributeInterpolation(result);
result = this.transformContentInterpolation(result);
return result;
}
private transformAttributeInterpolation(content: string): string {
const tagRegex = /<([a-zA-Z][a-zA-Z0-9-]*)((?:\s+[^>]*?)?)>/g;
return content.replace(tagRegex, (fullMatch, tagName, attributesPart) => {
if (!attributesPart || !attributesPart.includes('{{')) {
return fullMatch;
}
const interpolationRegex = /([a-zA-Z][a-zA-Z0-9-]*)\s*=\s*"([^"]*\{\{[^"]*\}\}[^"]*)"/g;
const bindings: { attr: string; expr: string }[] = [];
let newAttributes = attributesPart;
newAttributes = attributesPart.replace(interpolationRegex, (_attrMatch: string, attrName: string, attrValue: string) => {
const hasInterpolation = /\{\{.*?\}\}/.test(attrValue);
if (!hasInterpolation) {
return _attrMatch;
}
const parts: string[] = [];
let lastIndex = 0;
const exprRegex = /\{\{\s*([^}]+?)\s*\}\}/g;
let match;
while ((match = exprRegex.exec(attrValue)) !== null) {
if (match.index > lastIndex) {
const literal = attrValue.substring(lastIndex, match.index);
if (literal) {
parts.push(`'${literal}'`);
}
}
const transformedExpr = this.transformPipeExpression(match[1].trim());
parts.push(`(${transformedExpr})`);
lastIndex = exprRegex.lastIndex;
}
if (lastIndex < attrValue.length) {
const literal = attrValue.substring(lastIndex);
if (literal) {
parts.push(`'${literal}'`);
}
}
const expression = parts.length === 1 ? parts[0] : parts.join(' + ');
bindings.push({ attr: attrName, expr: expression });
return '';
});
if (bindings.length === 0) {
return fullMatch;
}
const bindingsJson = JSON.stringify(bindings).replace(/"/g, "'");
const dataAttr = ` data-quarc-attr-bindings="${bindingsJson.replace(/'/g, ''')}"`;
newAttributes = newAttributes.trim();
return `<${tagName}${newAttributes ? ' ' + newAttributes : ''}${dataAttr}>`;
});
}
private transformContentInterpolation(content: string): string {
return content.replace(
/\{\{\s*([^}]+?)\s*\}\}/g,
(_, expr) => {
const transformedExpr = this.transformPipeExpression(expr.trim());
return ``;
},
);
}
private transformPipeExpression(expression: string): string {
const parts = this.splitByPipe(expression);
if (parts.length === 1) {
return expression;
}
let result = parts[0].trim();
for (let i = 1; i < parts.length; i++) {
const pipePart = parts[i].trim();
const colonIndex = pipePart.indexOf(':');
if (colonIndex === -1) {
const pipeName = pipePart.trim();
result = `this._pipes?.['${pipeName}']?.transform(${result})`;
} else {
const pipeName = pipePart.substring(0, colonIndex).trim();
const argsStr = pipePart.substring(colonIndex + 1).trim();
const args = argsStr.split(':').map(arg => arg.trim());
const argsJoined = args.join(', ');
result = `this._pipes?.['${pipeName}']?.transform(${result}, ${argsJoined})`;
}
}
return result;
}
private splitByPipe(expression: string): string[] {
const parts: string[] = [];
let current = '';
let i = 0;
while (i < expression.length) {
const char = expression[i];
if (char === '|') {
if (i + 1 < expression.length && expression[i + 1] === '|') {
current += '||';
i += 2;
} else {
parts.push(current);
current = '';
i++;
}
} else {
current += char;
i++;
}
}
if (current) {
parts.push(current);
}
return parts.length > 0 ? parts : [expression];
}
transformControlFlowIf(content: string): string {
return this.controlFlowTransformer.transform(content);
}
transformControlFlowFor(content: string): string {
let result = content;
let startIndex = 0;
while (startIndex < result.length) {
const forIndex = result.indexOf('@for', startIndex);
if (forIndex === -1) break;
const block = this.extractForBlock(result, forIndex);
if (!block) {
startIndex = forIndex + 4;
continue;
}
const replacement = this.buildForDirective(block.header, block.body);
result = result.substring(0, forIndex) + replacement + result.substring(block.endIndex);
startIndex = forIndex + replacement.length;
}
return result;
}
transformNgIfDirective(content: string): string {
// Keep *ngIf as is - runtime handles it
return content;
}
transformNgForDirective(content: string): string {
// Keep *ngFor as is - runtime handles it
return content;
}
transformInputBindings(content: string): string {
return content.replace(/\[([a-zA-Z][a-zA-Z0-9]*)\]="/g, (match, propName) => {
const kebabName = this.camelToKebab(propName);
return `[${kebabName}]="`;
});
}
private camelToKebab(str: string): string {
return str.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase();
}
transformOutputBindings(content: string): string {
// Keep (event) as is - runtime handles it
return content;
}
transformTwoWayBindings(content: string): string {
// Keep [(model)] as is - runtime handles it
return content;
}
transformAll(content: string): string {
let result = content;
result = this.transformInterpolation(result);
result = this.transformControlFlowFor(result);
result = this.transformControlFlowIf(result);
result = this.transformSelectNgFor(result);
result = this.transformNgIfDirective(result);
result = this.transformNgForDirective(result);
result = this.transformInputBindings(result);
result = this.transformOutputBindings(result);
result = this.transformTwoWayBindings(result);
return result;
}
async loadExternalTemplate(templatePath: string, fileDir: string): Promise {
const fullPath = path.resolve(fileDir, templatePath);
if (!fs.existsSync(fullPath)) {
throw new Error(`Template file not found: ${fullPath}`);
}
return fs.promises.readFile(fullPath, 'utf8');
}
private extractForBlock(content: string, startIndex: number): { header: string; body: string; endIndex: number } | null {
const openParenIndex = content.indexOf('(', startIndex);
if (openParenIndex === -1) return null;
const closeParenIndex = this.findMatchingParen(content, openParenIndex);
if (closeParenIndex === -1) return null;
const openBraceIndex = content.indexOf('{', closeParenIndex);
if (openBraceIndex === -1) return null;
const closeBraceIndex = this.findMatchingBrace(content, openBraceIndex);
if (closeBraceIndex === -1) return null;
return {
header: content.substring(openParenIndex + 1, closeParenIndex).trim(),
body: content.substring(openBraceIndex + 1, closeBraceIndex),
endIndex: closeBraceIndex + 1,
};
}
private buildForDirective(header: string, body: string): string {
const parts = header.split(';');
const forPart = parts[0].trim();
const trackPart = parts[1]?.trim();
const forMatch = forPart.match(/^\s*(\w+)\s+of\s+(.+)\s*$/);
if (!forMatch) return ``;
const variable = forMatch[1];
const iterable = forMatch[2].trim();
let ngForExpr = `let ${variable} of ${iterable}`;
if (trackPart) {
const trackMatch = trackPart.match(/^track\s+(.+)$/);
if (trackMatch) {
ngForExpr += `; trackBy: ${trackMatch[1].trim()}`;
}
}
return `${body}`;
}
transformSelectNgFor(content: string): string {
// Transform ng-container *ngFor inside