@jqhtml/parser 2.2.222

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1377 @@
1
+ // JQHTML Code Generator - Converts AST to JavaScript functions
2
+ // Generates v1-compatible instruction arrays for efficient DOM construction
3
+ import { NodeType } from './ast.js';
4
+ import { SourceMapGenerator } from 'source-map';
5
+ import { JQHTMLParseError } from './errors.js';
6
+ import * as vm from 'vm';
7
+ export class CodeGenerator {
8
+ indent_level = 0;
9
+ components = new Map();
10
+ current_component = null;
11
+ in_slot = false;
12
+ tag_depth = 0;
13
+ lastOutput = ''; // Track last generated output for deduplication
14
+ // Position tracking for source maps
15
+ outputLine = 1;
16
+ outputColumn = 0;
17
+ sourceMapGenerator;
18
+ sourceContent;
19
+ sourceFile;
20
+ outputBuffer = [];
21
+ // Enable position tracking for debugging
22
+ enablePositionTracking = false;
23
+ positionLog = [];
24
+ // Line preservation for 1:1 source mapping
25
+ currentOutputLine = 1;
26
+ preserveLines = false;
27
+ // For true 1:1 line mapping
28
+ outputLines = [];
29
+ sourceLines = [];
30
+ generate(ast, sourceFile, sourceContent) {
31
+ // Reset state
32
+ this.components.clear();
33
+ this.current_component = null;
34
+ this.tag_depth = 0;
35
+ this.resetPositionTracking();
36
+ // Store source info for error reporting
37
+ this.sourceFile = sourceFile;
38
+ this.sourceContent = sourceContent;
39
+ // Process all top-level nodes
40
+ for (const node of ast.body) {
41
+ if (node.type === NodeType.COMPONENT_DEFINITION) {
42
+ this.generate_component(node);
43
+ }
44
+ }
45
+ // Build the final code
46
+ const code = this.build_module_code();
47
+ return {
48
+ code,
49
+ components: this.components
50
+ };
51
+ }
52
+ /**
53
+ * Generate code with source maps using 1:1 line mapping + SourceMapGenerator
54
+ */
55
+ generateWithSourceMap(ast, sourceFile, sourceContent) {
56
+ // Use the regular generate method which creates 1:1 line mapping
57
+ const result = this.generate(ast, sourceFile, sourceContent);
58
+ // Now create a proper sourcemap for each component using SourceMapGenerator
59
+ const componentsWithSourcemaps = new Map();
60
+ for (const [name, component] of result.components) {
61
+ // Create a SourceMapGenerator for this component
62
+ const generator = new SourceMapGenerator({
63
+ file: sourceFile.replace(/\.jqhtml$/, '.js')
64
+ });
65
+ generator.setSourceContent(sourceFile, sourceContent);
66
+ // Parse the render function to find line count
67
+ const renderLines = component.render_function.split('\n');
68
+ // The first line is the function declaration - maps to the Define line (line 1)
69
+ generator.addMapping({
70
+ generated: { line: 1, column: 0 },
71
+ source: sourceFile,
72
+ original: { line: 1, column: 0 }
73
+ });
74
+ // Map ALL lines in the generated output
75
+ // This ensures that even lines beyond the source file length are mapped
76
+ const sourceLines = sourceContent.split('\n');
77
+ const numSourceLines = sourceLines.length;
78
+ // CRITICAL FIX: The render function has a consistent offset from source
79
+ // We need to map with the proper offset, not naive 1:1
80
+ // The parser generates code with a fixed offset (typically around 10-13 lines)
81
+ // Calculate the actual offset by looking at the generated code
82
+ // The render function's first real content line starts around line 2
83
+ // but it maps to source line 1 (the Define line)
84
+ // After that, we need to maintain proper offset mapping
85
+ // For now, use the simple 1:1 mapping with clamping
86
+ // The CLI's adjustSourcemapForCLI will handle the offset adjustment
87
+ for (let lineNum = 2; lineNum <= renderLines.length; lineNum++) {
88
+ // Map each line to its corresponding source line
89
+ // When we go beyond source length, clamp to last source line
90
+ const sourceLine = Math.min(lineNum, numSourceLines);
91
+ generator.addMapping({
92
+ generated: { line: lineNum, column: 0 },
93
+ source: sourceFile,
94
+ original: { line: sourceLine, column: 0 }
95
+ });
96
+ }
97
+ // Generate the sourcemap
98
+ const sourcemap = generator.toJSON();
99
+ const sourcemapJson = JSON.stringify(sourcemap);
100
+ const sourcemapBase64 = Buffer.from(sourcemapJson).toString('base64');
101
+ const sourceMapDataUri = `data:application/json;charset=utf-8;base64,${sourcemapBase64}`;
102
+ // Add inline sourcemap to render function
103
+ const renderWithSourcemap = component.render_function +
104
+ '\n//# sourceMappingURL=' + sourceMapDataUri;
105
+ componentsWithSourcemaps.set(name, {
106
+ ...component,
107
+ render_function: renderWithSourcemap
108
+ });
109
+ }
110
+ return {
111
+ code: result.code,
112
+ components: componentsWithSourcemaps,
113
+ source_map: JSON.stringify({}), // Not used for inline sourcemaps
114
+ source_map_data_uri: '' // Not used for inline sourcemaps
115
+ };
116
+ }
117
+ generate_component_with_mappings_TESTING(node) {
118
+ // Extract metadata
119
+ let tagName = 'div';
120
+ const defaultAttributes = {};
121
+ const dependencies = new Set();
122
+ // Extract new fields from Define tag
123
+ const defineArgs = node.defineArgs;
124
+ const extendsValue = node.extends;
125
+ for (const [key, value] of Object.entries(node.attributes)) {
126
+ if (key === 'tag') {
127
+ if (typeof value === 'object' && value.quoted) {
128
+ tagName = value.value;
129
+ }
130
+ else if (typeof value === 'string') {
131
+ tagName = value;
132
+ }
133
+ }
134
+ else {
135
+ if (typeof value === 'object' && value.quoted) {
136
+ defaultAttributes[key] = value.value;
137
+ }
138
+ else if (typeof value === 'object' && value.expression) {
139
+ defaultAttributes[key] = value.value;
140
+ }
141
+ else if (typeof value === 'object' && value.identifier) {
142
+ defaultAttributes[key] = value.value;
143
+ }
144
+ else {
145
+ defaultAttributes[key] = value;
146
+ }
147
+ }
148
+ }
149
+ // Reset output tracking for this component
150
+ this.outputLine = 1;
151
+ this.outputColumn = 0;
152
+ this.outputBuffer = [];
153
+ // Generate function header
154
+ const header = 'function render(data, args, content, jqhtml) { let _output = []; const _cid = this._cid; const that = this;\n';
155
+ this.outputBuffer.push(header);
156
+ this.outputLine = 2; // We're now on line 2 after the header
157
+ // Generate function body - each node's emit() will track its position
158
+ for (const childNode of node.body) {
159
+ const childCode = this.generate_node(childNode);
160
+ if (childCode) {
161
+ // Don't add extra space - let the code maintain its position
162
+ this.outputBuffer.push(childCode);
163
+ }
164
+ }
165
+ // Add the return statement
166
+ this.outputBuffer.push('return [_output, this]; }');
167
+ // Combine all the output
168
+ const renderFunction = this.outputBuffer.join('');
169
+ // Store the component
170
+ this.components.set(node.name, {
171
+ name: node.name,
172
+ render_function: renderFunction,
173
+ dependencies: Array.from(dependencies),
174
+ tagName,
175
+ defaultAttributes,
176
+ defineArgs,
177
+ extends: extendsValue
178
+ });
179
+ }
180
+ generate_component(node) {
181
+ this.current_component = node.name;
182
+ this.lastOutput = ''; // Reset output tracking for each component
183
+ const dependencies = new Set();
184
+ // Always use 1:1 line mapping for proper sourcemaps
185
+ // Even when using SourceMapGenerator, we need the line structure
186
+ // Enable line preservation for 1:1 source maps
187
+ this.preserveLines = true;
188
+ this.currentOutputLine = 1;
189
+ // Initialize output lines array for 1:1 mapping
190
+ // We'll build the function line by line to match source structure
191
+ this.outputLines = [];
192
+ // Extract metadata and default attributes
193
+ // Handle the 'tag' attribute which might be a quoted object from the parser
194
+ let tagName = 'div';
195
+ const defaultAttributes = {};
196
+ // Extract new fields from Define tag (extends and defineArgs)
197
+ const defineArgs = node.defineArgs;
198
+ const extendsValue = node.extends;
199
+ // Process all attributes from the Define tag
200
+ for (const [key, value] of Object.entries(node.attributes)) {
201
+ if (key === 'tag') {
202
+ // Special handling for 'tag' attribute
203
+ if (typeof value === 'object' && value.quoted) {
204
+ tagName = value.value;
205
+ }
206
+ else if (typeof value === 'string') {
207
+ tagName = value;
208
+ }
209
+ }
210
+ else {
211
+ // Store as default attribute - extract actual value from quoted objects
212
+ if (typeof value === 'object' && value.quoted) {
213
+ defaultAttributes[key] = value.value;
214
+ }
215
+ else if (typeof value === 'object' && value.expression) {
216
+ // For expressions, we need to store them as is to be evaluated at runtime
217
+ defaultAttributes[key] = value.value;
218
+ }
219
+ else if (typeof value === 'object' && value.identifier) {
220
+ // For identifiers, store as is to be evaluated at runtime
221
+ defaultAttributes[key] = value.value;
222
+ }
223
+ else {
224
+ defaultAttributes[key] = value;
225
+ }
226
+ }
227
+ }
228
+ // Check if this is a slot-only template (template inheritance pattern)
229
+ if (node.isSlotOnly && node.slotNames && node.slotNames.length > 0) {
230
+ // Generate slot-only render function with 1:1 line mapping
231
+ // Extract slot nodes from body
232
+ const slots = {};
233
+ for (const child of node.body) {
234
+ if (child.type === NodeType.SLOT) {
235
+ const slotNode = child;
236
+ slots[slotNode.name] = slotNode;
237
+ }
238
+ }
239
+ // Filter out TEXT nodes (whitespace) from slot-only templates
240
+ // TEXT nodes cause _output.push() calls which are invalid in slot-only context
241
+ const slotsOnly = node.body.filter(child => child.type === NodeType.SLOT);
242
+ // Use 1:1 line mapping for slot-only templates
243
+ const bodyLines = this.generate_function_body_1to1(slotsOnly);
244
+ // Build the render function with line preservation
245
+ const lines = [];
246
+ // Line 1: function declaration returning slots object
247
+ lines.push(`function render(data, args, content, jqhtml) { return [{_slots: {`);
248
+ // Lines 2-N: Body lines with slot functions
249
+ lines.push(...bodyLines);
250
+ // Fix trailing comma on last slot function
251
+ // Find the last line that contains '.bind(this),' and remove the comma
252
+ for (let i = lines.length - 1; i >= 0; i--) {
253
+ if (lines[i] && lines[i].includes('.bind(this),')) {
254
+ lines[i] = lines[i].replace(/\.bind\(this\),([^,]*)$/, '.bind(this)$1');
255
+ break;
256
+ }
257
+ }
258
+ // Last line: close slots object and return
259
+ if (lines[lines.length - 1]) {
260
+ lines[lines.length - 1] += ' }}, this]; }';
261
+ }
262
+ else {
263
+ lines[lines.length - 1] = '}}, this]; }';
264
+ }
265
+ const render_function = lines.join('\n');
266
+ // Store component with slot-only metadata
267
+ this.components.set(node.name, {
268
+ name: node.name,
269
+ render_function,
270
+ dependencies: Array.from(dependencies),
271
+ tagName,
272
+ defaultAttributes,
273
+ defineArgs,
274
+ extends: extendsValue
275
+ });
276
+ this.current_component = null;
277
+ return;
278
+ }
279
+ // For 1:1 line mapping, we need to generate the body first to know how many lines it has
280
+ const bodyLines = this.generate_function_body_1to1(node.body);
281
+ // Now create the render function with 1:1 line mapping
282
+ // Line 1 of input (Define tag) becomes the function declaration
283
+ const lines = [];
284
+ // First line: function declaration and initial setup (corresponds to Define line)
285
+ lines.push(`function render(data, args, content, jqhtml) { let _output = []; const _cid = this._cid; const that = this;`);
286
+ // Body lines: each corresponds to a source line
287
+ lines.push(...bodyLines);
288
+ // Last line: closing (corresponds to closing Define tag)
289
+ if (lines[lines.length - 1]) {
290
+ lines[lines.length - 1] += ' return [_output, this]; }';
291
+ }
292
+ else {
293
+ lines[lines.length - 1] = 'return [_output, this]; }';
294
+ }
295
+ const render_function = lines.join('\n');
296
+ // Validate the generated function syntax using vm.Script for better error reporting
297
+ try {
298
+ const generatedCode = `
299
+ let _output = [];
300
+ const _cid = this._cid;
301
+ const that = this;
302
+ ${bodyLines.join('\n')}
303
+ `;
304
+ new vm.Script(generatedCode, {
305
+ filename: this.sourceFile || 'component.jqhtml'
306
+ });
307
+ }
308
+ catch (error) {
309
+ // Extract JavaScript error details
310
+ const errorDetail = error.message || String(error);
311
+ let sourceLine = 1; // Default to component definition line
312
+ let sourceColumn = 0;
313
+ // Extract line number from vm.Script stack trace
314
+ // Stack format: "filename.jqhtml:LINE\n..." or "evalmachine.<anonymous>:LINE\n..."
315
+ if (error.stack && typeof error.stack === 'string') {
316
+ // Extract line number from first line of stack (before first newline)
317
+ const firstLine = error.stack.split('\n')[0];
318
+ const lineMatch = firstLine.match(/:(\d+)/);
319
+ if (lineMatch) {
320
+ const generatedLine = parseInt(lineMatch[1], 10);
321
+ // Map generated line to source line
322
+ // The bodyLines array has 1:1 correspondence with source lines
323
+ // Generated code includes 3-line header, so subtract that offset
324
+ const headerOffset = 3; // "let _output = [];\n const _cid = this._cid;\n const that = this;"
325
+ const bodyLineIndex = generatedLine - headerOffset - 1; // Convert to 0-based
326
+ if (bodyLineIndex >= 0 && bodyLineIndex < bodyLines.length) {
327
+ // Map to actual source line (bodyLines starts at source line 2)
328
+ // Subtract 1 because syntax errors typically get detected on the line AFTER the problem
329
+ sourceLine = Math.max(1, bodyLineIndex + 2 - 1);
330
+ }
331
+ }
332
+ }
333
+ // Build error message emphasizing unbalanced brackets
334
+ // Keep the helpful context about what to check for
335
+ const errorMessage = `Unbalanced bracket error in component "${node.name}": ${errorDetail}\n\nCheck for unclosed brackets, missing quotes, or invalid JavaScript syntax.`;
336
+ // Create proper JQHTMLParseError with file/line info for Laravel Ignition integration
337
+ throw new JQHTMLParseError(errorMessage, sourceLine, sourceColumn, this.sourceContent, this.sourceFile);
338
+ }
339
+ this.components.set(node.name, {
340
+ name: node.name,
341
+ render_function,
342
+ dependencies: Array.from(dependencies),
343
+ tagName, // Store metadata
344
+ defaultAttributes, // Store default attributes
345
+ defineArgs, // Store $ attributes from Define tag
346
+ extends: extendsValue // Store extends value for template inheritance
347
+ });
348
+ this.current_component = null;
349
+ }
350
+ generate_function_body(nodes, preserveLines = false) {
351
+ // For simplicity and better sourcemaps, generate everything on minimal lines
352
+ // This matches the rspade team's recommendation for 1:1 line mapping
353
+ const statements = [];
354
+ for (const node of nodes) {
355
+ const code = this.generate_node(node);
356
+ if (code) {
357
+ statements.push(code);
358
+ }
359
+ }
360
+ // Join all statements on a single line to minimize output lines
361
+ // This makes sourcemap generation much simpler
362
+ return statements.join(' ');
363
+ }
364
+ /**
365
+ * Generate function body with true 1:1 line mapping
366
+ * Each line in the source produces exactly one line in the output
367
+ */
368
+ generate_function_body_1to1(nodes) {
369
+ // Find the max line number in the template body
370
+ let maxLine = 0;
371
+ const findMaxLine = (node) => {
372
+ if (node.line && node.line > maxLine) {
373
+ maxLine = node.line;
374
+ }
375
+ // Check various node types for children
376
+ if (node.body)
377
+ node.body.forEach(findMaxLine);
378
+ if (node.children)
379
+ node.children.forEach(findMaxLine);
380
+ if (node.consequent)
381
+ node.consequent.forEach(findMaxLine);
382
+ if (node.alternate)
383
+ node.alternate.forEach(findMaxLine);
384
+ };
385
+ nodes.forEach(findMaxLine);
386
+ // Initialize lines array with empty strings for each source line
387
+ const lines = [];
388
+ for (let i = 2; i <= maxLine; i++) {
389
+ lines.push('');
390
+ }
391
+ // Process nodes and build output line by line
392
+ // We need to handle nodes differently based on their type
393
+ const processNodeForLine = (node) => {
394
+ if (!node.line)
395
+ return;
396
+ const lineIndex = node.line - 2; // Adjust for array index (line 2 = index 0)
397
+ if (lineIndex < 0 || lineIndex >= lines.length)
398
+ return;
399
+ // Generate code based on node type
400
+ switch (node.type) {
401
+ case NodeType.HTML_TAG: {
402
+ this.lastOutput = ''; // Reset for non-text output
403
+ const tag = node;
404
+ // Check if this is a raw content tag (textarea, pre)
405
+ if (tag.preserveWhitespace && !tag.selfClosing && tag.children && tag.children.length > 0) {
406
+ // Validate: only TEXT, EXPRESSION, and CODE_BLOCK children allowed in raw content tags
407
+ // HTML tags and components are not allowed (they would break whitespace preservation)
408
+ for (const child of tag.children) {
409
+ if (child.type !== NodeType.TEXT &&
410
+ child.type !== NodeType.EXPRESSION &&
411
+ child.type !== NodeType.CODE_BLOCK) {
412
+ const error = new JQHTMLParseError(`Invalid content in <${tag.name}> tag`, tag.line, tag.column || 0, this.sourceContent, this.sourceFile);
413
+ error.suggestion =
414
+ `\n\nAll content within <textarea> and <pre> tags must be plain text or expressions.\n` +
415
+ `HTML tags and components are not allowed.\n\n` +
416
+ `Allowed:\n` +
417
+ ` <textarea><%= this.data.value %></textarea> ← expressions OK\n` +
418
+ ` <textarea>plain text</textarea> ← plain text OK\n\n` +
419
+ `Not allowed:\n` +
420
+ ` <textarea><div>content</div></textarea> ← HTML tags not OK\n` +
421
+ ` <textarea><MyComponent /></textarea> ← components not OK\n\n` +
422
+ `This ensures proper whitespace preservation.`;
423
+ throw error;
424
+ }
425
+ }
426
+ // Generate rawtag instruction with raw content
427
+ const attrs_obj = this.generate_attributes_with_conditionals(tag.attributes, tag.conditionalAttributes);
428
+ // Collect raw content from children (all validated as TEXT)
429
+ let rawContent = '';
430
+ for (const child of tag.children) {
431
+ rawContent += child.content;
432
+ }
433
+ // Escape the raw content for JavaScript string
434
+ const escapedContent = this.escape_string(rawContent);
435
+ const rawtagInstruction = `_output.push({rawtag: ["${tag.name}", ${attrs_obj}, ${escapedContent}]});`;
436
+ lines[lineIndex] = (lines[lineIndex] || '') + rawtagInstruction;
437
+ }
438
+ else {
439
+ // Normal HTML tag processing
440
+ // Opening tag goes on its line
441
+ const openTag = this.generate_tag_open(tag);
442
+ if (openTag) {
443
+ lines[lineIndex] = (lines[lineIndex] || '') + openTag;
444
+ }
445
+ // Process children
446
+ if (tag.children) {
447
+ tag.children.forEach(processNodeForLine);
448
+ }
449
+ // Closing tag might be on a different line
450
+ const closeTag = `_output.push("</${tag.name}>");`;
451
+ // For simplicity, put closing tag on the last child's line or same line
452
+ const closeLine = tag.children && tag.children.length > 0
453
+ ? (tag.children[tag.children.length - 1].line || node.line)
454
+ : node.line;
455
+ const closeIndex = closeLine - 2;
456
+ if (closeIndex >= 0 && closeIndex < lines.length) {
457
+ lines[closeIndex] = (lines[closeIndex] || '') + ' ' + closeTag;
458
+ }
459
+ }
460
+ break;
461
+ }
462
+ case NodeType.TEXT: {
463
+ const text = node;
464
+ // Apply padded trim to preserve intentional whitespace
465
+ const processed = this.padded_trim(text.content);
466
+ if (processed) {
467
+ const code = `_output.push(${this.escape_string(processed)});`;
468
+ // Optimization: skip consecutive identical space pushes
469
+ if (code === '_output.push(" ");' && this.lastOutput === '_output.push(" ");') {
470
+ // Skip duplicate - don't add to output
471
+ break;
472
+ }
473
+ this.lastOutput = code; // Track for next comparison
474
+ lines[lineIndex] = (lines[lineIndex] || '') + ' ' + code;
475
+ }
476
+ else {
477
+ // Empty text resets tracking so next space won't be skipped
478
+ this.lastOutput = '';
479
+ }
480
+ // Empty after processing: skip (no code generated)
481
+ break;
482
+ }
483
+ case NodeType.CODE_BLOCK: {
484
+ this.lastOutput = ''; // Reset for non-text output
485
+ const codeBlock = node;
486
+ const code = this.generate_code_block(codeBlock);
487
+ if (code) {
488
+ // With preprocessing, empty lines are already marked with /* empty line */
489
+ // So we can just add the code as-is, line by line
490
+ const codeLines = code.split('\n');
491
+ for (let i = 0; i < codeLines.length; i++) {
492
+ const targetIndex = lineIndex + i;
493
+ if (targetIndex < lines.length) {
494
+ // Add all lines including comment placeholders
495
+ const codeLine = codeLines[i].trim();
496
+ if (codeLine) {
497
+ lines[targetIndex] = (lines[targetIndex] || '') + ' ' + codeLines[i];
498
+ }
499
+ }
500
+ }
501
+ }
502
+ break;
503
+ }
504
+ case NodeType.EXPRESSION: {
505
+ this.lastOutput = ''; // Reset for non-text output
506
+ const expr = node;
507
+ // Generate the expression wrapper on a single line
508
+ let code;
509
+ // Special handling for content() calls
510
+ // Strip trailing semicolon if present (optional in <%= %> blocks)
511
+ let trimmedCode = expr.code.trim();
512
+ if (trimmedCode.endsWith(';')) {
513
+ trimmedCode = trimmedCode.slice(0, -1).trim();
514
+ }
515
+ if (trimmedCode === 'content()') {
516
+ // Default slot/content - check _inner_html first
517
+ 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]); } })();`;
518
+ }
519
+ else if (trimmedCode.startsWith('content.') && trimmedCode.endsWith('()')) {
520
+ // Named slot: content.header() (property access style)
521
+ const slotName = trimmedCode.slice(8, -2); // Extract "header" from "content.header()"
522
+ code = `(() => { if (typeof content === 'object' && typeof content.${slotName} === 'function') { const [contentInstructions] = content.${slotName}.call(this); _output.push(['_content', contentInstructions]); } })();`;
523
+ }
524
+ else if (trimmedCode.match(/^content\s*\(\s*['"]([^'"]+)['"]\s*(?:,\s*(.+?))?\s*\)$/)) {
525
+ // Named slot: content('header') or content('header', data) (function call with string parameter and optional data)
526
+ // Use the standard result pattern for proper handling
527
+ 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)); } })();`;
528
+ }
529
+ else if (expr.escaped) {
530
+ 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)); } })();`;
531
+ }
532
+ else {
533
+ 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); } })();`;
534
+ }
535
+ // Put the code on the starting line
536
+ if (code) {
537
+ lines[lineIndex] = (lines[lineIndex] || '') + ' ' + code;
538
+ }
539
+ break;
540
+ }
541
+ case NodeType.COMPONENT_INVOCATION: {
542
+ this.lastOutput = ''; // Reset for non-text output
543
+ const comp = node;
544
+ // For 1:1 mapping, generate compact component invocations
545
+ const attrs = this.generate_attributes_with_conditionals(comp.attributes, comp.conditionalAttributes);
546
+ if (comp.selfClosing || comp.children.length === 0) {
547
+ // Simple component without children
548
+ const code = `_output.push({comp: ["${comp.name}", ${attrs}]});`;
549
+ lines[lineIndex] = (lines[lineIndex] || '') + ' ' + code;
550
+ }
551
+ else {
552
+ // Check if children contain slots
553
+ const slots = this.extract_slots_from_children(comp.children);
554
+ if (Object.keys(slots).length > 0) {
555
+ // Component with slots - use generate_component_invocation for proper slot handling
556
+ const code = this.generate_component_invocation(comp);
557
+ lines[lineIndex] = (lines[lineIndex] || '') + ' ' + code;
558
+ }
559
+ else {
560
+ // Component with regular content (no slots)
561
+ // Generate inline content function
562
+ // Always include let _output = []; inside the function
563
+ lines[lineIndex] = (lines[lineIndex] || '') + ` _output.push({comp: ["${comp.name}", ${attrs}, function(${comp.name}) { let _output = [];`;
564
+ // Process children
565
+ if (comp.children && comp.children.length > 0) {
566
+ // Check if all children are simple text/inline nodes that can go on one line
567
+ const allInline = comp.children.every(child => child.type === NodeType.TEXT ||
568
+ (child.type === NodeType.COMPONENT_INVOCATION && child.selfClosing));
569
+ if (allInline) {
570
+ // Put all inline children on the same line as the component
571
+ comp.children.forEach(child => {
572
+ const childCode = this.generate_node(child);
573
+ if (childCode) {
574
+ lines[lineIndex] = (lines[lineIndex] || '') + ' ' + childCode;
575
+ }
576
+ });
577
+ // Close on same line
578
+ lines[lineIndex] = (lines[lineIndex] || '') + ' return [_output, this]; }.bind(this)]});';
579
+ }
580
+ else {
581
+ // Multi-line children - process on their respective lines
582
+ comp.children.forEach(child => {
583
+ const origLine = child.line;
584
+ if (origLine && origLine >= 2) {
585
+ const childIndex = origLine - 2;
586
+ if (childIndex >= 0 && childIndex < lines.length) {
587
+ const childCode = this.generate_node(child);
588
+ if (childCode) {
589
+ lines[childIndex] = (lines[childIndex] || '') + ' ' + childCode;
590
+ }
591
+ }
592
+ }
593
+ });
594
+ // Return statement and closing on the last child's line
595
+ const returnLine = comp.children[comp.children.length - 1].line || node.line;
596
+ const returnIndex = returnLine - 2;
597
+ if (returnIndex >= 0 && returnIndex < lines.length) {
598
+ lines[returnIndex] = (lines[returnIndex] || '') + ' return [_output, this]; }.bind(this)]});';
599
+ }
600
+ }
601
+ }
602
+ else {
603
+ // No children - close on same line
604
+ lines[lineIndex] = (lines[lineIndex] || '') + ' return [_output, this]; }.bind(this)]});';
605
+ }
606
+ }
607
+ }
608
+ break;
609
+ }
610
+ case NodeType.SLOT: {
611
+ // Generate slot function definition with line mapping
612
+ const slot = node;
613
+ const slotName = slot.name;
614
+ // Start slot function on this line
615
+ lines[lineIndex] = (lines[lineIndex] || '') + ` ${slotName}: function(${slotName}) { const _output = [];`;
616
+ // Process slot children on their respective lines
617
+ if (slot.children && slot.children.length > 0) {
618
+ slot.children.forEach(child => {
619
+ const childLine = child.line;
620
+ if (childLine && childLine >= 2) {
621
+ const childIndex = childLine - 2;
622
+ if (childIndex >= 0 && childIndex < lines.length) {
623
+ const childCode = this.generate_node(child);
624
+ if (childCode) {
625
+ lines[childIndex] = (lines[childIndex] || '') + ' ' + childCode;
626
+ }
627
+ }
628
+ }
629
+ });
630
+ // Close slot function on the last child's line
631
+ const lastChild = slot.children[slot.children.length - 1];
632
+ const closeLine = lastChild.line || node.line;
633
+ const closeIndex = closeLine - 2;
634
+ if (closeIndex >= 0 && closeIndex < lines.length) {
635
+ lines[closeIndex] = (lines[closeIndex] || '') + ' return [_output, this]; }.bind(this),';
636
+ }
637
+ }
638
+ else {
639
+ // Empty slot - close on same line
640
+ lines[lineIndex] = (lines[lineIndex] || '') + ' return [_output, this]; }.bind(this),';
641
+ }
642
+ break;
643
+ }
644
+ default: {
645
+ // For other node types, use the standard generator
646
+ const code = this.generate_node(node);
647
+ if (code) {
648
+ lines[lineIndex] = (lines[lineIndex] || '') + ' ' + code;
649
+ }
650
+ }
651
+ }
652
+ };
653
+ // Process all top-level nodes
654
+ for (const node of nodes) {
655
+ processNodeForLine(node);
656
+ }
657
+ // For true 1:1 mapping, we must preserve ALL lines, even empty ones
658
+ // Each source line from 2 to maxLine should produce exactly one output line
659
+ // Don't remove any empty lines - they're essential for maintaining line positions
660
+ return lines;
661
+ }
662
+ generate_node(node) {
663
+ switch (node.type) {
664
+ case NodeType.TEXT:
665
+ return this.generate_text(node);
666
+ case NodeType.EXPRESSION:
667
+ return this.generate_expression(node);
668
+ case NodeType.HTML_TAG:
669
+ return this.generate_html_tag(node);
670
+ case NodeType.COMPONENT_INVOCATION:
671
+ return this.generate_component_invocation(node);
672
+ case NodeType.IF_STATEMENT:
673
+ return this.generate_if(node);
674
+ case NodeType.FOR_STATEMENT:
675
+ return this.generate_for(node);
676
+ case NodeType.CODE_BLOCK:
677
+ return this.generate_code_block(node);
678
+ default:
679
+ console.warn(`Unknown node type: ${node.type}`);
680
+ return '';
681
+ }
682
+ }
683
+ /**
684
+ * Padded trim: Collapse internal whitespace but preserve leading/trailing space
685
+ * Examples:
686
+ * " hello " → " hello "
687
+ * "hello" → "hello"
688
+ * " " → " "
689
+ * "\n\n \n" → " "
690
+ */
691
+ padded_trim(text) {
692
+ const has_leading_space = /^\s/.test(text);
693
+ const has_trailing_space = /\s$/.test(text);
694
+ // Trim the text
695
+ let result = text.trim();
696
+ // Add back single space if original had leading/trailing whitespace
697
+ if (has_leading_space)
698
+ result = ' ' + result;
699
+ if (has_trailing_space)
700
+ result = result + ' ';
701
+ // Final pass: collapse all whitespace sequences to single space
702
+ return result.replace(/\s+/g, ' ');
703
+ }
704
+ generate_text(node) {
705
+ const content = node.content;
706
+ // Apply padded trim to preserve intentional whitespace
707
+ const processed = this.padded_trim(content);
708
+ // Skip if empty after processing
709
+ if (!processed) {
710
+ return '';
711
+ }
712
+ // Generate output code
713
+ const escaped = this.escape_string(processed);
714
+ const output = `_output.push(${escaped});`;
715
+ // Optimization: skip consecutive identical space pushes (but never skip newlines)
716
+ if (output === '_output.push(" ");' && this.lastOutput === '_output.push(" ");') {
717
+ return ''; // Skip duplicate space push
718
+ }
719
+ // Track this output for next comparison
720
+ this.lastOutput = output;
721
+ // Track the emitted position with source mapping
722
+ if (this.enablePositionTracking) {
723
+ this.emit(output, node);
724
+ }
725
+ return output;
726
+ }
727
+ generate_expression(node) {
728
+ this.lastOutput = ''; // Reset for non-text output
729
+ let output;
730
+ // Special handling for content() calls
731
+ // Strip trailing semicolon if present (optional in <%= %> blocks)
732
+ let trimmedCode = node.code.trim();
733
+ if (trimmedCode.endsWith(';')) {
734
+ trimmedCode = trimmedCode.slice(0, -1).trim();
735
+ }
736
+ if (trimmedCode === 'content()') {
737
+ // Default slot/content - check _inner_html first
738
+ 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]); } })();`;
739
+ }
740
+ else if (trimmedCode.startsWith('content.') && trimmedCode.endsWith('()')) {
741
+ // Named slot: content.header() (property access style)
742
+ const slotName = trimmedCode.slice(8, -2); // Extract "header" from "content.header()"
743
+ output = `(() => { if (typeof content === 'object' && typeof content.${slotName} === 'function') { const [contentInstructions] = content.${slotName}.call(this); _output.push(['_content', contentInstructions]); } })();`;
744
+ }
745
+ else if (trimmedCode.match(/^content\s*\(\s*['"]([^'"]+)['"]\s*(?:,\s*(.+?))?\s*\)$/)) {
746
+ // Named slot: content('header') or content('header', data) (function call with string parameter and optional data)
747
+ // Use the standard result pattern for proper handling
748
+ 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)); } })();`;
749
+ }
750
+ else if (node.escaped) {
751
+ // Single-line expression handler for escaped output
752
+ 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)); } })();`;
753
+ }
754
+ else {
755
+ // Single-line expression handler for unescaped output
756
+ 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); } })();`;
757
+ }
758
+ // Track the emitted position with source mapping
759
+ if (this.enablePositionTracking) {
760
+ this.emit(output, node);
761
+ }
762
+ return output;
763
+ }
764
+ generate_if(node) {
765
+ // Clean up condition - remove trailing opening brace
766
+ let condition = node.condition.trim();
767
+ condition = condition.replace(/\s*{\s*$/, ''); // Remove trailing brace
768
+ // Generate consequent body inline
769
+ const consequent_parts = [];
770
+ for (const child of node.consequent) {
771
+ const child_code = this.generate_node(child);
772
+ if (child_code) {
773
+ consequent_parts.push(child_code);
774
+ }
775
+ }
776
+ // Generate if statement - keep on single line for line preservation
777
+ let code = `if (${condition}) { ${consequent_parts.join(' ')} }`;
778
+ // Add else clause if present
779
+ if (node.alternate && node.alternate.length > 0) {
780
+ const alternate_parts = [];
781
+ for (const child of node.alternate) {
782
+ const child_code = this.generate_node(child);
783
+ if (child_code) {
784
+ alternate_parts.push(child_code);
785
+ }
786
+ }
787
+ code += ` else { ${alternate_parts.join(' ')} }`;
788
+ }
789
+ // Track the emitted position with source mapping
790
+ if (this.enablePositionTracking) {
791
+ this.emit(code, node);
792
+ }
793
+ return code;
794
+ }
795
+ generate_for(node) {
796
+ const iterator = node.iterator.trim();
797
+ // Generate body inline
798
+ const body_parts = [];
799
+ for (const child of node.body) {
800
+ const child_code = this.generate_node(child);
801
+ if (child_code) {
802
+ body_parts.push(child_code);
803
+ }
804
+ }
805
+ // Generate for loop - keep on single line for line preservation
806
+ const code = `for ${iterator} { ${body_parts.join(' ')} }`;
807
+ // Track the emitted position with source mapping
808
+ if (this.enablePositionTracking) {
809
+ this.emit(code, node);
810
+ }
811
+ return code;
812
+ }
813
+ generate_code_block(node) {
814
+ // Handle legacy code property for backward compatibility
815
+ if (node.code !== undefined) {
816
+ return node.code;
817
+ }
818
+ // Process tokens to transform PHP-style syntax
819
+ if (!node.tokens || node.tokens.length === 0) {
820
+ return '';
821
+ }
822
+ const result = [];
823
+ for (let i = 0; i < node.tokens.length; i++) {
824
+ const token = node.tokens[i];
825
+ const nextToken = node.tokens[i + 1];
826
+ switch (token.type) {
827
+ case 'IF':
828
+ result.push('if');
829
+ break;
830
+ case 'ELSE':
831
+ // Check if next token is IF for "else if"
832
+ if (nextToken && nextToken.type === 'IF') {
833
+ result.push('} else');
834
+ // Don't skip the IF token, it will be processed next
835
+ }
836
+ else if (nextToken && nextToken.type === 'JAVASCRIPT') {
837
+ // Check if this is "else if" in the JavaScript token
838
+ if (nextToken.value.trim().startsWith('if ')) {
839
+ result.push('} else');
840
+ // The 'if' is part of the JAVASCRIPT token, will be handled there
841
+ }
842
+ else if (nextToken.value.trim() === ':') {
843
+ // Regular else with colon in next token
844
+ result.push('} else {');
845
+ i++; // Skip the colon token
846
+ }
847
+ else {
848
+ // else with other JavaScript code
849
+ result.push('} else {');
850
+ }
851
+ }
852
+ else {
853
+ // else without anything after (shouldn't happen in valid JQHTML)
854
+ result.push('} else {');
855
+ }
856
+ break;
857
+ case 'ELSEIF':
858
+ result.push('} else if');
859
+ break;
860
+ case 'ENDIF':
861
+ result.push('}');
862
+ // Skip optional semicolon in next JAVASCRIPT token
863
+ if (nextToken && nextToken.type === 'JAVASCRIPT' && nextToken.value.trim() === ';') {
864
+ i++; // Skip the semicolon token
865
+ }
866
+ break;
867
+ case 'FOR':
868
+ result.push('for');
869
+ break;
870
+ case 'ENDFOR':
871
+ result.push('}');
872
+ // Skip optional semicolon in next JAVASCRIPT token
873
+ if (nextToken && nextToken.type === 'JAVASCRIPT' && nextToken.value.trim() === ';') {
874
+ i++; // Skip the semicolon token
875
+ }
876
+ break;
877
+ case 'JAVASCRIPT':
878
+ // Transform colons to opening braces after conditions
879
+ let jsCode = token.value;
880
+ // Check if this is a condition ending with colon
881
+ const prevToken = i > 0 ? node.tokens[i - 1] : null;
882
+ if (jsCode.endsWith(':') &&
883
+ prevToken &&
884
+ (prevToken.type === 'IF' || prevToken.type === 'FOR' || prevToken.type === 'ELSEIF' ||
885
+ (prevToken.type === 'ELSE' && jsCode.startsWith('if ')))) {
886
+ jsCode = jsCode.slice(0, -1) + ' {';
887
+ }
888
+ result.push(jsCode);
889
+ break;
890
+ default:
891
+ // Pass through any other token types
892
+ result.push(token.value);
893
+ }
894
+ }
895
+ const code = result.join(' ');
896
+ // Track the emitted position with source mapping
897
+ if (this.enablePositionTracking) {
898
+ this.emit(code, node);
899
+ }
900
+ return code;
901
+ }
902
+ generate_tag_open(node) {
903
+ // Generate just the opening tag
904
+ const attrs = this.generate_attributes_with_conditionals(node.attributes, node.conditionalAttributes);
905
+ return `_output.push({tag: ["${node.name}", ${attrs}, ${node.selfClosing || false}]});`;
906
+ }
907
+ generate_html_tag(node) {
908
+ this.lastOutput = ''; // Reset for non-text output
909
+ // Check if this tag needs raw content preservation
910
+ if (node.preserveWhitespace && !node.selfClosing && node.children.length > 0) {
911
+ // Validate: only TEXT, EXPRESSION, and CODE_BLOCK children allowed in raw content tags
912
+ // HTML tags and components are not allowed (they would break whitespace preservation)
913
+ for (const child of node.children) {
914
+ if (child.type !== NodeType.TEXT &&
915
+ child.type !== NodeType.EXPRESSION &&
916
+ child.type !== NodeType.CODE_BLOCK) {
917
+ const error = new JQHTMLParseError(`Invalid content in <${node.name}> tag`, node.line, node.column || 0, this.sourceContent, this.sourceFile);
918
+ error.suggestion =
919
+ `\n\nAll content within <textarea> and <pre> tags must be plain text or expressions.\n` +
920
+ `HTML tags and components are not allowed.\n\n` +
921
+ `Allowed:\n` +
922
+ ` <textarea><%= this.data.value %></textarea> ← expressions OK\n` +
923
+ ` <textarea>plain text</textarea> ← plain text OK\n\n` +
924
+ `Not allowed:\n` +
925
+ ` <textarea><div>content</div></textarea> ← HTML tags not OK\n` +
926
+ ` <textarea><MyComponent /></textarea> ← components not OK\n\n` +
927
+ `This ensures proper whitespace preservation.`;
928
+ throw error;
929
+ }
930
+ }
931
+ // Generate rawtag instruction with raw content
932
+ const attrs_obj = this.generate_attributes_with_conditionals(node.attributes, node.conditionalAttributes);
933
+ // Collect raw content from children (all validated as TEXT)
934
+ let rawContent = '';
935
+ for (const child of node.children) {
936
+ rawContent += child.content;
937
+ }
938
+ // Escape the raw content for JavaScript string
939
+ const escapedContent = this.escape_string(rawContent);
940
+ return `_output.push({rawtag: ["${node.name}", ${attrs_obj}, ${escapedContent}]});`;
941
+ }
942
+ // Normal tag generation
943
+ const parts = [];
944
+ // Generate opening tag instruction
945
+ const attrs_obj = this.generate_attributes_with_conditionals(node.attributes, node.conditionalAttributes);
946
+ parts.push(`_output.push({tag: ["${node.name}", ${attrs_obj}, ${node.selfClosing}]});`);
947
+ if (!node.selfClosing) {
948
+ // Generate children inline
949
+ if (node.children.length > 0) {
950
+ for (const child of node.children) {
951
+ const child_code = this.generate_node(child);
952
+ if (child_code) {
953
+ // Skip empty text nodes
954
+ if (!child_code.match(/^_output\.push\(""\);?$/)) {
955
+ parts.push(child_code);
956
+ }
957
+ }
958
+ }
959
+ }
960
+ // Generate closing tag
961
+ parts.push(`_output.push("</${node.name}>");`);
962
+ }
963
+ // Join parts with space for single-line tags, but this will be split later
964
+ return parts.join(' ');
965
+ }
966
+ generate_component_invocation(node) {
967
+ this.lastOutput = ''; // Reset for non-text output
968
+ const instructions = [];
969
+ const attrs_obj = this.generate_attributes_with_conditionals(node.attributes, node.conditionalAttributes);
970
+ if (node.selfClosing || node.children.length === 0) {
971
+ // Simple component without children
972
+ const componentCall = `_output.push({comp: ["${node.name}", ${attrs_obj}]});`;
973
+ instructions.push(componentCall);
974
+ // Track component invocation position
975
+ if (this.enablePositionTracking) {
976
+ this.emit(componentCall, node);
977
+ }
978
+ }
979
+ else {
980
+ // Check if children contain slots
981
+ const slots = this.extract_slots_from_children(node.children);
982
+ if (Object.keys(slots).length > 0) {
983
+ // Component with slots - generate content as object with slot functions ON A SINGLE LINE
984
+ const slotEntries = [];
985
+ for (const [slotName, slotNode] of Object.entries(slots)) {
986
+ const slot_code = slotNode.children.length > 0
987
+ ? this.generate_function_body(slotNode.children)
988
+ : '';
989
+ // Add slot name as parameter: function(slotName) { ... }
990
+ slotEntries.push(`${slotName}: function(${slotName}) { const _output = []; ${slot_code} return [_output, this]; }.bind(this)`);
991
+ }
992
+ // Everything on one line
993
+ instructions.push(`_output.push({comp: ["${node.name}", ${attrs_obj}, {${slotEntries.join(', ')}}]});`);
994
+ }
995
+ else {
996
+ // Component with regular content (no slots)
997
+ instructions.push(`_output.push({comp: ["${node.name}", ${attrs_obj}, function(${node.name}) {`);
998
+ instructions.push(` const _output = [];`);
999
+ const children_code = this.generate_function_body(node.children);
1000
+ if (children_code) {
1001
+ instructions.push(this.indent(children_code, 1));
1002
+ }
1003
+ instructions.push(` return [_output, this];`);
1004
+ instructions.push(`}.bind(this)]});`);
1005
+ }
1006
+ }
1007
+ return instructions.join('\n');
1008
+ }
1009
+ parse_attributes(attrs_string) {
1010
+ const attrs = {};
1011
+ // Simple attribute parser - handles key="value" and key=value
1012
+ const attr_regex = /([a-zA-Z$][a-zA-Z0-9-]*)\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/g;
1013
+ let match;
1014
+ while ((match = attr_regex.exec(attrs_string)) !== null) {
1015
+ const [, name, value] = match;
1016
+ // Remove quotes if present
1017
+ attrs[name] = value.replace(/^["']|["']$/g, '');
1018
+ }
1019
+ return attrs;
1020
+ }
1021
+ // Generate attribute object including conditional attributes
1022
+ generate_attributes_with_conditionals(attrs, conditionalAttrs) {
1023
+ // If no conditional attributes, use simple object
1024
+ if (!conditionalAttrs || conditionalAttrs.length === 0) {
1025
+ return this.generate_attributes_object(attrs);
1026
+ }
1027
+ // We have conditional attributes - need to merge them at runtime
1028
+ const baseAttrs = this.generate_attributes_object(attrs);
1029
+ // Generate code that conditionally adds attributes
1030
+ let result = baseAttrs;
1031
+ for (const condAttr of conditionalAttrs) {
1032
+ const condAttrsObj = this.generate_attributes_object(condAttr.attributes);
1033
+ // Use Object.assign to merge conditional attributes
1034
+ result = `Object.assign({}, ${result}, (${condAttr.condition}) ? ${condAttrsObj} : {})`;
1035
+ }
1036
+ return result;
1037
+ }
1038
+ generate_attributes_object(attrs) {
1039
+ if (Object.keys(attrs).length === 0) {
1040
+ return '{}';
1041
+ }
1042
+ const entries = Object.entries(attrs).flatMap(([key, value]) => {
1043
+ // Convert 'tag' to '_tag' for component invocations
1044
+ const attrKey = key === 'tag' ? '_tag' : key;
1045
+ // Special handling for data-sid attribute (from $sid) - create scoped id
1046
+ // NOTE: Parser converts $sid="foo" → data-sid="foo" so we can distinguish from regular id
1047
+ // This generates: id="foo:PARENT_CID" data-sid="foo"
1048
+ // The :PARENT_CID scoping happens at runtime in instruction-processor.ts
1049
+ if (key === 'data-sid') {
1050
+ const id_entries = [];
1051
+ if (value && typeof value === 'object' && value.interpolated) {
1052
+ // Interpolated $sid like $sid="user<%= index %>"
1053
+ const parts = value.parts.map((part) => {
1054
+ if (part.type === 'text') {
1055
+ return this.escape_string(part.value);
1056
+ }
1057
+ else {
1058
+ return part.value;
1059
+ }
1060
+ });
1061
+ const base_id = parts.join(' + ');
1062
+ id_entries.push(`"id": ${base_id} + ":" + this._cid`);
1063
+ id_entries.push(`"data-sid": ${base_id}`);
1064
+ }
1065
+ else if (value && typeof value === 'object' && value.quoted) {
1066
+ // Quoted $sid like $sid="static"
1067
+ const base_id = this.escape_string(value.value);
1068
+ id_entries.push(`"id": ${base_id} + ":" + this._cid`);
1069
+ id_entries.push(`"data-sid": ${base_id}`);
1070
+ }
1071
+ else {
1072
+ // Simple $sid like $sid="username" or expression like $sid=someVar
1073
+ const base_id = this.escape_string(String(value));
1074
+ id_entries.push(`"id": ${base_id} + ":" + this._cid`);
1075
+ id_entries.push(`"data-sid": ${base_id}`);
1076
+ }
1077
+ return id_entries;
1078
+ }
1079
+ // Regular id attribute - pass through unchanged
1080
+ // id="foo" remains id="foo" (no scoping)
1081
+ if (key === 'id') {
1082
+ if (value && typeof value === 'object' && value.quoted) {
1083
+ return `"id": ${this.escape_string(value.value)}`;
1084
+ }
1085
+ else {
1086
+ return `"id": ${this.escape_string(String(value))}`;
1087
+ }
1088
+ }
1089
+ // Check if this is an interpolated attribute value
1090
+ if (value && typeof value === 'object' && value.interpolated) {
1091
+ // Build concatenation expression
1092
+ const parts = value.parts.map((part) => {
1093
+ if (part.type === 'text') {
1094
+ return this.escape_string(part.value);
1095
+ }
1096
+ else {
1097
+ // Expression - wrap in parentheses to preserve operator precedence
1098
+ // This ensures "a" + (x ? 'b' : 'c') instead of "a" + x ? 'b' : 'c'
1099
+ return `(${part.value})`;
1100
+ }
1101
+ });
1102
+ return `"${attrKey}": ${parts.join(' + ')}`;
1103
+ }
1104
+ // Check if value is marked as quoted
1105
+ if (value && typeof value === 'object' && value.quoted) {
1106
+ // This was a quoted string in the source - always treat as string
1107
+ return `"${attrKey}": ${this.escape_string(value.value)}`;
1108
+ }
1109
+ // Check if it's a parenthesized expression: $attr=(expr)
1110
+ if (value && typeof value === 'object' && value.expression) {
1111
+ // It's an expression - output without quotes
1112
+ return `"${attrKey}": ${value.value}`;
1113
+ }
1114
+ // Check if it's a bare identifier: $attr=identifier
1115
+ if (value && typeof value === 'object' && value.identifier) {
1116
+ // It's an identifier - output as JavaScript expression
1117
+ return `"${attrKey}": ${value.value}`;
1118
+ }
1119
+ // Check if it's an event handler binding (data-__-on-*)
1120
+ if (key.startsWith('data-__-on-')) {
1121
+ // Handle based on whether value was quoted or not
1122
+ if (typeof value === 'object' && value !== null) {
1123
+ if (value.quoted) {
1124
+ // Was quoted: @click="handler" -> string literal
1125
+ return `"${key}": ${this.escape_string(value.value)}`;
1126
+ }
1127
+ else if (value.identifier || value.expression) {
1128
+ // Was unquoted: @click=this.handler -> JavaScript expression
1129
+ return `"${key}": ${value.value}`;
1130
+ }
1131
+ else if (value.interpolated) {
1132
+ // Has interpolation - needs special handling
1133
+ return `"${key}": ${this.compile_interpolated_value(value)}`;
1134
+ }
1135
+ }
1136
+ else if (typeof value === 'string') {
1137
+ // Fallback for simple strings (backwards compatibility)
1138
+ return `"${key}": ${this.escape_string(value)}`;
1139
+ }
1140
+ }
1141
+ // Check if it's a data attribute ($foo or :binding)
1142
+ if (key.startsWith('data-')) {
1143
+ // Handle based on whether value was quoted or not
1144
+ if (typeof value === 'object' && value !== null) {
1145
+ if (value.quoted) {
1146
+ // Was quoted: $foo="bar" -> string literal
1147
+ return `"${key}": ${this.escape_string(value.value)}`;
1148
+ }
1149
+ else if (value.identifier || value.expression) {
1150
+ // Was unquoted: $foo=this.data.bar -> JavaScript expression
1151
+ return `"${key}": ${value.value}`;
1152
+ }
1153
+ else if (value.interpolated) {
1154
+ // Has interpolation - needs special handling
1155
+ return `"${key}": ${this.compile_interpolated_value(value)}`;
1156
+ }
1157
+ }
1158
+ else if (typeof value === 'string') {
1159
+ // Fallback for simple strings
1160
+ return `"${key}": ${this.escape_string(value)}`;
1161
+ }
1162
+ }
1163
+ // Regular attributes
1164
+ if (typeof value === 'object' && value !== null) {
1165
+ if (value.quoted) {
1166
+ // Explicitly quoted value
1167
+ return `"${attrKey}": ${this.escape_string(value.value)}`;
1168
+ }
1169
+ else if (value.identifier || value.expression) {
1170
+ // Unquoted JavaScript expression
1171
+ return `"${attrKey}": ${value.value}`;
1172
+ }
1173
+ else if (value.interpolated) {
1174
+ // Has interpolation
1175
+ return `"${attrKey}": ${this.compile_interpolated_value(value)}`;
1176
+ }
1177
+ }
1178
+ // Default: treat as string
1179
+ return `"${attrKey}": ${this.escape_string(String(value))}`;
1180
+ });
1181
+ return `{${entries.join(', ')}}`;
1182
+ }
1183
+ is_self_closing_tag(tag_name) {
1184
+ const self_closing = [
1185
+ 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
1186
+ 'link', 'meta', 'param', 'source', 'track', 'wbr'
1187
+ ];
1188
+ return self_closing.includes(tag_name.toLowerCase());
1189
+ }
1190
+ compile_interpolated_value(value) {
1191
+ // Handle interpolated values with embedded expressions
1192
+ if (!value.parts || !Array.isArray(value.parts)) {
1193
+ return this.escape_string(String(value));
1194
+ }
1195
+ // Build template literal from parts
1196
+ const parts = value.parts.map((part) => {
1197
+ if (part.type === 'text') {
1198
+ // Escape text parts for template literal
1199
+ const escaped = part.value
1200
+ .replace(/\\/g, '\\\\')
1201
+ .replace(/`/g, '\\`')
1202
+ .replace(/\${/g, '\\${');
1203
+ return escaped;
1204
+ }
1205
+ else if (part.type === 'expression') {
1206
+ // Embed expression
1207
+ return `\${${part.value}}`;
1208
+ }
1209
+ return part.value;
1210
+ });
1211
+ // Return as template literal
1212
+ return '`' + parts.join('') + '`';
1213
+ }
1214
+ escape_string(str) {
1215
+ // Escape for JavaScript string literal
1216
+ const escaped = str
1217
+ .replace(/\\/g, '\\\\')
1218
+ .replace(/"/g, '\\"')
1219
+ .replace(/\n/g, '\\n')
1220
+ .replace(/\r/g, '\\r')
1221
+ .replace(/\t/g, '\\t');
1222
+ return `"${escaped}"`;
1223
+ }
1224
+ indent(code, level) {
1225
+ const indent = ' '.repeat(level);
1226
+ return code.split('\n').map(line => line ? indent + line : line).join('\n');
1227
+ }
1228
+ extract_slots_from_children(children) {
1229
+ const slots = {};
1230
+ for (const child of children) {
1231
+ if (child.type === NodeType.SLOT) {
1232
+ const slotNode = child;
1233
+ slots[slotNode.name] = slotNode;
1234
+ }
1235
+ }
1236
+ return slots;
1237
+ }
1238
+ // -------------------------------------------------------------------------
1239
+ // Position Tracking Methods
1240
+ // -------------------------------------------------------------------------
1241
+ /**
1242
+ * Emit text and track position for source maps
1243
+ */
1244
+ emit(text, node) {
1245
+ // Store the starting position before emitting
1246
+ const startLine = this.outputLine;
1247
+ const startColumn = this.outputColumn;
1248
+ // Update position tracking
1249
+ for (let i = 0; i < text.length; i++) {
1250
+ if (text[i] === '\n') {
1251
+ this.outputLine++;
1252
+ this.outputColumn = 0;
1253
+ }
1254
+ else {
1255
+ this.outputColumn++;
1256
+ }
1257
+ }
1258
+ // Log position if tracking enabled
1259
+ if (this.enablePositionTracking) {
1260
+ this.positionLog.push({
1261
+ line: this.outputLine,
1262
+ column: this.outputColumn,
1263
+ text: text.substring(0, 20), // First 20 chars for debugging
1264
+ node: node ? `${node.type}:L${node.line}:C${node.column}` : undefined
1265
+ });
1266
+ }
1267
+ // ARCHIVED: Token-by-token mapping approach - not currently used
1268
+ // We're using 1:1 line mapping instead, but keeping this code for potential future use
1269
+ /* istanbul ignore next */
1270
+ if (false) {
1271
+ // TypeScript can't analyze dead code properly, so we cast to any
1272
+ const deadCode = () => {
1273
+ if (this.sourceMapGenerator && node?.loc && this.sourceFile) {
1274
+ this.sourceMapGenerator.addMapping({
1275
+ generated: { line: startLine, column: startColumn },
1276
+ source: this.sourceFile,
1277
+ original: { line: node.loc.start.line, column: node.loc.start.column - 1 },
1278
+ name: undefined
1279
+ });
1280
+ }
1281
+ };
1282
+ }
1283
+ this.outputBuffer.push(text);
1284
+ return text;
1285
+ }
1286
+ /**
1287
+ * Emit a line of text (adds newline)
1288
+ */
1289
+ emitLine(text, node) {
1290
+ return this.emit(text + '\n', node);
1291
+ }
1292
+ /**
1293
+ * Reset position tracking
1294
+ */
1295
+ resetPositionTracking() {
1296
+ this.outputLine = 1;
1297
+ this.outputColumn = 0;
1298
+ this.outputBuffer = [];
1299
+ this.positionLog = [];
1300
+ }
1301
+ /**
1302
+ * Get position tracking log for debugging
1303
+ */
1304
+ getPositionLog() {
1305
+ return this.positionLog;
1306
+ }
1307
+ /**
1308
+ * Enable/disable position tracking
1309
+ */
1310
+ setPositionTracking(enabled) {
1311
+ this.enablePositionTracking = enabled;
1312
+ }
1313
+ /**
1314
+ * Serialize attribute object with proper handling of identifiers and expressions
1315
+ * Quoted values become strings, identifiers/expressions become raw JavaScript
1316
+ */
1317
+ serializeAttributeObject(obj) {
1318
+ if (!obj || Object.keys(obj).length === 0) {
1319
+ return '{}';
1320
+ }
1321
+ const entries = [];
1322
+ for (const [key, value] of Object.entries(obj)) {
1323
+ // Check if value is a parsed attribute object with type info
1324
+ if (value && typeof value === 'object' && (value.identifier || value.expression)) {
1325
+ // Identifier or expression - output as raw JavaScript (no quotes)
1326
+ entries.push(`"${key}": ${value.value}`);
1327
+ }
1328
+ else if (value && typeof value === 'object' && value.quoted) {
1329
+ // Quoted string - output as string literal
1330
+ entries.push(`"${key}": ${JSON.stringify(value.value)}`);
1331
+ }
1332
+ else {
1333
+ // Simple value - output as-is via JSON.stringify
1334
+ entries.push(`"${key}": ${JSON.stringify(value)}`);
1335
+ }
1336
+ }
1337
+ return `{${entries.join(', ')}}`;
1338
+ }
1339
+ build_module_code() {
1340
+ let code = '// Generated by JQHTML v2 Code Generator\n';
1341
+ code += '// Parser version: 2.2.56 (with 1:1 sourcemap fixes)\n';
1342
+ code += '// Produces v1-compatible instruction arrays\n\n';
1343
+ // Add html escaping function (assumed to be available in runtime)
1344
+ code += '// Runtime should provide: function html(str) { return escapeHtml(str); }\n\n';
1345
+ // Add component registration
1346
+ code += 'const jqhtml_components = new Map();\n\n';
1347
+ // Add each component
1348
+ for (const [name, component] of this.components) {
1349
+ code += `// Component: ${name}\n`;
1350
+ code += `jqhtml_components.set('${name}', {\n`;
1351
+ code += ` _jqhtml_version: '2.2.222',\n`; // Version will be replaced during build
1352
+ code += ` name: '${name}',\n`;
1353
+ code += ` tag: '${component.tagName}',\n`;
1354
+ code += ` defaultAttributes: ${this.serializeAttributeObject(component.defaultAttributes)},\n`;
1355
+ // Add defineArgs if present ($ attributes on Define tag)
1356
+ if (component.defineArgs) {
1357
+ code += ` defineArgs: ${this.serializeAttributeObject(component.defineArgs)},\n`;
1358
+ }
1359
+ // Add extends if present (template inheritance)
1360
+ if (component.extends) {
1361
+ code += ` extends: '${component.extends}',\n`;
1362
+ }
1363
+ code += ` render: ${this.indent(component.render_function, 1).trim()},\n`;
1364
+ code += ` dependencies: [${component.dependencies.map(d => `'${d}'`).join(', ')}]\n`;
1365
+ code += '});\n\n';
1366
+ }
1367
+ // Add export
1368
+ code += 'export { jqhtml_components };\n';
1369
+ return code;
1370
+ }
1371
+ }
1372
+ // Helper function for standalone usage
1373
+ export function generate(ast, sourceFile, sourceContent) {
1374
+ const generator = new CodeGenerator();
1375
+ return generator.generate(ast, sourceFile, sourceContent);
1376
+ }
1377
+ //# sourceMappingURL=codegen.js.map