// 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 let sourceColumn = 0; // Extract line number from vm.Script stack trace // Stack format: "filename.jqhtml:LINE\n..." or "evalmachine.:LINE\n..." if (error.stack && typeof error.stack === 'string') { // Extract line number from first line of stack (before first newline) const firstLine = error.stack.split('\n')[0]; const lineMatch = firstLine.match(/:(\d+)/); if (lineMatch) { const generatedLine = parseInt(lineMatch[1], 10); // 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) // Subtract 1 because syntax errors typically get detected on the line AFTER the problem sourceLine = Math.max(1, bodyLineIndex + 2 - 1); } } } // 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 ← expressions OK\n` + ` ← plain text OK\n\n` + `Not allowed:\n` + ` ← HTML tags not OK\n` + ` ← components not OK\n\n` + `This ensures proper whitespace preservation.`; throw error; } } // Generate rawtag instruction with raw content const attrs_obj = this.generate_attributes_with_conditionals(tag.attributes, tag.conditionalAttributes); // Collect raw content from children (all validated as TEXT) let rawContent = ''; for (const child of tag.children) { rawContent += child.content; } // Escape the raw content for JavaScript string const escapedContent = this.escape_string(rawContent); const rawtagInstruction = `_output.push({rawtag: ["${tag.name}", ${attrs_obj}, ${escapedContent}]});`; lines[lineIndex] = (lines[lineIndex] || '') + rawtagInstruction; } else { // Normal HTML tag processing // Opening tag goes on its line const openTag = this.generate_tag_open(tag); if (openTag) { lines[lineIndex] = (lines[lineIndex] || '') + openTag; } // Process children if (tag.children) { tag.children.forEach(processNodeForLine); } // Closing tag might be on a different line const closeTag = `_output.push("");`; // For simplicity, put closing tag on the last child's line or same line const closeLine = tag.children && tag.children.length > 0 ? (tag.children[tag.children.length - 1].line || node.line) : node.line; const closeIndex = closeLine - 2; if (closeIndex >= 0 && closeIndex < lines.length) { lines[closeIndex] = (lines[closeIndex] || '') + ' ' + closeTag; } } break; } case NodeType.TEXT: { const text = node; // Apply padded trim to preserve intentional whitespace const processed = this.padded_trim(text.content); if (processed) { const code = `_output.push(${this.escape_string(processed)});`; // Optimization: skip consecutive identical space pushes if (code === '_output.push(" ");' && this.lastOutput === '_output.push(" ");') { // Skip duplicate - don't add to output break; } this.lastOutput = code; // Track for next comparison lines[lineIndex] = (lines[lineIndex] || '') + ' ' + code; } else { // Empty text resets tracking so next space won't be skipped this.lastOutput = ''; } // Empty after processing: skip (no code generated) break; } case NodeType.CODE_BLOCK: { this.lastOutput = ''; // Reset for non-text output const codeBlock = node; const code = this.generate_code_block(codeBlock); if (code) { // With preprocessing, empty lines are already marked with /* empty line */ // So we can just add the code as-is, line by line const codeLines = code.split('\n'); for (let i = 0; i < codeLines.length; i++) { const targetIndex = lineIndex + i; if (targetIndex < lines.length) { // Add all lines including comment placeholders const codeLine = codeLines[i].trim(); if (codeLine) { lines[targetIndex] = (lines[targetIndex] || '') + ' ' + codeLines[i]; } } } } break; } case NodeType.EXPRESSION: { this.lastOutput = ''; // Reset for non-text output const expr = node; // Generate the expression wrapper on a single line let code; // Special handling for content() calls // Strip trailing semicolon if present (optional in <%= %> blocks) let trimmedCode = expr.code.trim(); if (trimmedCode.endsWith(';')) { trimmedCode = trimmedCode.slice(0, -1).trim(); } if (trimmedCode === 'content()') { // Default slot/content - check _inner_html first code = `(() => { if (this.args._inner_html) { _output.push(this.args._inner_html); } else if (typeof content === 'function') { const [contentInstructions] = content.call(this); _output.push(['_content', contentInstructions]); } })();`; } else if (trimmedCode.startsWith('content.') && trimmedCode.endsWith('()')) { // Named slot: content.header() (property access style) const slotName = trimmedCode.slice(8, -2); // Extract "header" from "content.header()" code = `(() => { if (typeof content === 'object' && typeof content.${slotName} === 'function') { const [contentInstructions] = content.${slotName}.call(this); _output.push(['_content', contentInstructions]); } })();`; } else if (trimmedCode.match(/^content\s*\(\s*['"]([^'"]+)['"]\s*(?:,\s*(.+?))?\s*\)$/)) { // Named slot: content('header') or content('header', data) (function call with string parameter and optional data) // Use the standard result pattern for proper handling code = `(() => { const result = ${trimmedCode};; if (Array.isArray(result)) { if (result.length === 2 && Array.isArray(result[0])) { _output.push(...result[0]); } else { _output.push(...result); } } else { _output.push(jqhtml.escape_html(result)); } })();`; } else if (expr.escaped) { code = `(() => { const result = ${expr.code}; if (Array.isArray(result)) { if (result.length === 2 && Array.isArray(result[0])) { _output.push(...result[0]); } else { _output.push(...result); } } else { _output.push(jqhtml.escape_html(result)); } })();`; } else { code = `(() => { const result = ${expr.code}; if (Array.isArray(result)) { if (result.length === 2 && Array.isArray(result[0])) { _output.push(...result[0]); } else { _output.push(...result); } } else { _output.push(result); } })();`; } // Put the code on the starting line if (code) { lines[lineIndex] = (lines[lineIndex] || '') + ' ' + code; } break; } case NodeType.COMPONENT_INVOCATION: { this.lastOutput = ''; // Reset for non-text output const comp = node; // For 1:1 mapping, generate compact component invocations const attrs = this.generate_attributes_with_conditionals(comp.attributes, comp.conditionalAttributes); if (comp.selfClosing || comp.children.length === 0) { // Simple component without children const code = `_output.push({comp: ["${comp.name}", ${attrs}]});`; lines[lineIndex] = (lines[lineIndex] || '') + ' ' + code; } else { // Check if children contain slots const slots = this.extract_slots_from_children(comp.children); if (Object.keys(slots).length > 0) { // Component with slots - use generate_component_invocation for proper slot handling const code = this.generate_component_invocation(comp); lines[lineIndex] = (lines[lineIndex] || '') + ' ' + code; } else { // Component with regular content (no slots) // Generate inline content function // Always include let _output = []; inside the function lines[lineIndex] = (lines[lineIndex] || '') + ` _output.push({comp: ["${comp.name}", ${attrs}, function(${comp.name}) { let _output = [];`; // Process children if (comp.children && comp.children.length > 0) { // Check if all children are simple text/inline nodes that can go on one line const allInline = comp.children.every(child => child.type === NodeType.TEXT || (child.type === NodeType.COMPONENT_INVOCATION && child.selfClosing)); if (allInline) { // Put all inline children on the same line as the component comp.children.forEach(child => { const childCode = this.generate_node(child); if (childCode) { lines[lineIndex] = (lines[lineIndex] || '') + ' ' + childCode; } }); // Close on same line lines[lineIndex] = (lines[lineIndex] || '') + ' return [_output, this]; }.bind(this)]});'; } else { // Multi-line children - process on their respective lines comp.children.forEach(child => { const origLine = child.line; if (origLine && origLine >= 2) { const childIndex = origLine - 2; if (childIndex >= 0 && childIndex < lines.length) { const childCode = this.generate_node(child); if (childCode) { lines[childIndex] = (lines[childIndex] || '') + ' ' + childCode; } } } }); // Return statement and closing on the last child's line const returnLine = comp.children[comp.children.length - 1].line || node.line; const returnIndex = returnLine - 2; if (returnIndex >= 0 && returnIndex < lines.length) { lines[returnIndex] = (lines[returnIndex] || '') + ' return [_output, this]; }.bind(this)]});'; } } } else { // No children - close on same line lines[lineIndex] = (lines[lineIndex] || '') + ' return [_output, this]; }.bind(this)]});'; } } } break; } case NodeType.SLOT: { // Generate slot function definition with line mapping const slot = node; const slotName = slot.name; // Start slot function on this line lines[lineIndex] = (lines[lineIndex] || '') + ` ${slotName}: function(${slotName}) { const _output = [];`; // Process slot children on their respective lines if (slot.children && slot.children.length > 0) { slot.children.forEach(child => { const childLine = child.line; if (childLine && childLine >= 2) { const childIndex = childLine - 2; if (childIndex >= 0 && childIndex < lines.length) { const childCode = this.generate_node(child); if (childCode) { lines[childIndex] = (lines[childIndex] || '') + ' ' + childCode; } } } }); // Close slot function on the last child's line const lastChild = slot.children[slot.children.length - 1]; const closeLine = lastChild.line || node.line; const closeIndex = closeLine - 2; if (closeIndex >= 0 && closeIndex < lines.length) { lines[closeIndex] = (lines[closeIndex] || '') + ' return [_output, this]; }.bind(this),'; } } else { // Empty slot - close on same line lines[lineIndex] = (lines[lineIndex] || '') + ' return [_output, this]; }.bind(this),'; } break; } default: { // For other node types, use the standard generator const code = this.generate_node(node); if (code) { lines[lineIndex] = (lines[lineIndex] || '') + ' ' + code; } } } }; // Process all top-level nodes for (const node of nodes) { processNodeForLine(node); } // For true 1:1 mapping, we must preserve ALL lines, even empty ones // Each source line from 2 to maxLine should produce exactly one output line // Don't remove any empty lines - they're essential for maintaining line positions return lines; } generate_node(node) { switch (node.type) { case NodeType.TEXT: return this.generate_text(node); case NodeType.EXPRESSION: return this.generate_expression(node); case NodeType.HTML_TAG: return this.generate_html_tag(node); case NodeType.COMPONENT_INVOCATION: return this.generate_component_invocation(node); case NodeType.IF_STATEMENT: return this.generate_if(node); case NodeType.FOR_STATEMENT: return this.generate_for(node); case NodeType.CODE_BLOCK: return this.generate_code_block(node); default: console.warn(`Unknown node type: ${node.type}`); return ''; } } /** * Padded trim: Collapse internal whitespace but preserve leading/trailing space * Examples: * " hello " → " hello " * "hello" → "hello" * " " → " " * "\n\n \n" → " " */ padded_trim(text) { const has_leading_space = /^\s/.test(text); const has_trailing_space = /\s$/.test(text); // Trim the text let result = text.trim(); // Add back single space if original had leading/trailing whitespace if (has_leading_space) result = ' ' + result; if (has_trailing_space) result = result + ' '; // Final pass: collapse all whitespace sequences to single space return result.replace(/\s+/g, ' '); } generate_text(node) { const content = node.content; // Apply padded trim to preserve intentional whitespace const processed = this.padded_trim(content); // Skip if empty after processing if (!processed) { return ''; } // Generate output code const escaped = this.escape_string(processed); const output = `_output.push(${escaped});`; // Optimization: skip consecutive identical space pushes (but never skip newlines) if (output === '_output.push(" ");' && this.lastOutput === '_output.push(" ");') { return ''; // Skip duplicate space push } // Track this output for next comparison this.lastOutput = output; // Track the emitted position with source mapping if (this.enablePositionTracking) { this.emit(output, node); } return output; } generate_expression(node) { this.lastOutput = ''; // Reset for non-text output let output; // Special handling for content() calls // Strip trailing semicolon if present (optional in <%= %> blocks) let trimmedCode = node.code.trim(); if (trimmedCode.endsWith(';')) { trimmedCode = trimmedCode.slice(0, -1).trim(); } if (trimmedCode === 'content()') { // Default slot/content - check _inner_html first output = `(() => { if (this.args._inner_html) { _output.push(this.args._inner_html); } else if (typeof content === 'function') { const [contentInstructions] = content.call(this); _output.push(['_content', contentInstructions]); } })();`; } else if (trimmedCode.startsWith('content.') && trimmedCode.endsWith('()')) { // Named slot: content.header() (property access style) const slotName = trimmedCode.slice(8, -2); // Extract "header" from "content.header()" output = `(() => { if (typeof content === 'object' && typeof content.${slotName} === 'function') { const [contentInstructions] = content.${slotName}.call(this); _output.push(['_content', contentInstructions]); } })();`; } else if (trimmedCode.match(/^content\s*\(\s*['"]([^'"]+)['"]\s*(?:,\s*(.+?))?\s*\)$/)) { // Named slot: content('header') or content('header', data) (function call with string parameter and optional data) // Use the standard result pattern for proper handling output = `(() => { const result = ${trimmedCode};; if (Array.isArray(result)) { if (result.length === 2 && Array.isArray(result[0])) { _output.push(...result[0]); } else { _output.push(...result); } } else { _output.push(jqhtml.escape_html(result)); } })();`; } else if (node.escaped) { // Single-line expression handler for escaped output output = `(() => { const result = ${node.code}; if (Array.isArray(result)) { if (result.length === 2 && Array.isArray(result[0])) { _output.push(...result[0]); } else { _output.push(...result); } } else { _output.push(jqhtml.escape_html(result)); } })();`; } else { // Single-line expression handler for unescaped output output = `(() => { const result = ${node.code}; if (Array.isArray(result)) { if (result.length === 2 && Array.isArray(result[0])) { _output.push(...result[0]); } else { _output.push(...result); } } else { _output.push(result); } })();`; } // Track the emitted position with source mapping if (this.enablePositionTracking) { this.emit(output, node); } return output; } generate_if(node) { // Clean up condition - remove trailing opening brace let condition = node.condition.trim(); condition = condition.replace(/\s*{\s*$/, ''); // Remove trailing brace // Generate consequent body inline const consequent_parts = []; for (const child of node.consequent) { const child_code = this.generate_node(child); if (child_code) { consequent_parts.push(child_code); } } // Generate if statement - keep on single line for line preservation let code = `if (${condition}) { ${consequent_parts.join(' ')} }`; // Add else clause if present if (node.alternate && node.alternate.length > 0) { const alternate_parts = []; for (const child of node.alternate) { const child_code = this.generate_node(child); if (child_code) { alternate_parts.push(child_code); } } code += ` else { ${alternate_parts.join(' ')} }`; } // Track the emitted position with source mapping if (this.enablePositionTracking) { this.emit(code, node); } return code; } generate_for(node) { const iterator = node.iterator.trim(); // Generate body inline const body_parts = []; for (const child of node.body) { const child_code = this.generate_node(child); if (child_code) { body_parts.push(child_code); } } // Generate for loop - keep on single line for line preservation const code = `for ${iterator} { ${body_parts.join(' ')} }`; // Track the emitted position with source mapping if (this.enablePositionTracking) { this.emit(code, node); } return code; } generate_code_block(node) { // Handle legacy code property for backward compatibility if (node.code !== undefined) { return node.code; } // Process tokens to transform PHP-style syntax if (!node.tokens || node.tokens.length === 0) { return ''; } const result = []; for (let i = 0; i < node.tokens.length; i++) { const token = node.tokens[i]; const nextToken = node.tokens[i + 1]; switch (token.type) { case 'IF': result.push('if'); break; case 'ELSE': // Check if next token is IF for "else if" if (nextToken && nextToken.type === 'IF') { result.push('} else'); // Don't skip the IF token, it will be processed next } else if (nextToken && nextToken.type === 'JAVASCRIPT') { // Check if this is "else if" in the JavaScript token if (nextToken.value.trim().startsWith('if ')) { result.push('} else'); // The 'if' is part of the JAVASCRIPT token, will be handled there } else if (nextToken.value.trim() === ':') { // Regular else with colon in next token result.push('} else {'); i++; // Skip the colon token } else { // else with other JavaScript code result.push('} else {'); } } else { // else without anything after (shouldn't happen in valid JQHTML) result.push('} else {'); } break; case 'ELSEIF': result.push('} else if'); break; case 'ENDIF': result.push('}'); // Skip optional semicolon in next JAVASCRIPT token if (nextToken && nextToken.type === 'JAVASCRIPT' && nextToken.value.trim() === ';') { i++; // Skip the semicolon token } break; case 'FOR': result.push('for'); break; case 'ENDFOR': result.push('}'); // Skip optional semicolon in next JAVASCRIPT token if (nextToken && nextToken.type === 'JAVASCRIPT' && nextToken.value.trim() === ';') { i++; // Skip the semicolon token } break; case 'JAVASCRIPT': // Transform colons to opening braces after conditions let jsCode = token.value; // Check if this is a condition ending with colon const prevToken = i > 0 ? node.tokens[i - 1] : null; if (jsCode.endsWith(':') && prevToken && (prevToken.type === 'IF' || prevToken.type === 'FOR' || prevToken.type === 'ELSEIF' || (prevToken.type === 'ELSE' && jsCode.startsWith('if ')))) { jsCode = jsCode.slice(0, -1) + ' {'; } result.push(jsCode); break; default: // Pass through any other token types result.push(token.value); } } const code = result.join(' '); // Track the emitted position with source mapping if (this.enablePositionTracking) { this.emit(code, node); } return code; } generate_tag_open(node) { // Generate just the opening tag const attrs = this.generate_attributes_with_conditionals(node.attributes, node.conditionalAttributes); return `_output.push({tag: ["${node.name}", ${attrs}, ${node.selfClosing || false}]});`; } generate_html_tag(node) { this.lastOutput = ''; // Reset for non-text output // Check if this tag needs raw content preservation if (node.preserveWhitespace && !node.selfClosing && node.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 node.children) { if (child.type !== NodeType.TEXT && child.type !== NodeType.EXPRESSION && child.type !== NodeType.CODE_BLOCK) { const error = new JQHTMLParseError(`Invalid content in <${node.name}> tag`, node.line, node.column || 0, this.sourceContent, this.sourceFile); error.suggestion = `\n\nAll content within ← expressions OK\n` + ` ← plain text OK\n\n` + `Not allowed:\n` + ` ← HTML tags not OK\n` + ` ← components not OK\n\n` + `This ensures proper whitespace preservation.`; throw error; } } // Generate rawtag instruction with raw content const attrs_obj = this.generate_attributes_with_conditionals(node.attributes, node.conditionalAttributes); // Collect raw content from children (all validated as TEXT) let rawContent = ''; for (const child of node.children) { rawContent += child.content; } // Escape the raw content for JavaScript string const escapedContent = this.escape_string(rawContent); return `_output.push({rawtag: ["${node.name}", ${attrs_obj}, ${escapedContent}]});`; } // Normal tag generation const parts = []; // Generate opening tag instruction const attrs_obj = this.generate_attributes_with_conditionals(node.attributes, node.conditionalAttributes); parts.push(`_output.push({tag: ["${node.name}", ${attrs_obj}, ${node.selfClosing}]});`); if (!node.selfClosing) { // Generate children inline if (node.children.length > 0) { for (const child of node.children) { const child_code = this.generate_node(child); if (child_code) { // Skip empty text nodes if (!child_code.match(/^_output\.push\(""\);?$/)) { parts.push(child_code); } } } } // Generate closing tag parts.push(`_output.push("");`); } // Join parts with space for single-line tags, but this will be split later return parts.join(' '); } generate_component_invocation(node) { this.lastOutput = ''; // Reset for non-text output const instructions = []; const attrs_obj = this.generate_attributes_with_conditionals(node.attributes, node.conditionalAttributes); if (node.selfClosing || node.children.length === 0) { // Simple component without children const componentCall = `_output.push({comp: ["${node.name}", ${attrs_obj}]});`; instructions.push(componentCall); // Track component invocation position if (this.enablePositionTracking) { this.emit(componentCall, node); } } else { // Check if children contain slots const slots = this.extract_slots_from_children(node.children); if (Object.keys(slots).length > 0) { // Component with slots - generate content as object with slot functions ON A SINGLE LINE const slotEntries = []; for (const [slotName, slotNode] of Object.entries(slots)) { const slot_code = slotNode.children.length > 0 ? this.generate_function_body(slotNode.children) : ''; // Add slot name as parameter: function(slotName) { ... } slotEntries.push(`${slotName}: function(${slotName}) { const _output = []; ${slot_code} return [_output, this]; }.bind(this)`); } // Everything on one line instructions.push(`_output.push({comp: ["${node.name}", ${attrs_obj}, {${slotEntries.join(', ')}}]});`); } else { // Component with regular content (no slots) instructions.push(`_output.push({comp: ["${node.name}", ${attrs_obj}, function(${node.name}) {`); instructions.push(` const _output = [];`); const children_code = this.generate_function_body(node.children); if (children_code) { instructions.push(this.indent(children_code, 1)); } instructions.push(` return [_output, this];`); instructions.push(`}.bind(this)]});`); } } return instructions.join('\n'); } parse_attributes(attrs_string) { const attrs = {}; // Simple attribute parser - handles key="value" and key=value const attr_regex = /([a-zA-Z$][a-zA-Z0-9-]*)\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/g; let match; while ((match = attr_regex.exec(attrs_string)) !== null) { const [, name, value] = match; // Remove quotes if present attrs[name] = value.replace(/^["']|["']$/g, ''); } return attrs; } // Generate attribute object including conditional attributes generate_attributes_with_conditionals(attrs, conditionalAttrs) { // If no conditional attributes, use simple object if (!conditionalAttrs || conditionalAttrs.length === 0) { return this.generate_attributes_object(attrs); } // We have conditional attributes - need to merge them at runtime const baseAttrs = this.generate_attributes_object(attrs); // Generate code that conditionally adds attributes let result = baseAttrs; for (const condAttr of conditionalAttrs) { const condAttrsObj = this.generate_attributes_object(condAttr.attributes); // Use Object.assign to merge conditional attributes result = `Object.assign({}, ${result}, (${condAttr.condition}) ? ${condAttrsObj} : {})`; } return result; } generate_attributes_object(attrs) { if (Object.keys(attrs).length === 0) { return '{}'; } const entries = Object.entries(attrs).flatMap(([key, value]) => { // Convert 'tag' to '_tag' for component invocations const attrKey = key === 'tag' ? '_tag' : key; // Special handling for data-sid attribute (from $sid) - create scoped id // NOTE: Parser converts $sid="foo" → data-sid="foo" so we can distinguish from regular id // This generates: id="foo:PARENT_CID" data-sid="foo" // The :PARENT_CID scoping happens at runtime in instruction-processor.ts if (key === 'data-sid') { const id_entries = []; if (value && typeof value === 'object' && value.interpolated) { // Interpolated $sid like $sid="user<%= index %>" const parts = value.parts.map((part) => { if (part.type === 'text') { return this.escape_string(part.value); } else { return part.value; } }); const base_id = parts.join(' + '); id_entries.push(`"id": ${base_id} + ":" + this._cid`); id_entries.push(`"data-sid": ${base_id}`); } else if (value && typeof value === 'object' && value.quoted) { // Quoted $sid like $sid="static" const base_id = this.escape_string(value.value); id_entries.push(`"id": ${base_id} + ":" + this._cid`); id_entries.push(`"data-sid": ${base_id}`); } else { // Simple $sid like $sid="username" or expression like $sid=someVar const base_id = this.escape_string(String(value)); id_entries.push(`"id": ${base_id} + ":" + this._cid`); id_entries.push(`"data-sid": ${base_id}`); } return id_entries; } // Regular id attribute - pass through unchanged // id="foo" remains id="foo" (no scoping) if (key === 'id') { if (value && typeof value === 'object' && value.quoted) { return `"id": ${this.escape_string(value.value)}`; } else { return `"id": ${this.escape_string(String(value))}`; } } // Check if this is an interpolated attribute value if (value && typeof value === 'object' && value.interpolated) { // Build concatenation expression const parts = value.parts.map((part) => { if (part.type === 'text') { return this.escape_string(part.value); } else { // Expression - wrap in parentheses to preserve operator precedence // This ensures "a" + (x ? 'b' : 'c') instead of "a" + x ? 'b' : 'c' return `(${part.value})`; } }); return `"${attrKey}": ${parts.join(' + ')}`; } // Check if value is marked as quoted if (value && typeof value === 'object' && value.quoted) { // This was a quoted string in the source - always treat as string return `"${attrKey}": ${this.escape_string(value.value)}`; } // Check if it's a parenthesized expression: $attr=(expr) if (value && typeof value === 'object' && value.expression) { // It's an expression - output without quotes return `"${attrKey}": ${value.value}`; } // Check if it's a bare identifier: $attr=identifier if (value && typeof value === 'object' && value.identifier) { // It's an identifier - output as JavaScript expression return `"${attrKey}": ${value.value}`; } // Check if it's an event handler binding (data-__-on-*) if (key.startsWith('data-__-on-')) { // Handle based on whether value was quoted or not if (typeof value === 'object' && value !== null) { if (value.quoted) { // Was quoted: @click="handler" -> string literal return `"${key}": ${this.escape_string(value.value)}`; } else if (value.identifier || value.expression) { // Was unquoted: @click=this.handler -> JavaScript expression return `"${key}": ${value.value}`; } else if (value.interpolated) { // Has interpolation - needs special handling return `"${key}": ${this.compile_interpolated_value(value)}`; } } else if (typeof value === 'string') { // Fallback for simple strings (backwards compatibility) return `"${key}": ${this.escape_string(value)}`; } } // Check if it's a data attribute ($foo or :binding) if (key.startsWith('data-')) { // Handle based on whether value was quoted or not if (typeof value === 'object' && value !== null) { if (value.quoted) { // Was quoted: $foo="bar" -> string literal return `"${key}": ${this.escape_string(value.value)}`; } else if (value.identifier || value.expression) { // Was unquoted: $foo=this.data.bar -> JavaScript expression return `"${key}": ${value.value}`; } else if (value.interpolated) { // Has interpolation - needs special handling return `"${key}": ${this.compile_interpolated_value(value)}`; } } else if (typeof value === 'string') { // Fallback for simple strings return `"${key}": ${this.escape_string(value)}`; } } // Regular attributes if (typeof value === 'object' && value !== null) { if (value.quoted) { // Explicitly quoted value return `"${attrKey}": ${this.escape_string(value.value)}`; } else if (value.identifier || value.expression) { // Unquoted JavaScript expression return `"${attrKey}": ${value.value}`; } else if (value.interpolated) { // Has interpolation return `"${attrKey}": ${this.compile_interpolated_value(value)}`; } } // Default: treat as string return `"${attrKey}": ${this.escape_string(String(value))}`; }); return `{${entries.join(', ')}}`; } is_self_closing_tag(tag_name) { const self_closing = [ 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr' ]; return self_closing.includes(tag_name.toLowerCase()); } compile_interpolated_value(value) { // Handle interpolated values with embedded expressions if (!value.parts || !Array.isArray(value.parts)) { return this.escape_string(String(value)); } // Build template literal from parts const parts = value.parts.map((part) => { if (part.type === 'text') { // Escape text parts for template literal const escaped = part.value .replace(/\\/g, '\\\\') .replace(/`/g, '\\`') .replace(/\${/g, '\\${'); return escaped; } else if (part.type === 'expression') { // Embed expression return `\${${part.value}}`; } return part.value; }); // Return as template literal return '`' + parts.join('') + '`'; } escape_string(str) { // Escape for JavaScript string literal const escaped = str .replace(/\\/g, '\\\\') .replace(/"/g, '\\"') .replace(/\n/g, '\\n') .replace(/\r/g, '\\r') .replace(/\t/g, '\\t'); return `"${escaped}"`; } indent(code, level) { const indent = ' '.repeat(level); return code.split('\n').map(line => line ? indent + line : line).join('\n'); } extract_slots_from_children(children) { const slots = {}; for (const child of children) { if (child.type === NodeType.SLOT) { const slotNode = child; slots[slotNode.name] = slotNode; } } return slots; } // ------------------------------------------------------------------------- // Position Tracking Methods // ------------------------------------------------------------------------- /** * Emit text and track position for source maps */ emit(text, node) { // Store the starting position before emitting const startLine = this.outputLine; const startColumn = this.outputColumn; // Update position tracking for (let i = 0; i < text.length; i++) { if (text[i] === '\n') { this.outputLine++; this.outputColumn = 0; } else { this.outputColumn++; } } // Log position if tracking enabled if (this.enablePositionTracking) { this.positionLog.push({ line: this.outputLine, column: this.outputColumn, text: text.substring(0, 20), // First 20 chars for debugging node: node ? `${node.type}:L${node.line}:C${node.column}` : undefined }); } // ARCHIVED: Token-by-token mapping approach - not currently used // We're using 1:1 line mapping instead, but keeping this code for potential future use /* istanbul ignore next */ if (false) { // TypeScript can't analyze dead code properly, so we cast to any const deadCode = () => { if (this.sourceMapGenerator && node?.loc && this.sourceFile) { this.sourceMapGenerator.addMapping({ generated: { line: startLine, column: startColumn }, source: this.sourceFile, original: { line: node.loc.start.line, column: node.loc.start.column - 1 }, name: undefined }); } }; } this.outputBuffer.push(text); return text; } /** * Emit a line of text (adds newline) */ emitLine(text, node) { return this.emit(text + '\n', node); } /** * Reset position tracking */ resetPositionTracking() { this.outputLine = 1; this.outputColumn = 0; this.outputBuffer = []; this.positionLog = []; } /** * Get position tracking log for debugging */ getPositionLog() { return this.positionLog; } /** * Enable/disable position tracking */ setPositionTracking(enabled) { this.enablePositionTracking = enabled; } /** * Serialize attribute object with proper handling of identifiers and expressions * Quoted values become strings, identifiers/expressions become raw JavaScript */ serializeAttributeObject(obj) { if (!obj || Object.keys(obj).length === 0) { return '{}'; } const entries = []; for (const [key, value] of Object.entries(obj)) { // Check if value is a parsed attribute object with type info if (value && typeof value === 'object' && (value.identifier || value.expression)) { // Identifier or expression - output as raw JavaScript (no quotes) entries.push(`"${key}": ${value.value}`); } else if (value && typeof value === 'object' && value.quoted) { // Quoted string - output as string literal entries.push(`"${key}": ${JSON.stringify(value.value)}`); } else { // Simple value - output as-is via JSON.stringify entries.push(`"${key}": ${JSON.stringify(value)}`); } } return `{${entries.join(', ')}}`; } build_module_code() { let code = '// Generated by JQHTML v2 Code Generator\n'; code += '// Parser version: 2.2.56 (with 1:1 sourcemap fixes)\n'; code += '// Produces v1-compatible instruction arrays\n\n'; // Add html escaping function (assumed to be available in runtime) code += '// Runtime should provide: function html(str) { return escapeHtml(str); }\n\n'; // Add component registration code += 'const jqhtml_components = new Map();\n\n'; // Add each component for (const [name, component] of this.components) { code += `// Component: ${name}\n`; code += `jqhtml_components.set('${name}', {\n`; code += ` _jqhtml_version: '2.3.9',\n`; // Version will be replaced during build code += ` name: '${name}',\n`; code += ` tag: '${component.tagName}',\n`; code += ` defaultAttributes: ${this.serializeAttributeObject(component.defaultAttributes)},\n`; // Add defineArgs if present ($ attributes on Define tag) if (component.defineArgs) { code += ` defineArgs: ${this.serializeAttributeObject(component.defineArgs)},\n`; } // Add extends if present (template inheritance) if (component.extends) { code += ` extends: '${component.extends}',\n`; } code += ` render: ${this.indent(component.render_function, 1).trim()},\n`; code += ` dependencies: [${component.dependencies.map(d => `'${d}'`).join(', ')}]\n`; code += '});\n\n'; } // Add export code += 'export { jqhtml_components };\n'; return code; } } // Helper function for standalone usage export function generate(ast, sourceFile, sourceContent) { const generator = new CodeGenerator(); return generator.generate(ast, sourceFile, sourceContent); } //# sourceMappingURL=codegen.js.map