@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.
package/dist/parser.js ADDED
@@ -0,0 +1,845 @@
1
+ // JQHTML Parser - Builds AST from tokens
2
+ // Simple recursive descent parser, no complex libraries
3
+ import { TokenType } from './lexer.js';
4
+ import { NodeType, createNode } from './ast.js';
5
+ import { JQHTMLParseError, unclosedError, mismatchedTagError, syntaxError, getSuggestion } from './errors.js';
6
+ import { CodeGenerator } from './codegen.js';
7
+ export class Parser {
8
+ tokens;
9
+ current = 0;
10
+ source;
11
+ filename;
12
+ // HTML5 void elements that cannot have closing tags
13
+ // These are automatically treated as self-closing
14
+ static VOID_ELEMENTS = new Set([
15
+ 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
16
+ 'link', 'meta', 'source', 'track', 'wbr'
17
+ ]);
18
+ constructor(tokens, source, filename) {
19
+ this.tokens = tokens;
20
+ this.source = source;
21
+ this.filename = filename;
22
+ }
23
+ /**
24
+ * Validate JavaScript code for common mistakes
25
+ */
26
+ validate_javascript_code(code, token) {
27
+ // Check for this.innerHTML usage (should use content() function instead)
28
+ if (/\bthis\.innerHTML\b/.test(code)) {
29
+ const error = new JQHTMLParseError(`Invalid usage: this.innerHTML is not available in JQHTML templates.\n` +
30
+ `Did you mean to use the content() function instead?`, token.line, token.column, this.source);
31
+ error.suggestion =
32
+ `\nJQHTML uses content() to render child elements, not this.innerHTML:\n\n` +
33
+ ` ❌ Wrong: <%= this.innerHTML %>\n` +
34
+ ` ✓ Correct: <%= content() %>\n\n` +
35
+ ` ❌ Wrong: <% if (condition) { %> this.innerHTML <% } %>\n` +
36
+ ` ✓ Correct: <% if (condition) { %> <%= content() %> <% } %>\n\n` +
37
+ `Why content() instead of this.innerHTML?\n` +
38
+ `- content() is a function that returns the rendered child content\n` +
39
+ `- this.innerHTML is a DOM property, not available during template compilation\n` +
40
+ `- content() supports named slots: content('slot_name')\n` +
41
+ `- content() can pass data: content('row', rowData)`;
42
+ throw error;
43
+ }
44
+ }
45
+ // Main entry point - parse tokens into AST
46
+ parse() {
47
+ const body = [];
48
+ const start = this.current_token();
49
+ // Skip leading whitespace and newlines
50
+ while (!this.is_at_end() && (this.match(TokenType.NEWLINE) ||
51
+ (this.match(TokenType.TEXT) && this.previous_token().value.trim() === ''))) {
52
+ // Skip whitespace
53
+ }
54
+ // Check if file is empty (only whitespace)
55
+ if (this.is_at_end()) {
56
+ // Empty file is allowed
57
+ return createNode(NodeType.PROGRAM, { body: [] }, start.start, start.end, start.line, start.column, this.create_location(start, start));
58
+ }
59
+ // Must have exactly one Define tag at top level
60
+ if (!this.check(TokenType.DEFINE_START)) {
61
+ const token = this.current_token();
62
+ throw syntaxError('JQHTML files must have exactly one top-level <Define:ComponentName> tag', token.line, token.column, this.source, this.filename);
63
+ }
64
+ // Parse the single component definition
65
+ const component = this.parse_component_definition();
66
+ if (component) {
67
+ body.push(component);
68
+ }
69
+ // Skip trailing whitespace and newlines
70
+ while (!this.is_at_end() && (this.match(TokenType.NEWLINE) || this.match(TokenType.TEXT) && this.previous_token().value.trim() === '')) {
71
+ // Skip whitespace
72
+ }
73
+ // Ensure no other content after the Define tag
74
+ if (!this.is_at_end()) {
75
+ const token = this.current_token();
76
+ throw syntaxError('JQHTML files must have exactly one top-level <Define:ComponentName> tag. Found additional content after the component definition.', token.line, token.column, this.source, this.filename);
77
+ }
78
+ const end = this.previous_token();
79
+ return createNode(NodeType.PROGRAM, { body }, start.start, end.end, start.line, start.column, this.create_location(start, end));
80
+ }
81
+ // Parse top-level constructs
82
+ parse_top_level() {
83
+ // Skip whitespace-only text nodes at top level
84
+ if (this.match(TokenType.NEWLINE)) {
85
+ return null;
86
+ }
87
+ // Component definition
88
+ if (this.check(TokenType.DEFINE_START)) {
89
+ return this.parse_component_definition();
90
+ }
91
+ // Regular content
92
+ return this.parse_content();
93
+ }
94
+ // Parse <Define:ComponentName>...</Define:ComponentName>
95
+ parse_component_definition() {
96
+ const start_token = this.consume(TokenType.DEFINE_START, 'Expected <Define:');
97
+ const name_token = this.consume(TokenType.COMPONENT_NAME, 'Expected component name');
98
+ // Validate component name starts with capital letter
99
+ if (!/^[A-Z]/.test(name_token.value)) {
100
+ throw syntaxError(`Component name '${name_token.value}' must start with a capital letter. Convention is First_Letter_With_Underscores.`, name_token.line, name_token.column, this.source, this.filename);
101
+ }
102
+ // Parse attributes (like tag="span", class="card", extends="Parent", $prop=value)
103
+ const attributes = {};
104
+ const defineArgs = {}; // $ attributes (raw JS, not data- attributes)
105
+ let extendsValue;
106
+ // Skip any leading newlines before attributes
107
+ while (this.match(TokenType.NEWLINE)) {
108
+ // Skip
109
+ }
110
+ while (!this.check(TokenType.GT) && !this.is_at_end()) {
111
+ const attr_name = this.consume(TokenType.ATTR_NAME, 'Expected attribute name');
112
+ // Validate that $sid is not used in Define tags
113
+ if (attr_name.value === '$sid') {
114
+ throw syntaxError('$sid is not allowed in <Define:> tags. Component definitions cannot have scoped IDs.', attr_name.line, attr_name.column, this.source, this.filename);
115
+ }
116
+ this.consume(TokenType.EQUALS, 'Expected =');
117
+ const attr_value = this.parse_attribute_value();
118
+ // Check if attribute value contains template expressions (dynamic content)
119
+ // Define tag attributes must be static - they're part of the template definition, not runtime values
120
+ const hasInterpolation = attr_value &&
121
+ typeof attr_value === 'object' &&
122
+ (attr_value.interpolated === true ||
123
+ (attr_value.parts && attr_value.parts.some((p) => p.type === 'expression')));
124
+ if (hasInterpolation) {
125
+ // Special error message for class attribute - most common case
126
+ if (attr_name.value === 'class') {
127
+ const error = syntaxError(`Template expressions cannot be used in <Define:> tag attributes. The <Define> tag is a static template definition, not a live component instance.`, attr_name.line, attr_name.column, this.source, this.filename);
128
+ error.message += '\n\n' +
129
+ ' For dynamic classes, set the class attribute on the component invocation instead:\n' +
130
+ ' ❌ <Define:MyComponent class="<%= this.args.col_class || \'default\' %>">\n' +
131
+ ' ✅ <Define:MyComponent class="static-class">\n' +
132
+ ' ...\n' +
133
+ ' </Define:MyComponent>\n\n' +
134
+ ' Then when using the component:\n' +
135
+ ' <MyComponent class="col-md-8 col-xl-4" />\n\n' +
136
+ ' Or set attributes dynamically in on_create() or on_ready():\n' +
137
+ ' on_ready() {\n' +
138
+ ' const classes = this.args.col_class || \'col-12 col-md-6\';\n' +
139
+ ' this.$.addClass(classes);\n' +
140
+ ' }';
141
+ throw error;
142
+ }
143
+ else {
144
+ // General error for other attributes
145
+ const error = syntaxError(`Template expressions cannot be used in <Define:> tag attributes. The <Define> tag is a static template definition, not a live component instance.`, attr_name.line, attr_name.column, this.source, this.filename);
146
+ error.message += '\n\n' +
147
+ ` For dynamic "${attr_name.value}" attribute values, set them dynamically in lifecycle methods:\n` +
148
+ ' ❌ <Define:MyComponent ' + attr_name.value + '="<%= expression %>">\n' +
149
+ ' ✅ <Define:MyComponent>\n' +
150
+ ' ...\n' +
151
+ ' </Define:MyComponent>\n\n' +
152
+ ' Then in your component class:\n' +
153
+ ' on_create() {\n' +
154
+ ` this.$.attr('${attr_name.value}', this.args.some_value);\n` +
155
+ ' }\n\n' +
156
+ ' Or use on_ready() for attributes that need DOM to be fully initialized.';
157
+ throw error;
158
+ }
159
+ }
160
+ // Handle special attributes on Define tags
161
+ if (attr_name.value === 'extends') {
162
+ // extends="ParentComponent" - explicit template inheritance
163
+ if (typeof attr_value === 'object' && attr_value.quoted) {
164
+ extendsValue = attr_value.value;
165
+ }
166
+ else if (typeof attr_value === 'string') {
167
+ extendsValue = attr_value;
168
+ }
169
+ else {
170
+ throw syntaxError(`extends attribute must be a quoted string with the parent component name`, attr_name.line, attr_name.column, this.source, this.filename);
171
+ }
172
+ }
173
+ else if (attr_name.value.startsWith('$')) {
174
+ // $ attributes on Define tags are raw JS assignments (like component invocations)
175
+ // Store them separately - they don't become data- attributes
176
+ const propName = attr_name.value.substring(1); // Remove $
177
+ defineArgs[propName] = attr_value;
178
+ }
179
+ else {
180
+ // Regular attributes (tag="span", class="card", etc.)
181
+ attributes[attr_name.value] = attr_value;
182
+ }
183
+ // Skip newlines between attributes
184
+ while (this.match(TokenType.NEWLINE)) {
185
+ // Skip
186
+ }
187
+ }
188
+ this.consume(TokenType.GT, 'Expected >');
189
+ const body = [];
190
+ // Parse until we find the closing tag
191
+ while (!this.check(TokenType.DEFINE_END)) {
192
+ if (this.is_at_end()) {
193
+ const error = unclosedError('component definition', name_token.value, name_token.line, name_token.column, this.source, this.filename);
194
+ error.message += getSuggestion(error.message);
195
+ throw error;
196
+ }
197
+ const node = this.parse_content();
198
+ if (node) {
199
+ body.push(node);
200
+ }
201
+ }
202
+ // Consume closing tag
203
+ this.consume(TokenType.DEFINE_END, 'Expected </Define:');
204
+ const closing_name = this.consume(TokenType.COMPONENT_NAME, 'Expected component name');
205
+ if (closing_name.value !== name_token.value) {
206
+ throw mismatchedTagError(`Define:${name_token.value}`, `Define:${closing_name.value}`, closing_name.line, closing_name.column, this.source, this.filename);
207
+ }
208
+ const end_token = this.consume(TokenType.GT, 'Expected >');
209
+ // Detect slot-only templates for inheritance
210
+ let isSlotOnly = false;
211
+ let slotNames = [];
212
+ // Check if body contains only slot nodes (ignoring whitespace-only text)
213
+ const nonWhitespaceNodes = body.filter(node => {
214
+ if (node.type === NodeType.TEXT) {
215
+ return node.content.trim() !== '';
216
+ }
217
+ return true;
218
+ });
219
+ if (nonWhitespaceNodes.length > 0) {
220
+ // Check if ALL non-whitespace nodes are slots
221
+ const allSlots = nonWhitespaceNodes.every(node => node.type === NodeType.SLOT);
222
+ if (allSlots) {
223
+ isSlotOnly = true;
224
+ slotNames = nonWhitespaceNodes.map(node => node.name);
225
+ }
226
+ }
227
+ return createNode(NodeType.COMPONENT_DEFINITION, {
228
+ name: name_token.value,
229
+ body,
230
+ attributes,
231
+ extends: extendsValue,
232
+ defineArgs: Object.keys(defineArgs).length > 0 ? defineArgs : undefined,
233
+ isSlotOnly,
234
+ slotNames
235
+ }, start_token.start, end_token.end, start_token.line, start_token.column, this.create_location(start_token, end_token));
236
+ }
237
+ // Parse content (text, expressions, control flow)
238
+ parse_content() {
239
+ // Comments are now preprocessed into whitespace by the lexer
240
+ // Plain text
241
+ if (this.match(TokenType.TEXT)) {
242
+ const token = this.previous();
243
+ return createNode(NodeType.TEXT, { content: token.value }, token.start, token.end, token.line, token.column, this.create_location(token, token));
244
+ }
245
+ // Expression <%= ... %> or <%!= ... %>
246
+ if (this.match(TokenType.EXPRESSION_START) ||
247
+ this.match(TokenType.EXPRESSION_UNESCAPED)) {
248
+ return this.parse_expression();
249
+ }
250
+ // Code block <% ... %>
251
+ if (this.match(TokenType.CODE_START)) {
252
+ return this.parse_code_block();
253
+ }
254
+ // Slot <Slot:name>...</Slot:name>
255
+ if (this.match(TokenType.SLOT_START)) {
256
+ return this.parse_slot();
257
+ }
258
+ // HTML tags and component invocations
259
+ if (this.match(TokenType.TAG_OPEN)) {
260
+ return this.parse_tag();
261
+ }
262
+ // Skip newlines in content
263
+ if (this.match(TokenType.NEWLINE)) {
264
+ const token = this.previous();
265
+ return createNode(NodeType.TEXT, { content: token.value }, token.start, token.end, token.line, token.column, this.create_location(token, token));
266
+ }
267
+ // Advance if we don't recognize the token
268
+ if (!this.is_at_end()) {
269
+ this.advance();
270
+ }
271
+ return null;
272
+ }
273
+ // Parse <%= expression %> or <%!= expression %>
274
+ parse_expression() {
275
+ const start_token = this.previous(); // EXPRESSION_START or EXPRESSION_UNESCAPED
276
+ const code_token = this.consume(TokenType.JAVASCRIPT, 'Expected JavaScript code');
277
+ // Validate JavaScript code for common mistakes
278
+ this.validate_javascript_code(code_token.value, code_token);
279
+ const end_token = this.consume(TokenType.TAG_END, 'Expected %>');
280
+ return createNode(NodeType.EXPRESSION, {
281
+ code: code_token.value,
282
+ escaped: start_token.type === TokenType.EXPRESSION_START
283
+ }, start_token.start, end_token.end, start_token.line, start_token.column, this.create_location(start_token, end_token));
284
+ }
285
+ // Parse <% code %> - collect tokens with their types for proper transformation
286
+ parse_code_block() {
287
+ const start_token = this.previous(); // CODE_START
288
+ // Collect tokens with their types
289
+ const tokens = [];
290
+ while (!this.check(TokenType.TAG_END)) {
291
+ if (this.is_at_end()) {
292
+ throw syntaxError('Unterminated code block - expected %>', start_token.line, start_token.column, this.source, this.filename);
293
+ }
294
+ const token = this.advance();
295
+ // Validate JavaScript tokens for common mistakes
296
+ if (token.type === TokenType.JAVASCRIPT) {
297
+ this.validate_javascript_code(token.value, token);
298
+ }
299
+ tokens.push({ type: token.type, value: token.value });
300
+ }
301
+ const end_token = this.consume(TokenType.TAG_END, 'Expected %>');
302
+ return createNode(NodeType.CODE_BLOCK, { tokens }, // Pass tokens array instead of concatenated code
303
+ start_token.start, end_token.end, start_token.line, start_token.column, this.create_location(start_token, end_token));
304
+ }
305
+ // JavaScript reserved words that cannot be used as slot names
306
+ static JAVASCRIPT_RESERVED_WORDS = new Set([
307
+ // Keywords
308
+ 'break', 'case', 'catch', 'class', 'const', 'continue', 'debugger', 'default',
309
+ 'delete', 'do', 'else', 'enum', 'export', 'extends', 'false', 'finally',
310
+ 'for', 'function', 'if', 'import', 'in', 'instanceof', 'let', 'new', 'null',
311
+ 'return', 'super', 'switch', 'this', 'throw', 'true', 'try', 'typeof',
312
+ 'var', 'void', 'while', 'with', 'yield',
313
+ // Future reserved words
314
+ 'implements', 'interface', 'package', 'private', 'protected', 'public', 'static', 'await',
315
+ // Other problematic words
316
+ 'arguments', 'eval'
317
+ ]);
318
+ // Parse slot <Slot:name>content</Slot:name> or <Slot:name />
319
+ parse_slot() {
320
+ const start_token = this.previous(); // SLOT_START
321
+ const name_token = this.consume(TokenType.SLOT_NAME, 'Expected slot name');
322
+ // Validate slot name against JavaScript reserved words
323
+ if (Parser.JAVASCRIPT_RESERVED_WORDS.has(name_token.value.toLowerCase())) {
324
+ throw syntaxError(`Slot name "${name_token.value}" is a JavaScript reserved word and cannot be used. Please choose a different name.`, name_token.line, name_token.column, this.source, this.filename);
325
+ }
326
+ // TODO: Parse attributes for let:prop syntax in future
327
+ const attributes = {};
328
+ // Check for self-closing slot
329
+ if (this.match(TokenType.SLASH)) {
330
+ const end_token = this.consume(TokenType.GT, 'Expected >');
331
+ return createNode(NodeType.SLOT, {
332
+ name: name_token.value,
333
+ attributes,
334
+ children: [],
335
+ selfClosing: true
336
+ }, start_token.start, end_token.end, start_token.line, start_token.column, this.create_location(start_token, end_token));
337
+ }
338
+ // Regular slot with content
339
+ this.consume(TokenType.GT, 'Expected >');
340
+ const children = [];
341
+ // Parse until we find the closing tag
342
+ while (!this.check(TokenType.SLOT_END)) {
343
+ if (this.is_at_end()) {
344
+ const error = unclosedError('slot', name_token.value, name_token.line, name_token.column, this.source, this.filename);
345
+ error.message += getSuggestion(error.message);
346
+ throw error;
347
+ }
348
+ const node = this.parse_content();
349
+ if (node) {
350
+ children.push(node);
351
+ }
352
+ }
353
+ // Consume closing tag
354
+ this.consume(TokenType.SLOT_END, 'Expected </Slot:');
355
+ const closing_name = this.consume(TokenType.SLOT_NAME, 'Expected slot name');
356
+ if (closing_name.value !== name_token.value) {
357
+ throw mismatchedTagError(name_token.value, closing_name.value, closing_name.line, closing_name.column, this.source, this.filename);
358
+ }
359
+ const end_token = this.consume(TokenType.GT, 'Expected >');
360
+ return createNode(NodeType.SLOT, {
361
+ name: name_token.value,
362
+ attributes,
363
+ children,
364
+ selfClosing: false
365
+ }, start_token.start, end_token.end, start_token.line, start_token.column, this.create_location(start_token, end_token));
366
+ }
367
+ // Token navigation helpers
368
+ // Parse HTML tag or component invocation
369
+ parse_tag() {
370
+ const start_token = this.previous(); // TAG_OPEN
371
+ const name_token = this.consume(TokenType.TAG_NAME, 'Expected tag name');
372
+ let tag_name = name_token.value;
373
+ let original_tag_name = null; // Track original for $redrawable
374
+ // Check for forbidden tags
375
+ const tag_lower = tag_name.toLowerCase();
376
+ if (tag_lower === 'script' || tag_lower === 'style') {
377
+ throw syntaxError(`<${tag_name}> tags are not allowed in JQHTML templates. ` +
378
+ `Use external files or inline styles via attributes instead.`, name_token.line, name_token.column, this.source, this.filename);
379
+ }
380
+ // Determine if this is a component (starts with capital letter) or HTML tag
381
+ let is_component = tag_name[0] >= 'A' && tag_name[0] <= 'Z';
382
+ // Check if this is an HTML5 void element (only for HTML tags, not components)
383
+ const is_void_element = !is_component && Parser.VOID_ELEMENTS.has(tag_lower);
384
+ // Parse attributes
385
+ const { attributes, conditionalAttributes } = this.parse_attributes();
386
+ // Check for $redrawable attribute transformation
387
+ // Transform <div $redrawable> to <Redrawable tag="div">
388
+ if (attributes['$redrawable'] !== undefined || attributes['data-redrawable'] !== undefined) {
389
+ const redrawable_attr = attributes['$redrawable'] !== undefined ? '$redrawable' : 'data-redrawable';
390
+ // Remove the $redrawable attribute
391
+ delete attributes[redrawable_attr];
392
+ // Store original tag name for closing tag matching
393
+ original_tag_name = tag_name;
394
+ // Add tag="original_tag_name" attribute
395
+ attributes['tag'] = { quoted: true, value: tag_name };
396
+ // Transform tag name to Redrawable (reserved component name)
397
+ tag_name = 'Redrawable';
398
+ is_component = true; // Now it's a component
399
+ }
400
+ // Check for explicit self-closing syntax
401
+ if (this.match(TokenType.SELF_CLOSING)) {
402
+ const end_token = this.previous();
403
+ if (is_component) {
404
+ return createNode(NodeType.COMPONENT_INVOCATION, {
405
+ name: tag_name,
406
+ attributes,
407
+ conditionalAttributes: conditionalAttributes.length > 0 ? conditionalAttributes : undefined,
408
+ children: [],
409
+ selfClosing: true
410
+ }, start_token.start, end_token.end, start_token.line, start_token.column, this.create_location(start_token, end_token));
411
+ }
412
+ else {
413
+ return createNode(NodeType.HTML_TAG, {
414
+ name: tag_name,
415
+ attributes,
416
+ conditionalAttributes: conditionalAttributes.length > 0 ? conditionalAttributes : undefined,
417
+ children: [],
418
+ selfClosing: true
419
+ }, start_token.start, end_token.end, start_token.line, start_token.column, this.create_location(start_token, end_token));
420
+ }
421
+ }
422
+ // Auto-close void elements even without explicit /> syntax
423
+ // This matches standard HTML5 authoring where <input> doesn't need />
424
+ if (is_void_element) {
425
+ // Skip newlines before >
426
+ while (this.match(TokenType.NEWLINE)) {
427
+ // Skip newlines
428
+ }
429
+ const end_token = this.consume(TokenType.GT, 'Expected >');
430
+ // Void elements are always HTML tags (not components)
431
+ return createNode(NodeType.HTML_TAG, {
432
+ name: tag_name,
433
+ attributes,
434
+ conditionalAttributes: conditionalAttributes.length > 0 ? conditionalAttributes : undefined,
435
+ children: [],
436
+ selfClosing: true
437
+ }, start_token.start, end_token.end, start_token.line, start_token.column, this.create_location(start_token, end_token));
438
+ }
439
+ // Must be a paired tag - skip newlines before >
440
+ while (this.match(TokenType.NEWLINE)) {
441
+ // Skip newlines
442
+ }
443
+ this.consume(TokenType.GT, 'Expected >');
444
+ // Parse children
445
+ const children = [];
446
+ // Keep parsing content until we hit the closing tag
447
+ // For $redrawable transforms, accept either original or transformed name
448
+ while (!this.check_closing_tag(tag_name) &&
449
+ !(original_tag_name && this.check_closing_tag(original_tag_name))) {
450
+ if (this.is_at_end()) {
451
+ const error = unclosedError(is_component ? 'component' : 'tag', tag_name, start_token.line, start_token.column, this.source, this.filename);
452
+ throw error;
453
+ }
454
+ const child = this.parse_content();
455
+ if (child) {
456
+ children.push(child);
457
+ }
458
+ }
459
+ // Consume closing tag
460
+ this.consume(TokenType.TAG_CLOSE, 'Expected </');
461
+ const close_name = this.consume(TokenType.TAG_NAME, 'Expected tag name');
462
+ // For $redrawable transforms, accept either original or transformed tag name
463
+ const is_valid_closing = close_name.value === tag_name ||
464
+ (original_tag_name && close_name.value === original_tag_name);
465
+ if (!is_valid_closing) {
466
+ throw mismatchedTagError(original_tag_name || tag_name, // Show original name in error
467
+ close_name.value, close_name.line, close_name.column, this.source, this.filename);
468
+ }
469
+ const end_token = this.consume(TokenType.GT, 'Expected >');
470
+ if (is_component) {
471
+ // Validate mixed content mode for components
472
+ this.validate_component_children(children, tag_name, start_token);
473
+ return createNode(NodeType.COMPONENT_INVOCATION, {
474
+ name: tag_name,
475
+ attributes,
476
+ conditionalAttributes: conditionalAttributes.length > 0 ? conditionalAttributes : undefined,
477
+ children,
478
+ selfClosing: false
479
+ }, start_token.start, end_token.end, start_token.line, start_token.column, this.create_location(start_token, end_token));
480
+ }
481
+ else {
482
+ // Check if this tag needs whitespace preservation
483
+ const tag_lower = tag_name.toLowerCase();
484
+ const preserveWhitespace = tag_lower === 'textarea' || tag_lower === 'pre';
485
+ return createNode(NodeType.HTML_TAG, {
486
+ name: tag_name,
487
+ attributes,
488
+ conditionalAttributes: conditionalAttributes.length > 0 ? conditionalAttributes : undefined,
489
+ children,
490
+ selfClosing: false,
491
+ preserveWhitespace
492
+ }, start_token.start, end_token.end, start_token.line, start_token.column, this.create_location(start_token, end_token));
493
+ }
494
+ }
495
+ // Parse attributes from tokens
496
+ parse_attributes() {
497
+ const attributes = {};
498
+ const conditionalAttributes = [];
499
+ // Skip any leading newlines
500
+ while (this.match(TokenType.NEWLINE)) {
501
+ // Skip
502
+ }
503
+ while (this.check(TokenType.ATTR_NAME) || this.check(TokenType.CODE_START)) {
504
+ // Check for conditional attribute: <% if (condition) { %>
505
+ if (this.check(TokenType.CODE_START)) {
506
+ // Check if this is a closing brace <% } %> - if so, stop parsing attributes
507
+ // Peek ahead to see if the next token after CODE_START is a closing brace
508
+ const peek_next = this.tokens[this.current + 1];
509
+ if (peek_next && peek_next.type === TokenType.JAVASCRIPT && peek_next.value.trim() === '}') {
510
+ // This is a closing brace, not a new conditional attribute
511
+ // Stop parsing and return control to the caller
512
+ break;
513
+ }
514
+ const condAttr = this.parse_conditional_attribute();
515
+ if (condAttr) {
516
+ conditionalAttributes.push(condAttr);
517
+ }
518
+ // Skip newlines after conditional attribute
519
+ while (this.match(TokenType.NEWLINE)) {
520
+ // Skip
521
+ }
522
+ continue;
523
+ }
524
+ const name_token = this.advance();
525
+ let name = name_token.value;
526
+ let value = true; // Default for boolean attributes
527
+ // Check for equals sign and value
528
+ if (this.match(TokenType.EQUALS)) {
529
+ // Check if this is a compound value with interpolation
530
+ if (this.check(TokenType.ATTR_VALUE) ||
531
+ this.check(TokenType.EXPRESSION_START) ||
532
+ this.check(TokenType.EXPRESSION_UNESCAPED)) {
533
+ value = this.parse_attribute_value();
534
+ }
535
+ }
536
+ // Handle special attribute prefixes
537
+ if (name.startsWith('$')) {
538
+ // Special case: $sid becomes data-sid (needed for scoped ID system)
539
+ // All other $ attributes stay as-is (handled by instruction-processor.ts)
540
+ if (name === '$sid') {
541
+ name = 'data-sid';
542
+ }
543
+ // Keep $ prefix for other attributes - they get stored via .data() at runtime
544
+ // Keep the value object intact to preserve quoted/unquoted distinction
545
+ }
546
+ else if (name.startsWith(':')) {
547
+ // Property binding: :prop="value" becomes data-bind-prop
548
+ // Preserve whether value was quoted or not for proper code generation
549
+ name = 'data-bind-' + name.substring(1);
550
+ // Keep the value object intact to preserve quoted/unquoted distinction
551
+ }
552
+ else if (name.startsWith('@')) {
553
+ // Event binding: @click="handler" becomes data-__-on-click
554
+ // Preserve whether value was quoted or not for proper code generation
555
+ name = 'data-__-on-' + name.substring(1);
556
+ // Keep the value object intact to preserve quoted/unquoted distinction
557
+ }
558
+ attributes[name] = value;
559
+ // Skip newlines between attributes
560
+ while (this.match(TokenType.NEWLINE)) {
561
+ // Skip
562
+ }
563
+ }
564
+ return { attributes, conditionalAttributes };
565
+ }
566
+ // Parse conditional attribute: <% if (condition) { %>attr="value"<% } %>
567
+ parse_conditional_attribute() {
568
+ const start_token = this.peek();
569
+ // Consume <%
570
+ this.consume(TokenType.CODE_START, 'Expected <%');
571
+ let condition;
572
+ // Only brace style supported: CODE_START → JAVASCRIPT "if (condition) {" → TAG_END
573
+ if (this.check(TokenType.JAVASCRIPT)) {
574
+ const jsToken = this.consume(TokenType.JAVASCRIPT, 'Expected if statement');
575
+ const jsCode = jsToken.value.trim();
576
+ // Verify it starts with 'if' and contains both ( and {
577
+ if (!jsCode.startsWith('if')) {
578
+ throw syntaxError('Only if statements are allowed in attribute context. Use <% if (condition) { %>attr="value"<% } %>', jsToken.line, jsToken.column, this.source);
579
+ }
580
+ // Extract condition from: if (condition) {
581
+ const openParen = jsCode.indexOf('(');
582
+ const closeBrace = jsCode.lastIndexOf('{');
583
+ if (openParen === -1 || closeBrace === -1) {
584
+ throw syntaxError('Expected format: <% if (condition) { %>', jsToken.line, jsToken.column, this.source);
585
+ }
586
+ // Extract just the condition part (between parens, including parens)
587
+ condition = jsCode.substring(openParen, closeBrace).trim();
588
+ }
589
+ else {
590
+ // Not an if statement
591
+ throw syntaxError('Only if statements are allowed in attribute context. Use <% if (condition) { %>attr="value"<% } %>', this.peek().line, this.peek().column, this.source);
592
+ }
593
+ // Consume %>
594
+ this.consume(TokenType.TAG_END, 'Expected %>');
595
+ // Now parse the attributes inside the conditional
596
+ const innerAttrs = this.parse_attributes();
597
+ // These should be plain attributes only (no nested conditionals)
598
+ if (innerAttrs.conditionalAttributes.length > 0) {
599
+ throw syntaxError('Nested conditional attributes are not supported', start_token.line, start_token.column, this.source);
600
+ }
601
+ // Consume <% } %>
602
+ this.consume(TokenType.CODE_START, 'Expected <% to close conditional attribute');
603
+ const closeToken = this.consume(TokenType.JAVASCRIPT, 'Expected }');
604
+ if (closeToken.value.trim() !== '}') {
605
+ throw syntaxError('Expected } to close if statement', closeToken.line, closeToken.column, this.source);
606
+ }
607
+ this.consume(TokenType.TAG_END, 'Expected %>');
608
+ return createNode(NodeType.CONDITIONAL_ATTRIBUTE, {
609
+ condition,
610
+ attributes: innerAttrs.attributes
611
+ }, start_token.start, this.previous().end, start_token.line, start_token.column);
612
+ }
613
+ // Parse potentially compound attribute value
614
+ parse_attribute_value() {
615
+ const parts = [];
616
+ // For simple string values that are quoted in the source, return them with a quoted flag
617
+ // This helps the codegen distinguish between $foo="bar" and $foo=bar
618
+ const firstToken = this.peek();
619
+ const isSimpleValue = this.check(TokenType.ATTR_VALUE) &&
620
+ !this.check_ahead(1, TokenType.EXPRESSION_START) &&
621
+ !this.check_ahead(1, TokenType.EXPRESSION_UNESCAPED);
622
+ // Collect all parts of the attribute value
623
+ while (this.check(TokenType.ATTR_VALUE) ||
624
+ this.check(TokenType.EXPRESSION_START) ||
625
+ this.check(TokenType.EXPRESSION_UNESCAPED)) {
626
+ if (this.check(TokenType.ATTR_VALUE)) {
627
+ const token = this.advance();
628
+ // Trim whitespace from attribute value text parts to avoid extra newlines
629
+ const trimmedValue = token.value.trim();
630
+ if (trimmedValue.length > 0) {
631
+ parts.push({ type: 'text', value: trimmedValue, escaped: true });
632
+ }
633
+ }
634
+ else if (this.check(TokenType.EXPRESSION_START) ||
635
+ this.check(TokenType.EXPRESSION_UNESCAPED)) {
636
+ const is_escaped = this.peek().type === TokenType.EXPRESSION_START;
637
+ this.advance(); // consume <%= or <%!=
638
+ const expr_token = this.consume(TokenType.JAVASCRIPT, 'Expected expression');
639
+ this.consume(TokenType.TAG_END, 'Expected %>');
640
+ parts.push({ type: 'expression', value: expr_token.value, escaped: is_escaped });
641
+ }
642
+ }
643
+ // If it's a single text part, check if it's quoted
644
+ if (parts.length === 1 && parts[0].type === 'text') {
645
+ const value = parts[0].value;
646
+ // Check if the value has quotes (preserved by lexer for quoted strings)
647
+ if ((value.startsWith('"') && value.endsWith('"')) ||
648
+ (value.startsWith("'") && value.endsWith("'"))) {
649
+ // Return a marker that this was a quoted string
650
+ return { quoted: true, value: value.slice(1, -1) };
651
+ }
652
+ // Check if it's a parenthesized expression: $attr=(expr)
653
+ if (value.startsWith('(') && value.endsWith(')')) {
654
+ // Return as an expression - remove the parentheses
655
+ return { expression: true, value: value.slice(1, -1) };
656
+ }
657
+ // Check if it's a bare identifier or member expression: $attr=identifier or $attr=this.method
658
+ // Valid identifiers can include dots for member access (e.g., this.handleClick, data.user.name)
659
+ // Can be prefixed with ! for negation (e.g., !this.canEdit)
660
+ // Pattern: optional ! then starts with letter/$/_ then any combo of letters/numbers/$/_ and dots
661
+ if (/^!?[a-zA-Z_$][a-zA-Z0-9_$.]*$/.test(value)) {
662
+ // Return as an identifier expression
663
+ return { identifier: true, value: value };
664
+ }
665
+ // Check if it contains function calls (parentheses)
666
+ // If the lexer allowed it (passed validation), treat it as an identifier/expression
667
+ // Examples: getData(), obj.method(arg), rsx.route('A','B').url(123)
668
+ if (value.includes('(') || value.includes(')')) {
669
+ // Return as an identifier expression (function call chain)
670
+ return { identifier: true, value: value };
671
+ }
672
+ // Otherwise, treat as a JavaScript expression (includes numeric literals like 42, 3.14, etc.)
673
+ return { expression: true, value: value };
674
+ }
675
+ // Any expression or multiple parts needs interpolation handling
676
+ return { interpolated: true, parts };
677
+ }
678
+ // Check if we're at a closing tag for the given name
679
+ check_closing_tag(tag_name) {
680
+ if (!this.check(TokenType.TAG_CLOSE)) {
681
+ return false;
682
+ }
683
+ // Look ahead to see if the tag name matches
684
+ const next_pos = this.current + 1;
685
+ if (next_pos < this.tokens.length &&
686
+ this.tokens[next_pos].type === TokenType.TAG_NAME &&
687
+ this.tokens[next_pos].value === tag_name) {
688
+ return true;
689
+ }
690
+ return false;
691
+ }
692
+ match(...types) {
693
+ for (const type of types) {
694
+ if (this.check(type)) {
695
+ this.advance();
696
+ return true;
697
+ }
698
+ }
699
+ return false;
700
+ }
701
+ check(type) {
702
+ if (this.is_at_end())
703
+ return false;
704
+ return this.peek().type === type;
705
+ }
706
+ check_ahead(offset, type) {
707
+ if (this.current + offset >= this.tokens.length) {
708
+ return false;
709
+ }
710
+ return this.tokens[this.current + offset].type === type;
711
+ }
712
+ check_sequence(...types) {
713
+ for (let i = 0; i < types.length; i++) {
714
+ if (this.current + i >= this.tokens.length) {
715
+ return false;
716
+ }
717
+ if (this.tokens[this.current + i].type !== types[i]) {
718
+ return false;
719
+ }
720
+ }
721
+ return true;
722
+ }
723
+ advance() {
724
+ if (!this.is_at_end())
725
+ this.current++;
726
+ return this.previous();
727
+ }
728
+ is_at_end() {
729
+ return this.peek().type === TokenType.EOF;
730
+ }
731
+ peek() {
732
+ return this.tokens[this.current];
733
+ }
734
+ peek_ahead(offset) {
735
+ const pos = this.current + offset;
736
+ if (pos >= this.tokens.length) {
737
+ return this.tokens[this.tokens.length - 1]; // Return EOF token
738
+ }
739
+ return this.tokens[pos];
740
+ }
741
+ previous() {
742
+ return this.tokens[this.current - 1];
743
+ }
744
+ current_token() {
745
+ return this.tokens[this.current] || this.tokens[this.tokens.length - 1];
746
+ }
747
+ previous_token() {
748
+ return this.tokens[Math.max(0, this.current - 1)];
749
+ }
750
+ /**
751
+ * Create a SourceLocation from start and end tokens
752
+ * Propagates loc field if available, falls back to old fields for compatibility
753
+ */
754
+ create_location(start, end) {
755
+ if (start.loc && end.loc) {
756
+ // Use new loc field if available
757
+ return {
758
+ start: start.loc.start,
759
+ end: end.loc.end
760
+ };
761
+ }
762
+ // Fall back to old fields for backward compatibility
763
+ return undefined;
764
+ }
765
+ consume(type, message) {
766
+ if (this.check(type))
767
+ return this.advance();
768
+ const token = this.peek();
769
+ // Special case: Detecting template expressions inside HTML tag attributes
770
+ if (type === TokenType.GT &&
771
+ (token.type === TokenType.EXPRESSION_START || token.type === TokenType.EXPRESSION_UNESCAPED)) {
772
+ const error = syntaxError('Template expressions (<% %>) cannot be used as attribute values inside HTML tags', token.line, token.column, this.source, this.filename);
773
+ // Add helpful remediation examples
774
+ error.message += '\n\n' +
775
+ ' Use template expressions INSIDE attribute values instead:\n' +
776
+ ' ✅ <tag style="<%= expression %>">\n' +
777
+ ' ✅ <tag class="<%= condition ? \'active\' : \'\' %>">\n\n' +
778
+ ' Or use conditional logic before the tag:\n' +
779
+ ' ✅ <% let attrs = expression ? \'value\' : \'\'; %>\n' +
780
+ ' <tag attr="<%= attrs %>">\n\n' +
781
+ ' Or set attributes in on_ready() using jQuery:\n' +
782
+ ' ✅ <tag $sid="my_element">\n' +
783
+ ' on_ready() {\n' +
784
+ ' if (this.args.required) this.$sid(\'my_element\').attr(\'required\', true);\n' +
785
+ ' }';
786
+ throw error;
787
+ }
788
+ const error = syntaxError(`${message}. Got ${token.type} instead`, token.line, token.column, this.source, this.filename);
789
+ throw error;
790
+ }
791
+ // Validate component children to prevent mixed content mode
792
+ validate_component_children(children, componentName, startToken) {
793
+ let hasSlots = false;
794
+ let hasNonSlotContent = false;
795
+ for (const child of children) {
796
+ if (child.type === NodeType.SLOT) {
797
+ hasSlots = true;
798
+ }
799
+ else if (child.type === NodeType.TEXT) {
800
+ // Check if it's non-whitespace text
801
+ const textContent = child.content;
802
+ if (textContent.trim() !== '') {
803
+ hasNonSlotContent = true;
804
+ }
805
+ }
806
+ else {
807
+ // Any other node type (expressions, tags, etc.) is non-slot content
808
+ hasNonSlotContent = true;
809
+ }
810
+ }
811
+ // If component has both slots and non-slot content, throw error
812
+ if (hasSlots && hasNonSlotContent) {
813
+ throw syntaxError(`Mixed content not allowed: when using slots, all content must be inside <Slot:slotname> tags`, startToken.line, startToken.column, this.source, this.filename);
814
+ }
815
+ }
816
+ /**
817
+ * Compile method for simplified API
818
+ * Parses the template and returns component metadata and render function
819
+ */
820
+ compile() {
821
+ // Parse to get AST
822
+ const ast = this.parse();
823
+ // Generate code with sourcemap
824
+ const generator = new CodeGenerator();
825
+ const result = generator.generateWithSourceMap(ast, this.filename || 'template.jqhtml', this.source || '');
826
+ // Extract the single component (should only be one per file)
827
+ const componentEntries = Array.from(result.components.entries());
828
+ if (componentEntries.length === 0) {
829
+ throw new Error('No component definition found in template');
830
+ }
831
+ if (componentEntries.length > 1) {
832
+ const names = componentEntries.map(([name]) => name).join(', ');
833
+ throw new Error(`Multiple component definitions found: ${names}. Only one component per file is allowed.`);
834
+ }
835
+ // Extract component information
836
+ const [name, componentDef] = componentEntries[0];
837
+ return {
838
+ name: name,
839
+ tagName: componentDef.tagName || 'div',
840
+ defaultAttributes: componentDef.defaultAttributes || {},
841
+ renderFunction: componentDef.render_function
842
+ };
843
+ }
844
+ }
845
+ //# sourceMappingURL=parser.js.map