// JQHTML Code Generator - Converts AST to JavaScript functions
// Generates v1-compatible instruction arrays for efficient DOM construction
import { NodeType } from './ast.js';
import { SourceMapGenerator } from 'source-map';
import { JQHTMLParseError } from './errors.js';
import * as vm from 'vm';
export class CodeGenerator {
indent_level = 0;
components = new Map();
current_component = null;
in_slot = false;
tag_depth = 0;
lastOutput = ''; // Track last generated output for deduplication
// Position tracking for source maps
outputLine = 1;
outputColumn = 0;
sourceMapGenerator;
sourceContent;
sourceFile;
outputBuffer = [];
// Enable position tracking for debugging
enablePositionTracking = false;
positionLog = [];
// Line preservation for 1:1 source mapping
currentOutputLine = 1;
preserveLines = false;
// For true 1:1 line mapping
outputLines = [];
sourceLines = [];
generate(ast, sourceFile, sourceContent) {
// Reset state
this.components.clear();
this.current_component = null;
this.tag_depth = 0;
this.resetPositionTracking();
// Store source info for error reporting
this.sourceFile = sourceFile;
this.sourceContent = sourceContent;
// Process all top-level nodes
for (const node of ast.body) {
if (node.type === NodeType.COMPONENT_DEFINITION) {
this.generate_component(node);
}
}
// Build the final code
const code = this.build_module_code();
return {
code,
components: this.components
};
}
/**
* Generate code with source maps using 1:1 line mapping + SourceMapGenerator
*/
generateWithSourceMap(ast, sourceFile, sourceContent) {
// Use the regular generate method which creates 1:1 line mapping
const result = this.generate(ast, sourceFile, sourceContent);
// Now create a proper sourcemap for each component using SourceMapGenerator
const componentsWithSourcemaps = new Map();
for (const [name, component] of result.components) {
// Create a SourceMapGenerator for this component
const generator = new SourceMapGenerator({
file: sourceFile.replace(/\.jqhtml$/, '.js')
});
generator.setSourceContent(sourceFile, sourceContent);
// Parse the render function to find line count
const renderLines = component.render_function.split('\n');
// The first line is the function declaration - maps to the Define line (line 1)
generator.addMapping({
generated: { line: 1, column: 0 },
source: sourceFile,
original: { line: 1, column: 0 }
});
// Map ALL lines in the generated output
// This ensures that even lines beyond the source file length are mapped
const sourceLines = sourceContent.split('\n');
const numSourceLines = sourceLines.length;
// CRITICAL FIX: The render function has a consistent offset from source
// We need to map with the proper offset, not naive 1:1
// The parser generates code with a fixed offset (typically around 10-13 lines)
// Calculate the actual offset by looking at the generated code
// The render function's first real content line starts around line 2
// but it maps to source line 1 (the Define line)
// After that, we need to maintain proper offset mapping
// For now, use the simple 1:1 mapping with clamping
// The CLI's adjustSourcemapForCLI will handle the offset adjustment
for (let lineNum = 2; lineNum <= renderLines.length; lineNum++) {
// Map each line to its corresponding source line
// When we go beyond source length, clamp to last source line
const sourceLine = Math.min(lineNum, numSourceLines);
generator.addMapping({
generated: { line: lineNum, column: 0 },
source: sourceFile,
original: { line: sourceLine, column: 0 }
});
}
// Generate the sourcemap
const sourcemap = generator.toJSON();
const sourcemapJson = JSON.stringify(sourcemap);
const sourcemapBase64 = Buffer.from(sourcemapJson).toString('base64');
const sourceMapDataUri = `data:application/json;charset=utf-8;base64,${sourcemapBase64}`;
// Add inline sourcemap to render function
const renderWithSourcemap = component.render_function +
'\n//# sourceMappingURL=' + sourceMapDataUri;
componentsWithSourcemaps.set(name, {
...component,
render_function: renderWithSourcemap
});
}
return {
code: result.code,
components: componentsWithSourcemaps,
source_map: JSON.stringify({}), // Not used for inline sourcemaps
source_map_data_uri: '' // Not used for inline sourcemaps
};
}
generate_component_with_mappings_TESTING(node) {
// Extract metadata
let tagName = 'div';
const defaultAttributes = {};
const dependencies = new Set();
// Extract new fields from Define tag
const defineArgs = node.defineArgs;
const extendsValue = node.extends;
for (const [key, value] of Object.entries(node.attributes)) {
if (key === 'tag') {
if (typeof value === 'object' && value.quoted) {
tagName = value.value;
}
else if (typeof value === 'string') {
tagName = value;
}
}
else {
if (typeof value === 'object' && value.quoted) {
defaultAttributes[key] = value.value;
}
else if (typeof value === 'object' && value.expression) {
defaultAttributes[key] = value.value;
}
else if (typeof value === 'object' && value.identifier) {
defaultAttributes[key] = value.value;
}
else {
defaultAttributes[key] = value;
}
}
}
// Reset output tracking for this component
this.outputLine = 1;
this.outputColumn = 0;
this.outputBuffer = [];
// Generate function header
const header = 'function render(data, args, content, jqhtml) { let _output = []; const _cid = this._cid; const that = this;\n';
this.outputBuffer.push(header);
this.outputLine = 2; // We're now on line 2 after the header
// Generate function body - each node's emit() will track its position
for (const childNode of node.body) {
const childCode = this.generate_node(childNode);
if (childCode) {
// Don't add extra space - let the code maintain its position
this.outputBuffer.push(childCode);
}
}
// Add the return statement
this.outputBuffer.push('return [_output, this]; }');
// Combine all the output
const renderFunction = this.outputBuffer.join('');
// Store the component
this.components.set(node.name, {
name: node.name,
render_function: renderFunction,
dependencies: Array.from(dependencies),
tagName,
defaultAttributes,
defineArgs,
extends: extendsValue
});
}
generate_component(node) {
this.current_component = node.name;
this.lastOutput = ''; // Reset output tracking for each component
const dependencies = new Set();
// Always use 1:1 line mapping for proper sourcemaps
// Even when using SourceMapGenerator, we need the line structure
// Enable line preservation for 1:1 source maps
this.preserveLines = true;
this.currentOutputLine = 1;
// Initialize output lines array for 1:1 mapping
// We'll build the function line by line to match source structure
this.outputLines = [];
// Extract metadata and default attributes
// Handle the 'tag' attribute which might be a quoted object from the parser
let tagName = 'div';
const defaultAttributes = {};
// Extract new fields from Define tag (extends and defineArgs)
const defineArgs = node.defineArgs;
const extendsValue = node.extends;
// Process all attributes from the Define tag
for (const [key, value] of Object.entries(node.attributes)) {
if (key === 'tag') {
// Special handling for 'tag' attribute
if (typeof value === 'object' && value.quoted) {
tagName = value.value;
}
else if (typeof value === 'string') {
tagName = value;
}
}
else {
// Store as default attribute - extract actual value from quoted objects
if (typeof value === 'object' && value.quoted) {
defaultAttributes[key] = value.value;
}
else if (typeof value === 'object' && value.expression) {
// For expressions, we need to store them as is to be evaluated at runtime
defaultAttributes[key] = value.value;
}
else if (typeof value === 'object' && value.identifier) {
// For identifiers, store as is to be evaluated at runtime
defaultAttributes[key] = value.value;
}
else {
defaultAttributes[key] = value;
}
}
}
// Check if this is a slot-only template (template inheritance pattern)
if (node.isSlotOnly && node.slotNames && node.slotNames.length > 0) {
// Generate slot-only render function with 1:1 line mapping
// Extract slot nodes from body
const slots = {};
for (const child of node.body) {
if (child.type === NodeType.SLOT) {
const slotNode = child;
slots[slotNode.name] = slotNode;
}
}
// Filter out TEXT nodes (whitespace) from slot-only templates
// TEXT nodes cause _output.push() calls which are invalid in slot-only context
const slotsOnly = node.body.filter(child => child.type === NodeType.SLOT);
// Use 1:1 line mapping for slot-only templates
const bodyLines = this.generate_function_body_1to1(slotsOnly);
// Build the render function with line preservation
const lines = [];
// Line 1: function declaration returning slots object
lines.push(`function render(data, args, content, jqhtml) { return [{_slots: {`);
// Lines 2-N: Body lines with slot functions
lines.push(...bodyLines);
// Fix trailing comma on last slot function
// Find the last line that contains '.bind(this),' and remove the comma
for (let i = lines.length - 1; i >= 0; i--) {
if (lines[i] && lines[i].includes('.bind(this),')) {
lines[i] = lines[i].replace(/\.bind\(this\),([^,]*)$/, '.bind(this)$1');
break;
}
}
// Last line: close slots object and return
if (lines[lines.length - 1]) {
lines[lines.length - 1] += ' }}, this]; }';
}
else {
lines[lines.length - 1] = '}}, this]; }';
}
const render_function = lines.join('\n');
// Store component with slot-only metadata
this.components.set(node.name, {
name: node.name,
render_function,
dependencies: Array.from(dependencies),
tagName,
defaultAttributes,
defineArgs,
extends: extendsValue
});
this.current_component = null;
return;
}
// For 1:1 line mapping, we need to generate the body first to know how many lines it has
const bodyLines = this.generate_function_body_1to1(node.body);
// Now create the render function with 1:1 line mapping
// Line 1 of input (Define tag) becomes the function declaration
const lines = [];
// First line: function declaration and initial setup (corresponds to Define line)
lines.push(`function render(data, args, content, jqhtml) { let _output = []; const _cid = this._cid; const that = this;`);
// Body lines: each corresponds to a source line
lines.push(...bodyLines);
// Last line: closing (corresponds to closing Define tag)
if (lines[lines.length - 1]) {
lines[lines.length - 1] += ' return [_output, this]; }';
}
else {
lines[lines.length - 1] = 'return [_output, this]; }';
}
const render_function = lines.join('\n');
// Validate the generated function syntax using vm.Script for better error reporting
try {
const generatedCode = `
let _output = [];
const _cid = this._cid;
const that = this;
${bodyLines.join('\n')}
`;
new vm.Script(generatedCode, {
filename: this.sourceFile || 'component.jqhtml'
});
}
catch (error) {
// Extract JavaScript error details
const errorDetail = error.message || String(error);
let sourceLine = 1; // Default to component definition line (line numbers not critical for bracket errors)
let sourceColumn = 0;
// Attempt to extract line number from vm.Script error if available
if (typeof error.lineNumber === 'number') {
const generatedLine = error.lineNumber;
// Map generated line to source line
// The bodyLines array has 1:1 correspondence with source lines
// Generated code includes 3-line header, so subtract that offset
const headerOffset = 3; // "let _output = [];\n const _cid = this._cid;\n const that = this;"
const bodyLineIndex = generatedLine - headerOffset - 1; // Convert to 0-based
if (bodyLineIndex >= 0 && bodyLineIndex < bodyLines.length) {
// Map to actual source line (bodyLines starts at source line 2)
sourceLine = bodyLineIndex + 2;
}
}
// Build error message emphasizing unbalanced brackets
// Keep the helpful context about what to check for
const errorMessage = `Unbalanced bracket error in component "${node.name}": ${errorDetail}\n\nCheck for unclosed brackets, missing quotes, or invalid JavaScript syntax.`;
// Create proper JQHTMLParseError with file/line info for Laravel Ignition integration
throw new JQHTMLParseError(errorMessage, sourceLine, sourceColumn, this.sourceContent, this.sourceFile);
}
this.components.set(node.name, {
name: node.name,
render_function,
dependencies: Array.from(dependencies),
tagName, // Store metadata
defaultAttributes, // Store default attributes
defineArgs, // Store $ attributes from Define tag
extends: extendsValue // Store extends value for template inheritance
});
this.current_component = null;
}
generate_function_body(nodes, preserveLines = false) {
// For simplicity and better sourcemaps, generate everything on minimal lines
// This matches the rspade team's recommendation for 1:1 line mapping
const statements = [];
for (const node of nodes) {
const code = this.generate_node(node);
if (code) {
statements.push(code);
}
}
// Join all statements on a single line to minimize output lines
// This makes sourcemap generation much simpler
return statements.join(' ');
}
/**
* Generate function body with true 1:1 line mapping
* Each line in the source produces exactly one line in the output
*/
generate_function_body_1to1(nodes) {
// Find the max line number in the template body
let maxLine = 0;
const findMaxLine = (node) => {
if (node.line && node.line > maxLine) {
maxLine = node.line;
}
// Check various node types for children
if (node.body)
node.body.forEach(findMaxLine);
if (node.children)
node.children.forEach(findMaxLine);
if (node.consequent)
node.consequent.forEach(findMaxLine);
if (node.alternate)
node.alternate.forEach(findMaxLine);
};
nodes.forEach(findMaxLine);
// Initialize lines array with empty strings for each source line
const lines = [];
for (let i = 2; i <= maxLine; i++) {
lines.push('');
}
// Process nodes and build output line by line
// We need to handle nodes differently based on their type
const processNodeForLine = (node) => {
if (!node.line)
return;
const lineIndex = node.line - 2; // Adjust for array index (line 2 = index 0)
if (lineIndex < 0 || lineIndex >= lines.length)
return;
// Generate code based on node type
switch (node.type) {
case NodeType.HTML_TAG: {
this.lastOutput = ''; // Reset for non-text output
const tag = node;
// Check if this is a raw content tag (textarea, pre)
if (tag.preserveWhitespace && !tag.selfClosing && tag.children && tag.children.length > 0) {
// Validate: only TEXT, EXPRESSION, and CODE_BLOCK children allowed in raw content tags
// HTML tags and components are not allowed (they would break whitespace preservation)
for (const child of tag.children) {
if (child.type !== NodeType.TEXT &&
child.type !== NodeType.EXPRESSION &&
child.type !== NodeType.CODE_BLOCK) {
const error = new JQHTMLParseError(`Invalid content in <${tag.name}> tag`, tag.line, tag.column || 0, this.sourceContent, this.sourceFile);
error.suggestion =
`\n\nAll content within