Files
rspade_system/node_modules/@jqhtml/parser/dist/codegen.js
root 77b4d10af8 Refactor filename naming system and apply convention-based renames
Standardize settings file naming and relocate documentation files
Fix code quality violations from rsx:check
Reorganize user_management directory into logical subdirectories
Move Quill Bundle to core and align with Tom Select pattern
Simplify Site Settings page to focus on core site information
Complete Phase 5: Multi-tenant authentication with login flow and site selection
Add route query parameter rule and synchronize filename validation logic
Fix critical bug in UpdateNpmCommand causing missing JavaScript stubs
Implement filename convention rule and resolve VS Code auto-rename conflict
Implement js-sanitizer RPC server to eliminate 900+ Node.js process spawns
Implement RPC server architecture for JavaScript parsing
WIP: Add RPC server infrastructure for JS parsing (partial implementation)
Update jqhtml terminology from destroy to stop, fix datagrid DOM preservation
Add JQHTML-CLASS-01 rule and fix redundant class names
Improve code quality rules and resolve violations
Remove legacy fatal error format in favor of unified 'fatal' error type
Filter internal keys from window.rsxapp output
Update button styling and comprehensive form/modal documentation
Add conditional fly-in animation for modals
Fix non-deterministic bundle compilation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-13 19:10:02 +00:00

1372 lines
67 KiB
JavaScript

// 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 <textarea> and <pre> tags must be plain text or expressions.\n` +
`HTML tags and components are not allowed.\n\n` +
`Allowed:\n` +
` <textarea><%= this.data.value %></textarea> ← expressions OK\n` +
` <textarea>plain text</textarea> ← plain text OK\n\n` +
`Not allowed:\n` +
` <textarea><div>content</div></textarea> ← HTML tags not OK\n` +
` <textarea><MyComponent /></textarea> ← 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("</${tag.name}>");`;
// 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 colon or opening brace
let condition = node.condition.trim();
condition = condition.replace(/:\s*$/, ''); // Remove trailing colon
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) {
// Remove trailing colon from iterator if present
const iterator = node.iterator.trim().replace(/:\s*$/, '');
// 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 <textarea> and <pre> tags must be plain text or expressions.\n` +
`HTML tags and components are not allowed.\n\n` +
`Allowed:\n` +
` <textarea><%= this.data.value %></textarea> ← expressions OK\n` +
` <textarea>plain text</textarea> ← plain text OK\n\n` +
`Not allowed:\n` +
` <textarea><div>content</div></textarea> ← HTML tags not OK\n` +
` <textarea><MyComponent /></textarea> ← 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("</${node.name}>");`);
}
// 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-id attribute (from $id) - create scoped id
// NOTE: Parser converts $id="foo" → data-id="foo" so we can distinguish from regular id
// This generates: id="foo:PARENT_CID" data-id="foo"
// The :PARENT_CID scoping happens at runtime in instruction-processor.ts
if (key === 'data-id') {
const id_entries = [];
if (value && typeof value === 'object' && value.interpolated) {
// Interpolated $id like $id="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-id": ${base_id}`);
}
else if (value && typeof value === 'object' && value.quoted) {
// Quoted $id like $id="static"
const base_id = this.escape_string(value.value);
id_entries.push(`"id": ${base_id} + ":" + this._cid`);
id_entries.push(`"data-id": ${base_id}`);
}
else {
// Simple $id like $id="username" or expression like $id=someVar
const base_id = this.escape_string(String(value));
id_entries.push(`"id": ${base_id} + ":" + this._cid`);
id_entries.push(`"data-id": ${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.2.186',\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