const fs = require('fs');
const path = require('path');
const ExpressionParser = require('./expressionParser');
/**
* ComponentParser handles the parsing of Avenx component files (.js and .css).
* It extracts component state, computed properties, methods, and templates,
* and coordinates with the StyleProcessor to handle styles.
*/
class ComponentParser {
/**
* @param {StyleProcessor} styleProcessor - An instance of StyleProcessor to handle styles.
*/
constructor(styleProcessor) {
/** @type {StyleProcessor} */
this.styleProcessor = styleProcessor;
/** @type {ExpressionParser} */
this.expressionParser = new ExpressionParser();
}
/**
* Parses a .component.js or .page.js file and its corresponding CSS file.
* @param {string} filePath - The absolute path to the file.
* @param {'component'|'page'} [type='component'] - The type of file being parsed.
* @returns {string} The generated JavaScript class.
*/
parse(filePath, type = 'component') {
const isPage = type === 'page';
const suffix = isPage ? '.page.js' : '.component.js';
const content = fs.readFileSync(filePath, 'utf-8');
const fileName = path.basename(filePath, suffix);
// Convert user-profile or user_profile to UserProfile
const name = fileName
.split(/[-_]/)
.map(part => part.charAt(0).toUpperCase() + part.slice(1))
.join('');
const desPath = filePath.replace(suffix, isPage ? '.page.css' : '.component.css');
let desBlocks = {};
if (fs.existsSync(desPath)) {
this.extractStylesAndVars(fs.readFileSync(desPath, 'utf-8'), desBlocks);
}
const state = this.extractState(content);
const computed = this.extractComputed(content);
const methods = this.extractMethods(content);
let template = this.extractTemplate(content, desBlocks, name);
// Handle declarative tags: <MyComponent /> or <MyComponent>...</MyComponent> -> <div data-avenx-comp="MyComponent">...</div>
// Only if it looks like a component (starts with uppercase)
template = this.processComponentTags(template);
const methodStrings = Object.entries(methods)
.map(([k, v]) => `${k}: \`${v}\``).join(',\n ');
if (isPage) {
return `
class ${name} extends AvenxPage {
constructor(bridges, componentRegistry, props) {
super(${JSON.stringify(state)}, ${JSON.stringify(computed)}, bridges, \`${template}\`, { ${methodStrings} }, componentRegistry, props);
}
}`;
}
return `
class ${name} extends AvenxComponent {
constructor(bridges, props) {
super(${JSON.stringify(state)}, ${JSON.stringify(computed)}, bridges, \`${template}\`, { ${methodStrings} }, props);
}
}`;
}
/**
* Extracts global CSS variables and component-specific style blocks from CSS content.
* @param {string} desContent - The content of the .component.css file.
* @param {Object} desBlocks - An object to store the extracted style blocks.
* @private
*/
extractStylesAndVars(desContent, desBlocks) {
const globalMatch = desContent.match(/<@global>([\s\S]*?)<\/ ?@global>/i);
if (globalMatch) {
let inner = globalMatch[1];
const defRegex = /@def\s+([\w-]+)\s+([^;]+);/g;
let defMatch;
while ((defMatch = defRegex.exec(inner)) !== null) {
this.styleProcessor.addVariable(defMatch[1], defMatch[2].trim());
}
// Remove @def lines and add the rest as global CSS
const rawCss = inner.replace(/@def\s+[\w-]+\s+[^;]+;/g, '').trim();
if (rawCss) {
this.styleProcessor.addGlobalCSS(rawCss);
}
}
const cssBlockMatch = desContent.match(/<@css>([\s\S]*?)<\/ ?@css>/i);
if (cssBlockMatch) {
const inner = cssBlockMatch[1];
let depth = 0, currentName = "", currentBody = "", inBlock = false;
for (let i = 0; i < inner.length; i++) {
const char = inner[i];
if (char === '{' && depth === 0) {
currentName = inner.substring(0, i).trim().split('}').pop().trim();
inBlock = true; depth++;
} else if (char === '{') {
depth++; currentBody += char;
} else if (char === '}') {
depth--;
if (depth === 0) {
if (currentName) desBlocks[currentName] = currentBody.trim();
currentBody = ""; currentName = ""; inBlock = false;
} else { currentBody += char; }
} else if (inBlock) { currentBody += char; }
}
}
}
/**
* Extracts the initial state from the component's <state /> tags.
* @param {string} content - The content of the .component.js file.
* @returns {Object} The extracted state object.
* @private
*/
extractState(content) {
return this.expressionParser.parseState(content);
}
/**
* Extracts computed properties from the component's <computed /> tags.
* @param {string} content - The content of the .component.js file.
* @returns {Object} A map of property names to their expression strings.
* @private
*/
extractComputed(content) {
return this.expressionParser.parseComputed(content);
}
/**
* Extracts actions (methods) from the component's <action /> tags.
* @param {string} content - The content of the .component.js file.
* @returns {Object<string, string>} A map of method names to their stringified bodies.
* @private
*/
extractMethods(content) {
return this.expressionParser.parseMethods(content);
}
/**
* Extracts the HTML template and processes internal styles.
* @param {string} content - The content of the .component.js file.
* @param {Object} desBlocks - The previously extracted design blocks.
* @param {string} name - The name of the component for style hashing.
* @returns {string} The cleaned and processed HTML template.
* @private
*/
extractTemplate(content, desBlocks, name) {
let template = content
.replace(/<state.*? \/>/g, '')
.replace(/<computed.*? \/>/g, '')
.replace(/<action.*?>[\s\S]*?<\/action>/g, '')
.trim();
template = this.styleProcessor.process(template, desBlocks, name);
template = this.processBindDirectives(template);
template = this.processForLoops(template);
return template.split('\n').filter(line => line.trim() !== '').join('\n');
}
/**
* Processes data-ax-bind attributes on input, textarea, and select elements.
* Converts data-ax-bind="expr" to value="{{ expr }}" and event listener.
* @param {string} template - The template string.
* @returns {string} The processed template.
*/
processBindDirectives(template) {
const tagRegex = /<(input|textarea|select)\b([^>]*?)>/gi;
return template.replace(tagRegex, (match, tagName, attrs) => {
const bindRegex = /\bdata-ax-bind\s*=\s*(?:"([^"]*)"|'([^']*)')/i;
const bindMatch = attrs.match(bindRegex);
if (!bindMatch) {
return match;
}
const bindExpr = (bindMatch[1] !== undefined ? bindMatch[1] : bindMatch[2]).trim();
let cleanAttrs = attrs.replace(bindRegex, '').trim();
let isSelfClosing = false;
if (cleanAttrs.endsWith('/')) {
isSelfClosing = true;
cleanAttrs = cleanAttrs.slice(0, -1).trim();
}
const eventName = tagName.toLowerCase() === 'select' ? 'change' : 'input';
const valueAttr = `value="{{ ${bindExpr} }}"`;
const eventAttr = `@${eventName}="${bindExpr} = event.target.value"`;
const suffix = isSelfClosing ? ' />' : '>';
return `<${tagName} ${cleanAttrs} ${valueAttr} ${eventAttr}`.trim().replace(/\s+/g, ' ') + suffix;
});
}
/**
* Processes <@for> loops in the template, converting them to <template> tags
* that can be handled by the runtime for efficient list rendering.
* @param {string} template - The HTML template string.
* @returns {string} The processed template.
* @private
*/
processForLoops(template) {
let currentTemplate = template;
while (true) {
// Matches <@for item in list> or <@for item in list key="item.id">, or closing tag </@for> / </ @for>
const tagRegex = /(<@for\s+(\w+)\s+in\s+([^>]+?)(?:\s+key="([^"]*)")?>)|(<\/ ?@for>)/gi;
let match;
const tags = [];
while ((match = tagRegex.exec(currentTemplate)) !== null) {
if (match[1]) {
tags.push({
type: 'start',
index: match.index,
length: match[0].length,
item: match[2],
list: match[3],
key: match[4]
});
} else {
tags.push({
type: 'end',
index: match.index,
length: match[0].length
});
}
}
if (tags.length === 0) {
break;
}
let innerPair = null;
const stack = [];
for (let i = 0; i < tags.length; i++) {
const tag = tags[i];
if (tag.type === 'start') {
stack.push(tag);
} else {
const startTag = stack.pop();
if (startTag) {
innerPair = { start: startTag, end: tag };
break; // Found innermost loop!
}
}
}
if (!innerPair) {
console.warn("[ComponentParser] Unmatched <@for> tags in template.");
break;
}
const startIdx = innerPair.start.index;
const endIdx = innerPair.end.index + innerPair.end.length;
const bodyStart = startIdx + innerPair.start.length;
const bodyEnd = innerPair.end.index;
const body = currentTemplate.substring(bodyStart, bodyEnd);
// Escape inner interpolation tags to prevent them from being processed
// by the initial template render. They will be processed per-item at runtime.
const escapedBody = body.replace(/\{\{/g, '{%').replace(/\}\}/g, '%}');
let attrs = `data-ax-for="${innerPair.start.list.trim()}" data-ax-as="${innerPair.start.item.trim()}"`;
if (innerPair.start.key) {
attrs += ` data-ax-key="${innerPair.start.key.trim()}"`;
}
const replacement = `<template ${attrs}>${escapedBody}</template>`;
currentTemplate = currentTemplate.substring(0, startIdx) + replacement + currentTemplate.substring(endIdx);
}
return currentTemplate;
}
/**
* Processes component tags recursively to handle transclusion slots.
* Maps `<CompName ...>...</CompName>` to `<div data-avenx-comp="CompName">...</div>`.
* @param {string} template - The template string.
* @returns {string} The processed template.
*/
processComponentTags(template) {
let currentTemplate = template;
while (true) {
// Find the first occurrence of < followed by an uppercase letter
const match = currentTemplate.match(/<([A-Z][a-zA-Z0-9]*)\b/);
if (!match) {
break;
}
const compName = match[1];
const startIndex = match.index;
// Find the end of this opening/self-closing tag
let i = startIndex + 1 + compName.length;
let inQuote = null;
let isSelfClosing = false;
let tagEndIndex = -1;
while (i < currentTemplate.length) {
const char = currentTemplate[i];
if (inQuote) {
if (char === inQuote) {
inQuote = null;
}
} else if (char === '"' || char === "'") {
inQuote = char;
} else if (char === '>') {
const trimmedBefore = currentTemplate.substring(startIndex + 1 + compName.length, i).trim();
if (trimmedBefore.endsWith('/')) {
isSelfClosing = true;
}
tagEndIndex = i + 1;
break;
}
i++;
}
if (tagEndIndex === -1) {
break; // Malformed tag, stop parsing to prevent infinite loops
}
// Extract the attributes string
let attrsStr = currentTemplate.substring(startIndex + 1 + compName.length, tagEndIndex - 1).trim();
if (isSelfClosing && attrsStr.endsWith('/')) {
attrsStr = attrsStr.slice(0, -1).trim();
}
// Parse attributes
const props = [];
const attrRegex = /(\w+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
let attrMatch;
while ((attrMatch = attrRegex.exec(attrsStr)) !== null) {
const attrName = attrMatch[1];
const attrVal = attrMatch[2] !== undefined ? attrMatch[2] : attrMatch[3];
let propExpr;
if (attrVal.startsWith('{{') && attrVal.endsWith('}}')) {
propExpr = attrVal.slice(2, -2).trim();
} else {
const trimmed = attrVal.trim();
if (trimmed === 'true' || trimmed === 'false' || trimmed === 'null' || (trimmed !== '' && !isNaN(trimmed))) {
propExpr = trimmed;
} else {
propExpr = `'${trimmed.replace(/'/g, "\\'")}'`;
}
}
props.push(`data-props-${attrName}="${propExpr}"`);
}
const propsAttr = props.length > 0 ? ` ${props.join(' ')}` : '';
if (isSelfClosing) {
const replacement = `<div data-avenx-comp="${compName}"${propsAttr}></div>`;
currentTemplate = currentTemplate.substring(0, startIndex) + replacement + currentTemplate.substring(tagEndIndex);
} else {
// Find matching closing tag </CompName>
let searchIndex = tagEndIndex;
let depth = 1;
let closingTagIndex = -1;
let closingTagLength = 0;
while (searchIndex < currentTemplate.length) {
const nextOpen = currentTemplate.substring(searchIndex).match(new RegExp(`^<${compName}\\b`));
const nextClose = currentTemplate.substring(searchIndex).match(new RegExp(`^</\\s*${compName}\\s*>`));
if (nextClose) {
depth--;
if (depth === 0) {
closingTagIndex = searchIndex;
closingTagLength = nextClose[0].length;
break;
}
searchIndex += nextClose[0].length;
} else if (nextOpen) {
// Scan to end of this open tag to see if it is self-closing
let tempIdx = searchIndex + nextOpen[0].length;
let tempInQuote = null;
let tempIsSelfClosing = false;
while (tempIdx < currentTemplate.length) {
const tc = currentTemplate[tempIdx];
if (tempInQuote) {
if (tc === tempInQuote) tempInQuote = null;
} else if (tc === '"' || tc === "'") {
tempInQuote = tc;
} else if (tc === '>') {
const trimmedBefore = currentTemplate.substring(searchIndex + nextOpen[0].length, tempIdx).trim();
if (trimmedBefore.endsWith('/')) {
tempIsSelfClosing = true;
}
tempIdx++;
break;
}
tempIdx++;
}
if (!tempIsSelfClosing) {
depth++;
}
searchIndex = tempIdx;
} else {
searchIndex++;
}
}
if (closingTagIndex === -1) {
// No matching closing tag, treat as self-closing
const replacement = `<div data-avenx-comp="${compName}"${propsAttr}></div>`;
currentTemplate = currentTemplate.substring(0, startIndex) + replacement + currentTemplate.substring(tagEndIndex);
} else {
const innerContent = currentTemplate.substring(tagEndIndex, closingTagIndex);
// Recursively process tags inside innerContent
const processedInner = this.processComponentTags(innerContent);
const replacement = `<div data-avenx-comp="${compName}"${propsAttr}>${processedInner}</div>`;
currentTemplate = currentTemplate.substring(0, startIndex) + replacement + currentTemplate.substring(closingTagIndex + closingTagLength);
}
}
}
return currentTemplate;
}
}
module.exports = ComponentParser;