USER
compiler.js
```
// cat/src/compiler.js
const fs = require('fs-extra');
const path = require('path');
const glob = require('glob');
const { parseDocument } = require('htmlparser2');
const acorn = require('acorn'); // Import Acorn for JavaScript parsing
/**
* Parse a component file to extract tagName, HTML, script, and style sections.
* @param {string} filePath - Path to the .comp.html file.
* @returns {Promise<{ tagName: string, html: string, script: string, style: string }>} - Component details.
*/
async function parseComponent(filePath) {
const content = await fs.readFile(filePath, 'utf-8');
// Extract the outermost tag name and inner HTML
const componentRegex = /<([\w-]+)>([\s\S]*?)<\/\1>/;
const componentMatch = content.match(componentRegex);
if (!componentMatch) {
throw new Error(`Invalid component structure in ${path.basename(filePath)}`);
}
const tagName = componentMatch[1];
let innerContent = componentMatch[2];
// Extract all <script> sections
const scriptRegex = /<script>([\s\S]*?)<\/script>/g;
let scriptContent = '';
let scriptMatch;
while ((scriptMatch = scriptRegex.exec(content)) !== null) {
scriptContent += scriptMatch[1].trim() + '\n';
}
// Extract all <style> sections
const styleRegex = /<style>([\s\S]*?)<\/style>/g;
let styleContent = '';
let styleMatch;
while ((styleMatch = styleRegex.exec(content)) !== null) {
styleContent += styleMatch[1].trim() + '\n';
}
// Remove all <script> and <style> tags from innerContent
innerContent = innerContent
.replace(scriptRegex, '')
.replace(styleRegex, '')
.trim();
return {
tagName,
html: innerContent,
script: scriptContent,
style: styleContent,
};
}
/**
* Parse a page file to extract the route and its content.
* @param {string} filePath - Path to the .page.html file.
* @returns {Promise<{ route: string, content: string }>} - Page details.
*/
async function parsePage(filePath) {
const content = await fs.readFile(filePath, 'utf-8');
// Regex to match the <page> tag and extract the route attribute
const pageTagRegex = /<page\s+[^>]*route=["']([^"']+)["'][^>]*>/i;
const match = content.match(pageTagRegex);
if (!match) {
throw new Error(`No valid <page> tag with 'route' attribute found in ${path.basename(filePath)}`);
}
const route = match[1];
// Extract the inner HTML of the <page> tag
const innerContentRegex = new RegExp(`<page[^>]*>([\\s\\S]*?)<\\/page>`, 'i');
const innerMatch = content.match(innerContentRegex);
const pageContent = innerMatch ? innerMatch[1].trim() : '';
return { route, content: pageContent };
}
/**
* Detect used components in the provided HTML contents by parsing the HTML tags.
* @param {Array<string>} htmlContents - Array of HTML file contents.
* @param {Set<string>} availableComponents - Set of all available component tag names.
* @returns {Set<string>} - Set of used component tag names.
*/
function detectUsedComponents(htmlContents, availableComponents) {
const usedComponents = new Set();
// Regex to match all tags in the HTML
const tagRegex = /<\s*([a-zA-Z][a-zA-Z0-9-]*)\b[^>]*>/g;
htmlContents.forEach(content => {
let match;
while ((match = tagRegex.exec(content)) !== null) {
let tag = match[1];
if (availableComponents.has(tag)) {
usedComponents.add(tag);
}
}
});
return usedComponents;
}
/**
* Detect all <load> tags in the provided HTML contents and extract their routes.
* @param {Array<string>} htmlContents - Array of HTML file contents.
* @returns {Array<string>} - Array of routes found in <load> tags.
*/
function detectLoadRoutes(htmlContents) {
const loadRoutes = [];
// Regex to match <load route="..."> tags
const loadTagRegex = /<load\s+[^>]*route=["']([^"']+)["'][^>]*>/gi;
htmlContents.forEach(content => {
let match;
while ((match = loadTagRegex.exec(content)) !== null) {
const route = match[1];
loadRoutes.push(route);
}
});
return loadRoutes;
}
/**
* Generate the registration script for the used components.
* @param {Map<string, string>} components - Map of tagName to compiled class code.
* @returns {string} - JavaScript code for registering components.
*/
function generateRegistrationScript(components) {
let script = '';
components.forEach((classCode) => {
script += `
${classCode}
`;
});
return script;
}
/**
* Convert a kebab-case or other tag name to PascalCase for class naming.
* @param {string} tagName - The custom element tag name.
* @returns {string} - PascalCase string.
*/
function toPascalCase(tagName) {
return tagName
.split('-')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join('');
}
/**
* Escape backticks in templates to avoid breaking template literals.
* @param {string} str - The template string.
* @returns {string} - Escaped string.
*/
function escapeBackticks(str) {
return str.replace(/`/g, '\\`');
}
/**
* Compiler Function
* @param {string} indexPath - Path to the index.html file.
* @returns {Promise<string>} - Compiled HTML content with component registrations and page injections.
*/
async function compile(indexPath) {
// Read the index.html content
let indexContent = await fs.readFile(indexPath, 'utf-8');
// Define project root
const projectRoot = path.dirname(indexPath);
// -----------------------
// Step 1: Process Components
// -----------------------
// Find all .comp.html files in the project directory
const compFiles = glob.sync('**/*.comp.html', {
cwd: projectRoot,
absolute: true,
ignore: ['node_modules/**', 'cat/**'] // Ignore node_modules and cat directory to prevent recursion
});
const componentMap = new Map(); // Map of tagName to compiled class code
const parsedComponents = new Set(); // To track parsed components
// Parse and compile each component file
for (const file of compFiles) {
try {
const { tagName, html, script, style } = await parseComponent(file);
if (!parsedComponents.has(tagName)) {
// Parse script to extract properties and methods
const { properties, methods } = parseScript(script);
// Generate class code
const classCode = generateClassCode({
tagName,
html,
style,
properties,
methods,
});
componentMap.set(tagName, classCode);
parsedComponents.add(tagName);
console.log(`🔍 Parsed and compiled component: <${tagName}> from ${path.relative(projectRoot, file)}`);
} else {
console.log(`ℹ️ Component <${tagName}> from ${path.relative(projectRoot, file)} has already been parsed.`);
}
} catch (error) {
console.error(`❌ Failed to parse ${file}: ${error.message}`);
}
}
if (componentMap.size === 0) {
console.warn('⚠️ No components found to compile.');
}
// -----------------------
// Step 2: Process Pages
// -----------------------
// Find all .page.html files in the project directory
const pageFiles = glob.sync('**/*.page.html', {
cwd: projectRoot,
absolute: true,
ignore: ['node_modules/**', 'cat/**']
});
const routeMap = new Map(); // Map of route to page content
for (const file of pageFiles) {
try {
const { route, content } = await parsePage(file);
if (routeMap.has(route)) {
console.warn(`⚠️ Duplicate route "${route}" found in ${path.relative(projectRoot, file)}. Overwriting previous route.`);
}
routeMap.set(route, content);
console.log(`📄 Mapped route "${route}" to ${path.relative(projectRoot, file)}`);
} catch (error) {
console.error(`❌ Failed to parse page ${file}: ${error.message}`);
}
}
if (routeMap.size === 0) {
console.warn('⚠️ No pages found to map routes.');
}
// -----------------------
// Step 3: Detect Used Components
// -----------------------
// Find all .html files in the project directory for usage detection
const htmlFiles = glob.sync('**/*.html', {
cwd: projectRoot,
absolute: true,
ignore: ['node_modules/**', 'cat/**']
});
const htmlContents = [];
for (const file of htmlFiles) {
try {
const content = await fs.readFile(file, 'utf-8');
htmlContents.push(content);
} catch (error) {
console.error(`❌ Failed to read HTML file ${file}: ${error.message}`);
}
}
// Detect which components are used across all HTML files
const availableComponents = new Set(componentMap.keys());
const usedComponents = detectUsedComponents(htmlContents, availableComponents);
if (usedComponents.size === 0) {
console.warn('⚠️ No used components found in HTML files.');
} else {
console.log(`📦 Used components: ${[...usedComponents].join(', ')}`);
}
// -----------------------
// Step 4: Handle <load> Tags and Inject Page Content
// -----------------------
// Detect all <load> routes in all HTML files
const loadRoutes = detectLoadRoutes(htmlContents);
// Prepare a map of route to page content
// Verify that each load route exists in the routeMap
loadRoutes.forEach(route => {
if (!routeMap.has(route)) {
console.error(`❌ No page found for route "${route}". Please ensure a .page.html file exists with route="${route}".`);
}
});
// Now, replace <load route="..."> tags in indexContent with the corresponding page content
const loadTagRegex = /<load\s+[^>]*route=["']([^"']+)["'][^>]*>/gi;
indexContent = indexContent.replace(loadTagRegex, (match, route) => {
if (routeMap.has(route)) {
console.log(`🔄 Injecting content for route "${route}"`);
return routeMap.get(route);
} else {
console.warn(`⚠️ No content found for route "${route}". Keeping <load> tag.`);
return match; // Keep the <load> tag if no content is found
}
});
// -----------------------
// Step 5: Generate Registration Script
// -----------------------
const registrationScript = generateRegistrationScript(
Array.from(usedComponents).reduce((map, tag) => map.set(tag, componentMap.get(tag)), new Map())
);
// -----------------------
// Step 6: Inject Registration Script
// -----------------------
if (registrationScript.trim()) {
// Inject the registration script before the closing </body> tag
const scriptTag = `<script>
${registrationScript}
</script>
`;
if (indexContent.includes('</body>')) {
indexContent = indexContent.replace('</body>', `${scriptTag}</body>`);
} else {
// If no </body> tag, append the script at the end
indexContent += `\n${scriptTag}`;
}
console.log('📝 Injected component registration scripts.');
}
// -----------------------
// Step 7: Return Compiled Content
// -----------------------
return indexContent;
}
/**
* Parse the script content to extract properties and methods.
* @param {string} script - The script content.
* @returns {{ properties: Array, methods: Array }} - Parsed properties and methods.
*/
function parseScript(script) {
const properties = [];
const methods = [];
try {
// Parse the script content into an AST
const ast = acorn.parse(script, { ecmaVersion: 2020, locations: true });
ast.body.forEach(node => {
if (node.type === 'VariableDeclaration') {
node.declarations.forEach(declarator => {
if (declarator.id.type === 'Identifier') {
const varName = declarator.id.name;
const varValue = script.substring(declarator.init.start, declarator.init.end);
properties.push({ name: varName, value: varValue });
}
});
} else if (node.type === 'FunctionDeclaration') {
const funcName = node.id.name;
const params = node.params.map(param => script.substring(param.start, param.end)).join(', ');
const body = script.substring(node.body.start, node.body.end);
methods.push({ name: funcName, params, body });
}
});
} catch (error) {
console.error(`Error parsing script: ${error.message}`);
}
return { properties, methods };
}
/**
* Generate the class code for the component.
* @param {object} component - Component details.
* @returns {string} - The compiled class code.
*/
function generateClassCode({ tagName, html, style, properties, methods }) {
const className = toPascalCase(tagName);
let classCode = `class ${className} extends HTMLElement {
constructor() {
super();
`;
// Initialize properties
properties.forEach(prop => {
classCode += ` this._${prop.name} = ${prop.value};
`;
});
classCode += `
this.attachShadow({ mode: 'open' });
this._buildDOM();
}
_buildDOM() {
const fragment = document.createDocumentFragment();
`;
// Add styles
if (style) {
classCode += ` const style = document.createElement('style');
style.textContent = \`
${style}
\`;
fragment.appendChild(style);
`;
}
// Generate DOM creation code
const { code: domCode, cachedNodes, eventListeners } = generateDOMCode(html);
classCode += domCode;
classCode += `
this.shadowRoot.appendChild(fragment);
}
`;
// connectedCallback
classCode += `
connectedCallback() {
`;
// Attach event listeners
if (eventListeners && eventListeners.length > 0) {
eventListeners.forEach(listener => {
classCode += ` this.${listener.elementVarName}.addEventListener('${listener.event}', this.${listener.handlerName}.bind(this));
`;
});
}
classCode += ` }
disconnectedCallback() {
`;
// Remove event listeners
if (eventListeners && eventListeners.length > 0) {
eventListeners.forEach(listener => {
classCode += ` this.${listener.elementVarName}.removeEventListener('${listener.event}', this.${listener.handlerName}.bind(this));
`;
});
}
classCode += ` }
`;
// Generate getters and setters
Object.keys(cachedNodes).forEach(propName => {
const nodes = cachedNodes[propName];
classCode += `
get ${propName}() { return this._${propName}; }
set ${propName}(value) {
if (this._${propName} !== value) {
this._${propName} = value;
`;
nodes.forEach(node => {
if (node.type === 'text') {
classCode += ` this.${node.varName}.textContent = ${node.expression};
`;
} else if (node.type === 'attribute') {
classCode += ` this.${node.elementVarName}.setAttribute('${node.attrName}', ${node.expression});
`;
}
});
classCode += ` }
}
`;
});
// Add methods
methods.forEach(method => {
const transformedBody = transformMethodBody(method.body, properties.map(p => p.name));
classCode += `
${method.name}(${method.params}) {
${transformedBody}
}
`;
});
classCode += `}
customElements.define('${tagName}', ${className});
`;
return classCode;
}
/**
* Transform method body to replace variable references with 'this.' prefix.
* @param {string} body - The method body.
* @param {Array<string>} propNames - The property names to replace.
* @returns {string} - The transformed method body.
*/
function transformMethodBody(body, propNames) {
let transformedBody = body;
propNames.forEach(propName => {
// Replace assignments like 'propName = value;' with 'this.propName = value;'
const assignRegex = new RegExp(`([^\\.\\w$])${propName}\\s*=`, 'g');
transformedBody = transformedBody.replace(assignRegex, `$1this.${propName} =`);
// Replace variable references with 'this.propName'
const varRegex = new RegExp(`([^\\.\\w$])${propName}([^\\w$])`, 'g');
transformedBody = transformedBody.replace(varRegex, `$1this.${propName}$2`);
});
// Ensure braces are correctly balanced and formatted
transformedBody = transformedBody.trim();
// Remove extra outer braces if they exist
if (transformedBody.startsWith('{') && transformedBody.endsWith('}')) {
transformedBody = transformedBody.slice(1, -1).trim();
}
// Properly indent the method body
transformedBody = transformedBody
.split('\n')
.map(line => ' ' + line.trim())
.join('\n');
return transformedBody;
}
/**
* Generate DOM creation code from HTML using htmlparser2.
* @param {string} html - The inner HTML string of the component.
* @returns {object} - Object containing generated code, cached nodes, and event listeners.
*/
function generateDOMCode(html) {
const dom = parseDocument(html).children; // Array of DOM nodes
let code = '';
let varCounter = 0;
const cachedNodes = {}; // For properties to nodes
const eventListeners = [];
function traverse(node, parentVarName) {
if (node.type === 'tag') {
const varName = node.name + ++varCounter;
// Decide whether to assign to 'this.' or 'const'
let declaration;
if (needsReference(node)) {
declaration = `this.${varName}`;
} else {
declaration = `const ${varName}`;
}
const varReference = declaration.replace(/^const /, '');
code += ` ${declaration} = document.createElement('${node.name}');
`;
// Handle attributes
const attribs = node.attribs || {};
for (let [attrName, attrValue] of Object.entries(attribs)) {
if (attrName === 'class' || attrName === 'style' || attrName.startsWith('data-') || attrName === 'id' || attrName === 'src' || attrName === 'alt') {
// Handle attributes with embedded expressions
const { expression, propNames } = generateAttributeExpression(attrValue);
code += ` ${varReference}.setAttribute('${attrName}', ${expression});
`;
// Cache nodes for reactive updates
propNames.forEach(propName => {
if (!cachedNodes[propName]) cachedNodes[propName] = [];
cachedNodes[propName].push({
type: 'attribute',
elementVarName: varReference.replace(/^this\./, ''),
attrName: attrName,
expression: generateAttributeExpression(attrValue).expression,
});
});
} else if (attrName.startsWith('on')) {
// Event listener
const event = attrName.substring(2);
const handlerName = attrValue.replace(/\(\)$/, '').trim();
eventListeners.push({ elementVarName: varReference.replace(/^this\./, ''), event, handlerName });
} else {
// For other attributes
code += ` ${varReference}.setAttribute('${attrName}', '${attrValue}');
`;
}
}
// Append to parent
const parentRef = parentVarName ? parentVarName : 'fragment';
code += ` ${parentRef}.appendChild(${varReference});
`;
// Traverse children
if (node.children && node.children.length) {
for (let child of node.children) {
traverse(child, varReference);
}
}
} else if (node.type === 'text') {
const textContent = node.data;
if (textContent.trim()) {
// Handle text nodes with expressions
const { expression, propNames } = generateTextExpression(textContent);
const varName = 'textNode' + ++varCounter;
// Decide whether to assign to 'this.' or 'const'
let declaration;
if (expression.includes('this._')) {
declaration = `this.${varName}`;
} else {
declaration = `const ${varName}`;
}
const varReference = declaration.replace(/^const /, '');
code += ` ${declaration} = document.createTextNode(${expression});
`;
code += ` ${parentVarName}.appendChild(${varReference});
`;
// Cache nodes for reactive updates
propNames.forEach(propName => {
if (!cachedNodes[propName]) cachedNodes[propName] = [];
cachedNodes[propName].push({
type: 'text',
varName: varReference.replace(/^this\./, ''),
expression: generateTextExpression(textContent).expression,
});
});
}
}
}
function needsReference(node) {
// Determines if the element needs to be assigned to 'this.'
// Elements that are:
// - Targets of event listeners
// - Contain reactive expressions
// Need to be accessible outside of _buildDOM
if (node.attribs) {
const hasEventListener = Object.keys(node.attribs).some(attr => attr.startsWith('on'));
const hasReactiveAttr = Object.values(node.attribs).some(value => /\{([\w$]+)\}/.test(value));
if (hasEventListener || hasReactiveAttr) {
return true;
}
}
if (node.children && node.children.some(child => child.type === 'text' && /\{([\w$]+)\}/.test(child.data))) {
return true;
}
return false;
}
function extractPropNames(textContent) {
const exprRegex = /\{([\w$]+)\}/g;
let propNames = [];
let match;
while ((match = exprRegex.exec(textContent)) !== null) {
propNames.push(match[1]);
}
return [...new Set(propNames)]; // Remove duplicates
}
function generateTextExpression(textContent) {
const exprRegex = /\{([\w$]+)\}/g;
let segments = [];
let propNames = [];
let lastIndex = 0;
let match;
while ((match = exprRegex.exec(textContent)) !== null) {
if (match.index > lastIndex) {
segments.push(JSON.stringify(textContent.slice(lastIndex, match.index)));
}
segments.push(`this._${match[1]}`);
propNames.push(match[1]);
lastIndex = exprRegex.lastIndex;
}
if (lastIndex < textContent.length) {
segments.push(JSON.stringify(textContent.slice(lastIndex)));
}
return {
expression: segments.join(' + '),
propNames,
};
}
function generateAttributeExpression(attrValue) {
const exprRegex = /\{([\w$]+)\}/g;
let segments = [];
let propNames = [];
let lastIndex = 0;
let match;
while ((match = exprRegex.exec(attrValue)) !== null) {
if (match.index > lastIndex) {
segments.push(JSON.stringify(attrValue.slice(lastIndex, match.index)));
}
segments.push(`this._${match[1]}`);
propNames.push(match[1]);
lastIndex = exprRegex.lastIndex;
}
if (lastIndex < attrValue.length) {
segments.push(JSON.stringify(attrValue.slice(lastIndex)));
}
return {
expression: segments.join(' + '),
propNames,
};
}
dom.forEach(node => traverse(node, null));
return { code, cachedNodes, eventListeners };
}
module.exports = { compile };
```
Your task it's simple, divide the compiler.js into three different files, compiler.js, component.js and page.js. (if you have a better naming for the new two). You don't have to modify anything from our logic, just set an independent file for each specialized module, and make sure everything fits perfect on the compiler.js, which is the core file.ASSISTANT
Certainly! To modularize your `compiler.js` into three separate files—**`compiler.js`**, **`component.js`**, and **`page.js`**—we'll distribute the existing functions based on their responsibilities. This approach enhances maintainability and readability by segregating component-related logic, page-related logic, and the core compilation process.
Below are the reorganized files:
---
## 1. `compiler.js`
**Description:**
This is the core file orchestrating the compilation process. It imports specialized functions from `component.js` and `page.js`, handles the overall workflow, and manages interactions between components and pages.
```javascript
// cat/src/compiler.js
const fs = require('fs-extra');
const path = require('path');
const glob = require('glob');
const { detectUsedComponents, detectLoadRoutes, generateRegistrationScript } = require('./utils');
const { parseComponent, generateClassCode, parseScript, transformMethodBody, generateDOMCode } = require('./component');
const { parsePage } = require('./page');
/**
* Compiler Function
* @param {string} indexPath - Path to the index.html file.
* @returns {Promise<string>} - Compiled HTML content with component registrations and page injections.
*/
async function compile(indexPath) {
// Read the index.html content
let indexContent = await fs.readFile(indexPath, 'utf-8');
// Define project root
const projectRoot = path.dirname(indexPath);
// -----------------------
// Step 1: Process Components
// -----------------------
// Find all .comp.html files in the project directory
const compFiles = glob.sync('**/*.comp.html', {
cwd: projectRoot,
absolute: true,
ignore: ['node_modules/**', 'cat/**'] // Ignore node_modules and cat directory to prevent recursion
});
const componentMap = new Map(); // Map of tagName to compiled class code
const parsedComponents = new Set(); // To track parsed components
// Parse and compile each component file
for (const file of compFiles) {
try {
const { tagName, html, script, style } = await parseComponent(file);
if (!parsedComponents.has(tagName)) {
// Parse script to extract properties and methods
const { properties, methods } = parseScript(script);
// Generate class code
const classCode = generateClassCode({
tagName,
html,
style,
properties,
methods,
});
componentMap.set(tagName, classCode);
parsedComponents.add(tagName);
console.log(`🔍 Parsed and compiled component: <${tagName}> from ${path.relative(projectRoot, file)}`);
} else {
console.log(`ℹ️ Component <${tagName}> from ${path.relative(projectRoot, file)} has already been parsed.`);
}
} catch (error) {
console.error(`❌ Failed to parse ${file}: ${error.message}`);
}
}
if (componentMap.size === 0) {
console.warn('⚠️ No components found to compile.');
}
// -----------------------
// Step 2: Process Pages
// -----------------------
// Find all .page.html files in the project directory
const pageFiles = glob.sync('**/*.page.html', {
cwd: projectRoot,
absolute: true,
ignore: ['node_modules/**', 'cat/**']
});
const routeMap = new Map(); // Map of route to page content
for (const file of pageFiles) {
try {
const { route, content } = await parsePage(file);
if (routeMap.has(route)) {
console.warn(`⚠️ Duplicate route "${route}" found in ${path.relative(projectRoot, file)}. Overwriting previous route.`);
}
routeMap.set(route, content);
console.log(`📄 Mapped route "${route}" to ${path.relative(projectRoot, file)}`);
} catch (error) {
console.error(`❌ Failed to parse page ${file}: ${error.message}`);
}
}
if (routeMap.size === 0) {
console.warn('⚠️ No pages found to map routes.');
}
// -----------------------
// Step 3: Detect Used Components
// -----------------------
// Find all .html files in the project directory for usage detection
const htmlFiles = glob.sync('**/*.html', {
cwd: projectRoot,
absolute: true,
ignore: ['node_modules/**', 'cat/**']
});
const htmlContents = [];
for (const file of htmlFiles) {
try {
const content = await fs.readFile(file, 'utf-8');
htmlContents.push(content);
} catch (error) {
console.error(`❌ Failed to read HTML file ${file}: ${error.message}`);
}
}
// Detect which components are used across all HTML files
const availableComponents = new Set(componentMap.keys());
const usedComponents = detectUsedComponents(htmlContents, availableComponents);
if (usedComponents.size === 0) {
console.warn('⚠️ No used components found in HTML files.');
} else {
console.log(`📦 Used components: ${[...usedComponents].join(', ')}`);
}
// -----------------------
// Step 4: Handle <load> Tags and Inject Page Content
// -----------------------
// Detect all <load> routes in all HTML files
const loadRoutes = detectLoadRoutes(htmlContents);
// Prepare a map of route to page content
// Verify that each load route exists in the routeMap
loadRoutes.forEach(route => {
if (!routeMap.has(route)) {
console.error(`❌ No page found for route "${route}". Please ensure a .page.html file exists with route="${route}".`);
}
});
// Now, replace <load route="..."> tags in indexContent with the corresponding page content
const loadTagRegex = /<load\s+[^>]*route=["']([^"']+)["'][^>]*>/gi;
indexContent = indexContent.replace(loadTagRegex, (match, route) => {
if (routeMap.has(route)) {
console.log(`🔄 Injecting content for route "${route}"`);
return routeMap.get(route);
} else {
console.warn(`⚠️ No content found for route "${route}". Keeping <load> tag.`);
return match; // Keep the <load> tag if no content is found
}
});
// -----------------------
// Step 5: Generate Registration Script
// -----------------------
const registrationScript = generateRegistrationScript(
Array.from(usedComponents).reduce((map, tag) => map.set(tag, componentMap.get(tag)), new Map())
);
// -----------------------
// Step 6: Inject Registration Script
// -----------------------
if (registrationScript.trim()) {
// Inject the registration script before the closing </body> tag
const scriptTag = `<script>
${registrationScript}
</script>
`;
if (indexContent.includes('</body>')) {
indexContent = indexContent.replace('</body>', `${scriptTag}</body>`);
} else {
// If no </body> tag, append the script at the end
indexContent += `\n${scriptTag}`;
}
console.log('📝 Injected component registration scripts.');
}
// -----------------------
// Step 7: Return Compiled Content
// -----------------------
return indexContent;
}
module.exports = { compile };
```
---
## 2. `component.js`
**Description:**
This file encapsulates all component-related functionalities, including parsing component files, generating class code, and handling script transformations. It ensures that component logic is isolated from the core compiler logic.
```javascript
// cat/src/component.js
const fs = require('fs-extra');
const path = require('path');
const { parseDocument } = require('htmlparser2');
const acorn = require('acorn');
/**
* Parse a component file to extract tagName, HTML, script, and style sections.
* @param {string} filePath - Path to the .comp.html file.
* @returns {Promise<{ tagName: string, html: string, script: string, style: string }>} - Component details.
*/
async function parseComponent(filePath) {
const content = await fs.readFile(filePath, 'utf-8');
// Extract the outermost tag name and inner HTML
const componentRegex = /<([\w-]+)>([\s\S]*?)<\/\1>/;
const componentMatch = content.match(componentRegex);
if (!componentMatch) {
throw new Error(`Invalid component structure in ${path.basename(filePath)}`);
}
const tagName = componentMatch[1];
let innerContent = componentMatch[2];
// Extract all <script> sections
const scriptRegex = /<script>([\s\S]*?)<\/script>/g;
let scriptContent = '';
let scriptMatch;
while ((scriptMatch = scriptRegex.exec(content)) !== null) {
scriptContent += scriptMatch[1].trim() + '\n';
}
// Extract all <style> sections
const styleRegex = /<style>([\s\S]*?)<\/style>/g;
let styleContent = '';
let styleMatch;
while ((styleMatch = styleRegex.exec(content)) !== null) {
styleContent += styleMatch[1].trim() + '\n';
}
// Remove all <script> and <style> tags from innerContent
innerContent = innerContent
.replace(scriptRegex, '')
.replace(styleRegex, '')
.trim();
return {
tagName,
html: innerContent,
script: scriptContent,
style: styleContent,
};
}
/**
* Parse the script content to extract properties and methods.
* @param {string} script - The script content.
* @returns {{ properties: Array, methods: Array }} - Parsed properties and methods.
*/
function parseScript(script) {
const properties = [];
const methods = [];
try {
// Parse the script content into an AST
const ast = acorn.parse(script, { ecmaVersion: 2020, locations: true });
ast.body.forEach(node => {
if (node.type === 'VariableDeclaration') {
node.declarations.forEach(declarator => {
if (declarator.id.type === 'Identifier') {
const varName = declarator.id.name;
const varValue = script.substring(declarator.init.start, declarator.init.end);
properties.push({ name: varName, value: varValue });
}
});
} else if (node.type === 'FunctionDeclaration') {
const funcName = node.id.name;
const params = node.params.map(param => script.substring(param.start, param.end)).join(', ');
const body = script.substring(node.body.start, node.body.end);
methods.push({ name: funcName, params, body });
}
});
} catch (error) {
console.error(`Error parsing script: ${error.message}`);
}
return { properties, methods };
}
/**
* Convert a kebab-case or other tag name to PascalCase for class naming.
* @param {string} tagName - The custom element tag name.
* @returns {string} - PascalCase string.
*/
function toPascalCase(tagName) {
return tagName
.split('-')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join('');
}
/**
* Escape backticks in templates to avoid breaking template literals.
* @param {string} str - The template string.
* @returns {string} - Escaped string.
*/
function escapeBackticks(str) {
return str.replace(/`/g, '\\`');
}
/**
* Generate the class code for the component.
* @param {object} component - Component details.
* @returns {string} - The compiled class code.
*/
function generateClassCode({ tagName, html, style, properties, methods }) {
const className = toPascalCase(tagName);
let classCode = `class ${className} extends HTMLElement {
constructor() {
super();
`;
// Initialize properties
properties.forEach(prop => {
classCode += ` this._${prop.name} = ${prop.value};
`;
});
classCode += `
this.attachShadow({ mode: 'open' });
this._buildDOM();
}
_buildDOM() {
const fragment = document.createDocumentFragment();
`;
// Add styles
if (style) {
classCode += ` const style = document.createElement('style');
style.textContent = \`
${style}
\`;
fragment.appendChild(style);
`;
}
// Generate DOM creation code
const { code: domCode, cachedNodes, eventListeners } = generateDOMCode(html);
classCode += domCode;
classCode += `
this.shadowRoot.appendChild(fragment);
}
`;
// connectedCallback
classCode += `
connectedCallback() {
`;
// Attach event listeners
if (eventListeners && eventListeners.length > 0) {
eventListeners.forEach(listener => {
classCode += ` this.${listener.elementVarName}.addEventListener('${listener.event}', this.${listener.handlerName}.bind(this));
`;
});
}
classCode += ` }
disconnectedCallback() {
`;
// Remove event listeners
if (eventListeners && eventListeners.length > 0) {
eventListeners.forEach(listener => {
classCode += ` this.${listener.elementVarName}.removeEventListener('${listener.event}', this.${listener.handlerName}.bind(this));
`;
});
}
classCode += ` }
`;
// Generate getters and setters
Object.keys(cachedNodes).forEach(propName => {
const nodes = cachedNodes[propName];
classCode += `
get ${propName}() { return this._${propName}; }
set ${propName}(value) {
if (this._${propName} !== value) {
this._${propName} = value;
`;
nodes.forEach(node => {
if (node.type === 'text') {
classCode += ` this.${node.varName}.textContent = ${node.expression};
`;
} else if (node.type === 'attribute') {
classCode += ` this.${node.elementVarName}.setAttribute('${node.attrName}', ${node.expression});
`;
}
});
classCode += ` }
}
`;
});
// Add methods
methods.forEach(method => {
const transformedBody = transformMethodBody(method.body, properties.map(p => p.name));
classCode += `
${method.name}(${method.params}) {
${transformedBody}
}
`;
});
classCode += `}
customElements.define('${tagName}', ${className});
`;
return classCode;
}
/**
* Transform method body to replace variable references with 'this.' prefix.
* @param {string} body - The method body.
* @param {Array<string>} propNames - The property names to replace.
* @returns {string} - The transformed method body.
*/
function transformMethodBody(body, propNames) {
let transformedBody = body;
propNames.forEach(propName => {
// Replace assignments like 'propName = value;' with 'this.propName = value;'
const assignRegex = new RegExp(`([^\\.\\w$])${propName}\\s*=`, 'g');
transformedBody = transformedBody.replace(assignRegex, `$1this.${propName} =`);
// Replace variable references with 'this.propName'
const varRegex = new RegExp(`([^\\.\\w$])${propName}([^\\w$])`, 'g');
transformedBody = transformedBody.replace(varRegex, `$1this.${propName}$2`);
});
// Ensure braces are correctly balanced and formatted
transformedBody = transformedBody.trim();
// Remove extra outer braces if they exist
if (transformedBody.startsWith('{') && transformedBody.endsWith('}')) {
transformedBody = transformedBody.slice(1, -1).trim();
}
// Properly indent the method body
transformedBody = transformedBody
.split('\n')
.map(line => ' ' + line.trim())
.join('\n');
return transformedBody;
}
/**
* Generate DOM creation code from HTML using htmlparser2.
* @param {string} html - The inner HTML string of the component.
* @returns {object} - Object containing generated code, cached nodes, and event listeners.
*/
function generateDOMCode(html) {
const dom = parseDocument(html).children; // Array of DOM nodes
let code = '';
let varCounter = 0;
const cachedNodes = {}; // For properties to nodes
const eventListeners = [];
function traverse(node, parentVarName) {
if (node.type === 'tag') {
const varName = node.name + ++varCounter;
// Decide whether to assign to 'this.' or 'const'
let declaration;
if (needsReference(node)) {
declaration = `this.${varName}`;
} else {
declaration = `const ${varName}`;
}
const varReference = declaration.replace(/^const /, '');
code += ` ${declaration} = document.createElement('${node.name}');
`;
// Handle attributes
const attribs = node.attribs || {};
for (let [attrName, attrValue] of Object.entries(attribs)) {
if (attrName === 'class' || attrName === 'style' || attrName.startsWith('data-') || attrName === 'id' || attrName === 'src' || attrName === 'alt') {
// Handle attributes with embedded expressions
const { expression, propNames } = generateAttributeExpression(attrValue);
code += ` ${varReference}.setAttribute('${attrName}', ${expression});
`;
// Cache nodes for reactive updates
propNames.forEach(propName => {
if (!cachedNodes[propName]) cachedNodes[propName] = [];
cachedNodes[propName].push({
type: 'attribute',
elementVarName: varReference.replace(/^this\./, ''),
attrName: attrName,
expression: generateAttributeExpression(attrValue).expression,
});
});
} else if (attrName.startsWith('on')) {
// Event listener
const event = attrName.substring(2);
const handlerName = attrValue.replace(/\(\)$/, '').trim();
eventListeners.push({ elementVarName: varReference.replace(/^this\./, ''), event, handlerName });
} else {
// For other attributes
code += ` ${varReference}.setAttribute('${attrName}', '${attrValue}');
`;
}
}
// Append to parent
const parentRef = parentVarName ? parentVarName : 'fragment';
code += ` ${parentRef}.appendChild(${varReference});
`;
// Traverse children
if (node.children && node.children.length) {
for (let child of node.children) {
traverse(child, varReference);
}
}
} else if (node.type === 'text') {
const textContent = node.data;
if (textContent.trim()) {
// Handle text nodes with expressions
const { expression, propNames } = generateTextExpression(textContent);
const varName = 'textNode' + ++varCounter;
// Decide whether to assign to 'this.' or 'const'
let declaration;
if (expression.includes('this._')) {
declaration = `this.${varName}`;
} else {
declaration = `const ${varName}`;
}
const varReference = declaration.replace(/^const /, '');
code += ` ${declaration} = document.createTextNode(${expression});
`;
code += ` ${parentVarName}.appendChild(${varReference});
`;
// Cache nodes for reactive updates
propNames.forEach(propName => {
if (!cachedNodes[propName]) cachedNodes[propName] = [];
cachedNodes[propName].push({
type: 'text',
varName: varReference.replace(/^this\./, ''),
expression: generateTextExpression(textContent).expression,
});
});
}
}
}
function needsReference(node) {
// Determines if the element needs to be assigned to 'this.'
// Elements that are:
// - Targets of event listeners
// - Contain reactive expressions
// Need to be accessible outside of _buildDOM
if (node.attribs) {
const hasEventListener = Object.keys(node.attribs).some(attr => attr.startsWith('on'));
const hasReactiveAttr = Object.values(node.attribs).some(value => /\{([\w$]+)\}/.test(value));
if (hasEventListener || hasReactiveAttr) {
return true;
}
}
if (node.children && node.children.some(child => child.type === 'text' && /\{([\w$]+)\}/.test(child.data))) {
return true;
}
return false;
}
function extractPropNames(textContent) {
const exprRegex = /\{([\w$]+)\}/g;
let propNames = [];
let match;
while ((match = exprRegex.exec(textContent)) !== null) {
propNames.push(match[1]);
}
return [...new Set(propNames)]; // Remove duplicates
}
function generateTextExpression(textContent) {
const exprRegex = /\{([\w$]+)\}/g;
let segments = [];
let propNames = [];
let lastIndex = 0;
let match;
while ((match = exprRegex.exec(textContent)) !== null) {
if (match.index > lastIndex) {
segments.push(JSON.stringify(textContent.slice(lastIndex, match.index)));
}
segments.push(`this._${match[1]}`);
propNames.push(match[1]);
lastIndex = exprRegex.lastIndex;
}
if (lastIndex < textContent.length) {
segments.push(JSON.stringify(textContent.slice(lastIndex)));
}
return {
expression: segments.join(' + '),
propNames,
};
}
function generateAttributeExpression(attrValue) {
const exprRegex = /\{([\w$]+)\}/g;
let segments = [];
let propNames = [];
let lastIndex = 0;
let match;
while ((match = exprRegex.exec(attrValue)) !== null) {
if (match.index > lastIndex) {
segments.push(JSON.stringify(attrValue.slice(lastIndex, match.index)));
}
segments.push(`this._${match[1]}`);
propNames.push(match[1]);
lastIndex = exprRegex.lastIndex;
}
if (lastIndex < attrValue.length) {
segments.push(JSON.stringify(attrValue.slice(lastIndex)));
}
return {
expression: segments.join(' + '),
propNames,
};
}
dom.forEach(node => traverse(node, null));
return { code, cachedNodes, eventListeners };
}
module.exports = {
parseComponent,
generateClassCode,
parseScript,
transformMethodBody,
generateDOMCode,
};
```
---
## 3. `page.js`
**Description:**
This file focuses on page-related functionalities, primarily parsing page files to extract routes and their corresponding content. It ensures that page management is decoupled from component logic.
```javascript
// cat/src/page.js
const fs = require('fs-extra');
const path = require('path');
/**
* Parse a page file to extract the route and its content.
* @param {string} filePath - Path to the .page.html file.
* @returns {Promise<{ route: string, content: string }>} - Page details.
*/
async function parsePage(filePath) {
const content = await fs.readFile(filePath, 'utf-8');
// Regex to match the <page> tag and extract the route attribute
const pageTagRegex = /<page\s+[^>]*route=["']([^"']+)["'][^>]*>/i;
const match = content.match(pageTagRegex);
if (!match) {
throw new Error(`No valid <page> tag with 'route' attribute found in ${path.basename(filePath)}`);
}
const route = match[1];
// Extract the inner HTML of the <page> tag
const innerContentRegex = new RegExp(`<page[^>]*>([\\s\\S]*?)<\\/page>`, 'i');
const innerMatch = content.match(innerContentRegex);
const pageContent = innerMatch ? innerMatch[1].trim() : '';
return { route, content: pageContent };
}
module.exports = { parsePage };
```
---
## 4. `utils.js` *(Optional but Recommended)*
**Description:**
To further enhance modularity and avoid cluttering `compiler.js`, it's advisable to extract utility functions into a separate `utils.js` file. This file contains functions that are general-purpose and used across multiple modules.
```javascript
// cat/src/utils.js
/**
* Detect used components in the provided HTML contents by parsing the HTML tags.
* @param {Array<string>} htmlContents - Array of HTML file contents.
* @param {Set<string>} availableComponents - Set of all available component tag names.
* @returns {Set<string>} - Set of used component tag names.
*/
function detectUsedComponents(htmlContents, availableComponents) {
const usedComponents = new Set();
// Regex to match all tags in the HTML
const tagRegex = /<\s*([a-zA-Z][a-zA-Z0-9-]*)\b[^>]*>/g;
htmlContents.forEach(content => {
let match;
while ((match = tagRegex.exec(content)) !== null) {
let tag = match[1];
if (availableComponents.has(tag)) {
usedComponents.add(tag);
}
}
});
return usedComponents;
}
/**
* Detect all <load> tags in the provided HTML contents and extract their routes.
* @param {Array<string>} htmlContents - Array of HTML file contents.
* @returns {Array<string>} - Array of routes found in <load> tags.
*/
function detectLoadRoutes(htmlContents) {
const loadRoutes = [];
// Regex to match <load route="..."> tags
const loadTagRegex = /<load\s+[^>]*route=["']([^"']+)["'][^>]*>/gi;
htmlContents.forEach(content => {
let match;
while ((match = loadTagRegex.exec(content)) !== null) {
const route = match[1];
loadRoutes.push(route);
}
});
return loadRoutes;
}
/**
* Generate the registration script for the used components.
* @param {Map<string, string>} components - Map of tagName to compiled class code.
* @returns {string} - JavaScript code for registering components.
*/
function generateRegistrationScript(components) {
let script = '';
components.forEach((classCode) => {
script += `
${classCode}
`;
});
return script;
}
module.exports = {
detectUsedComponents,
detectLoadRoutes,
generateRegistrationScript,
};
```
**Note:**
To use `utils.js`, ensure you update your `compiler.js` to import from it as shown in the above `compiler.js` file.
---
## Summary of Changes
1. **Created Three Files:**
- `compiler.js`: Core compilation logic.
- `component.js`: Component-related operations.
- `page.js`: Page-related operations.
- *(Optional)* `utils.js`: Shared utility functions.
2. **Updated `compiler.js`:**
- Imported necessary functions from `component.js`, `page.js`, and `utils.js`.
- Cleaned up the main compilation logic to utilize modular functions.
3. **Isolated Component Logic:**
- All functions related to component parsing, script handling, and class generation are moved to `component.js`.
4. **Isolated Page Logic:**
- Page parsing functions are moved to `page.js`.
5. **Optional Utility Isolation:**
- General-purpose functions are moved to `utils.js` to keep `compiler.js` clean and focused.
This modular approach ensures that each file has a single responsibility, making the codebase easier to maintain, extend, and debug.