🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1372 lines
67 KiB
JavaScript
Executable File
1372 lines
67 KiB
JavaScript
Executable File
// 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.176',\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
|