@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/lexer.js ADDED
@@ -0,0 +1,1446 @@
1
+ // JQHTML Lexer - Simple character scanner, no regex
2
+ // Tracks positions for source map support
3
+ import { JQHTMLParseError } from './errors.js';
4
+ export var TokenType;
5
+ (function (TokenType) {
6
+ // Literals
7
+ TokenType["TEXT"] = "TEXT";
8
+ // JQHTML tags
9
+ TokenType["EXPRESSION_START"] = "EXPRESSION_START";
10
+ TokenType["EXPRESSION_UNESCAPED"] = "EXPRESSION_UNESCAPED";
11
+ TokenType["CODE_START"] = "CODE_START";
12
+ TokenType["TAG_END"] = "TAG_END";
13
+ // Comments
14
+ TokenType["COMMENT"] = "COMMENT";
15
+ // Component definition
16
+ TokenType["DEFINE_START"] = "DEFINE_START";
17
+ TokenType["DEFINE_END"] = "DEFINE_END";
18
+ TokenType["COMPONENT_NAME"] = "COMPONENT_NAME";
19
+ // Slots (v2)
20
+ TokenType["SLOT_START"] = "SLOT_START";
21
+ TokenType["SLOT_END"] = "SLOT_END";
22
+ TokenType["SLOT_NAME"] = "SLOT_NAME";
23
+ // HTML tags
24
+ TokenType["TAG_OPEN"] = "TAG_OPEN";
25
+ TokenType["TAG_CLOSE"] = "TAG_CLOSE";
26
+ TokenType["TAG_NAME"] = "TAG_NAME";
27
+ TokenType["SELF_CLOSING"] = "SELF_CLOSING";
28
+ // Attributes
29
+ TokenType["ATTR_NAME"] = "ATTR_NAME";
30
+ TokenType["ATTR_VALUE"] = "ATTR_VALUE";
31
+ // Delimiters
32
+ TokenType["COLON"] = "COLON";
33
+ TokenType["SEMICOLON"] = "SEMICOLON";
34
+ TokenType["GT"] = "GT";
35
+ TokenType["LT"] = "LT";
36
+ TokenType["SLASH"] = "SLASH";
37
+ TokenType["EQUALS"] = "EQUALS";
38
+ TokenType["QUOTE"] = "QUOTE";
39
+ // Special
40
+ TokenType["EOF"] = "EOF";
41
+ TokenType["NEWLINE"] = "NEWLINE";
42
+ TokenType["WHITESPACE"] = "WHITESPACE";
43
+ // JavaScript code
44
+ TokenType["JAVASCRIPT"] = "JAVASCRIPT";
45
+ })(TokenType || (TokenType = {}));
46
+ export class Lexer {
47
+ input;
48
+ position = 0;
49
+ line = 1;
50
+ column = 1;
51
+ tokens = [];
52
+ // Track saved positions for accurate token creation
53
+ savedPosition = null;
54
+ constructor(input) {
55
+ // Preprocess: Normalize all line endings to \n (handles \r\n and \r)
56
+ // This ensures the lexer only needs to handle \n throughout
57
+ let processed = input.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
58
+ // Preprocess: Replace JQHTML comments (<%-- --%) with equivalent newlines to preserve line mapping
59
+ processed = this.preprocessComments(processed);
60
+ // Preprocess: Replace HTML comments (<!-- -->) outside Define tags with equivalent newlines
61
+ processed = this.preprocessHTMLComments(processed);
62
+ // Preprocess: Insert // for empty lines in code blocks to preserve line mapping
63
+ processed = this.preprocessCodeBlocks(processed);
64
+ this.input = processed;
65
+ }
66
+ /**
67
+ * Save current position for later token creation
68
+ */
69
+ savePosition() {
70
+ this.savedPosition = {
71
+ line: this.line,
72
+ column: this.column,
73
+ offset: this.position
74
+ };
75
+ return this.savedPosition;
76
+ }
77
+ /**
78
+ * Get saved position or current position
79
+ */
80
+ getSavedPosition() {
81
+ if (this.savedPosition) {
82
+ const pos = this.savedPosition;
83
+ this.savedPosition = null;
84
+ return pos;
85
+ }
86
+ return {
87
+ line: this.line,
88
+ column: this.column,
89
+ offset: this.position
90
+ };
91
+ }
92
+ /**
93
+ * Replace <%-- comment --%> with equivalent number of newlines
94
+ * This ensures line mapping stays accurate while removing comment content
95
+ */
96
+ preprocessComments(input) {
97
+ let result = input;
98
+ let searchPos = 0;
99
+ while (true) {
100
+ // Find next comment start
101
+ const startIdx = result.indexOf('<%--', searchPos);
102
+ if (startIdx === -1)
103
+ break;
104
+ // Find matching comment end
105
+ const endIdx = result.indexOf('--%>', startIdx + 4);
106
+ if (endIdx === -1) {
107
+ // Unclosed comment - leave it for parser to report error
108
+ break;
109
+ }
110
+ // Extract the comment including delimiters
111
+ const commentText = result.substring(startIdx, endIdx + 4);
112
+ // Count newlines in the comment
113
+ const newlineCount = (commentText.match(/\n/g) || []).length;
114
+ // Replace comment with spaces and same number of newlines
115
+ // We preserve the same total length to keep position tracking accurate
116
+ let replacement = '';
117
+ let charsNeeded = commentText.length;
118
+ // First, add the newlines at their original positions
119
+ let commentPos = 0;
120
+ for (let i = 0; i < commentText.length; i++) {
121
+ if (commentText[i] === '\n') {
122
+ replacement += '\n';
123
+ }
124
+ else {
125
+ replacement += ' ';
126
+ }
127
+ }
128
+ // Replace the comment with the spacing
129
+ result = result.substring(0, startIdx) + replacement + result.substring(endIdx + 4);
130
+ // Move search position past this replacement
131
+ searchPos = startIdx + replacement.length;
132
+ }
133
+ return result;
134
+ }
135
+ /**
136
+ * Replace HTML comments (<!-- -->) that appear OUTSIDE of <Define> tags
137
+ * This strips documentation comments before component definitions
138
+ * HTML comments INSIDE <Define> tags are preserved in the output
139
+ */
140
+ preprocessHTMLComments(input) {
141
+ let result = input;
142
+ let searchPos = 0;
143
+ let insideDefine = false;
144
+ while (searchPos < result.length) {
145
+ // Check if we're entering or leaving a Define tag
146
+ if (result.substring(searchPos, searchPos + 8) === '<Define:') {
147
+ insideDefine = true;
148
+ searchPos += 8;
149
+ continue;
150
+ }
151
+ if (result.substring(searchPos, searchPos + 9) === '</Define:') {
152
+ insideDefine = false;
153
+ searchPos += 9;
154
+ continue;
155
+ }
156
+ // Only strip HTML comments if we're outside Define tags
157
+ if (!insideDefine && result.substring(searchPos, searchPos + 4) === '<!--') {
158
+ const startIdx = searchPos;
159
+ // Find matching comment end
160
+ const endIdx = result.indexOf('-->', searchPos + 4);
161
+ if (endIdx === -1) {
162
+ // Unclosed comment - leave it for lexer to report error
163
+ break;
164
+ }
165
+ // Extract the comment including delimiters
166
+ const commentText = result.substring(startIdx, endIdx + 3);
167
+ // Replace comment with spaces and same number of newlines to preserve line mapping
168
+ let replacement = '';
169
+ for (let i = 0; i < commentText.length; i++) {
170
+ if (commentText[i] === '\n') {
171
+ replacement += '\n';
172
+ }
173
+ else {
174
+ replacement += ' ';
175
+ }
176
+ }
177
+ // Replace the comment with the spacing
178
+ result = result.substring(0, startIdx) + replacement + result.substring(endIdx + 3);
179
+ // Move search position past this replacement
180
+ searchPos = startIdx + replacement.length;
181
+ }
182
+ else {
183
+ searchPos++;
184
+ }
185
+ }
186
+ return result;
187
+ }
188
+ /**
189
+ * Preprocess code blocks and expressions
190
+ * - Insert comment markers for empty lines in code blocks
191
+ * - Collapse multi-line expressions to single line with trailing newlines
192
+ * This ensures 1:1 line mapping in generated code
193
+ */
194
+ preprocessCodeBlocks(input) {
195
+ let result = input;
196
+ let searchPos = 0;
197
+ while (true) {
198
+ // Find next <% sequence
199
+ let startIdx = result.indexOf('<%', searchPos);
200
+ if (startIdx === -1)
201
+ break;
202
+ // Check what type of block this is
203
+ const isExpression = result[startIdx + 2] === '=';
204
+ const isUnescapedExpression = result.substring(startIdx + 2, startIdx + 4) === '!=';
205
+ if (isExpression || isUnescapedExpression) {
206
+ // Handle expressions: collapse to single line
207
+ const exprStart = isUnescapedExpression ? startIdx + 4 : startIdx + 3;
208
+ // Find matching %> considering strings
209
+ const endIdx = this.findClosingTag(result, exprStart);
210
+ if (endIdx === -1) {
211
+ // Unclosed expression - leave it for parser to report error
212
+ searchPos = startIdx + 2;
213
+ continue;
214
+ }
215
+ // Extract the expression content
216
+ const exprContent = result.substring(exprStart, endIdx);
217
+ // Count newlines in the expression
218
+ const newlineCount = (exprContent.match(/\n/g) || []).length;
219
+ if (newlineCount > 0) {
220
+ // Strip line comments BEFORE collapsing to avoid breaking parser
221
+ let processedExpr = exprContent;
222
+ // Replace // comments with spaces (preserve length for sourcemaps)
223
+ let cleaned = '';
224
+ let inString = false;
225
+ let stringDelim = '';
226
+ let escaped = false;
227
+ for (let i = 0; i < processedExpr.length; i++) {
228
+ const ch = processedExpr[i];
229
+ const next = processedExpr[i + 1] || '';
230
+ // Handle escape sequences
231
+ if (escaped) {
232
+ cleaned += ch;
233
+ escaped = false;
234
+ continue;
235
+ }
236
+ if (ch === '\\' && inString) {
237
+ cleaned += ch;
238
+ escaped = true;
239
+ continue;
240
+ }
241
+ // Track strings
242
+ if (!inString && (ch === '"' || ch === "'" || ch === '`')) {
243
+ inString = true;
244
+ stringDelim = ch;
245
+ cleaned += ch;
246
+ }
247
+ else if (inString && ch === stringDelim) {
248
+ inString = false;
249
+ cleaned += ch;
250
+ }
251
+ else if (!inString && ch === '/' && next === '/') {
252
+ // Found line comment - replace with spaces until newline
253
+ cleaned += ' ';
254
+ i++; // skip second /
255
+ cleaned += ' ';
256
+ while (i + 1 < processedExpr.length && processedExpr[i + 1] !== '\n') {
257
+ i++;
258
+ cleaned += ' ';
259
+ }
260
+ }
261
+ else {
262
+ cleaned += ch;
263
+ }
264
+ }
265
+ // Collapse multi-line expression to single line
266
+ // Replace all newlines with spaces to preserve token separation
267
+ const collapsedExpr = cleaned.replace(/\n/g, ' ');
268
+ // Add trailing newlines after the expression
269
+ const trailingNewlines = '\n'.repeat(newlineCount);
270
+ // Reconstruct with collapsed expression and trailing newlines
271
+ const prefix = result.substring(0, exprStart);
272
+ const suffix = result.substring(endIdx);
273
+ result = prefix + collapsedExpr + suffix.substring(0, 2) + trailingNewlines + suffix.substring(2);
274
+ }
275
+ searchPos = startIdx + 2;
276
+ }
277
+ else {
278
+ // Handle code blocks: insert /* empty line */ for empty lines
279
+ const endIdx = this.findClosingTag(result, startIdx + 2);
280
+ if (endIdx === -1) {
281
+ // Unclosed code block - leave it for parser to report error
282
+ break;
283
+ }
284
+ // Extract the code block content between <% and %>
285
+ const blockContent = result.substring(startIdx + 2, endIdx);
286
+ // Process the content line by line
287
+ const lines = blockContent.split('\n');
288
+ const processedLines = [];
289
+ for (let i = 0; i < lines.length; i++) {
290
+ const line = lines[i];
291
+ const trimmed = line.trim();
292
+ // Don't add placeholders on the last line if it's empty
293
+ // (this would be right before the %>)
294
+ if (trimmed === '' && i < lines.length - 1) {
295
+ // Empty line - use /* */ instead of // to avoid breaking code
296
+ // Extra trailing space helps with alignment
297
+ processedLines.push(' /* empty line */ ');
298
+ }
299
+ else {
300
+ // Line has code or is the last line - keep as is
301
+ processedLines.push(line);
302
+ }
303
+ }
304
+ // Reconstruct the code block
305
+ const processedContent = processedLines.join('\n');
306
+ result = result.substring(0, startIdx + 2) + processedContent + result.substring(endIdx);
307
+ // Move search position past this block
308
+ searchPos = startIdx + 2 + processedContent.length + 2; // +2 for %>
309
+ }
310
+ }
311
+ return result;
312
+ }
313
+ /**
314
+ * Find the closing %> tag, properly handling strings and comments
315
+ */
316
+ findClosingTag(input, startPos) {
317
+ let pos = startPos;
318
+ let inString = false;
319
+ let stringDelimiter = '';
320
+ let inComment = false;
321
+ let commentType = '';
322
+ while (pos < input.length - 1) {
323
+ const char = input[pos];
324
+ const nextChar = input[pos + 1];
325
+ // Handle string literals
326
+ if (!inComment) {
327
+ if (!inString && (char === '"' || char === "'" || char === '`')) {
328
+ inString = true;
329
+ stringDelimiter = char;
330
+ }
331
+ else if (inString && char === stringDelimiter) {
332
+ // Check if it's escaped
333
+ let escapeCount = 0;
334
+ let checkPos = pos - 1;
335
+ while (checkPos >= 0 && input[checkPos] === '\\') {
336
+ escapeCount++;
337
+ checkPos--;
338
+ }
339
+ if (escapeCount % 2 === 0) {
340
+ inString = false;
341
+ stringDelimiter = '';
342
+ }
343
+ }
344
+ }
345
+ // Handle comments
346
+ if (!inString && !inComment) {
347
+ if (char === '/' && nextChar === '/') {
348
+ inComment = true;
349
+ commentType = 'line';
350
+ }
351
+ else if (char === '/' && nextChar === '*') {
352
+ inComment = true;
353
+ commentType = 'block';
354
+ }
355
+ }
356
+ else if (inComment) {
357
+ if (commentType === 'line' && char === '\n') {
358
+ inComment = false;
359
+ commentType = '';
360
+ }
361
+ else if (commentType === 'block' && char === '*' && nextChar === '/') {
362
+ inComment = false;
363
+ commentType = '';
364
+ pos++; // Skip the /
365
+ }
366
+ }
367
+ // Check for closing tag only if not in string or comment
368
+ if (!inString && !inComment) {
369
+ if (char === '%' && nextChar === '>') {
370
+ return pos;
371
+ }
372
+ }
373
+ pos++;
374
+ }
375
+ return -1; // Not found
376
+ }
377
+ tokenize() {
378
+ while (this.position < this.input.length) {
379
+ this.scan_next();
380
+ }
381
+ this.add_token(TokenType.EOF, '', this.position, this.position);
382
+ return this.tokens;
383
+ }
384
+ scan_next() {
385
+ const start = this.position;
386
+ const start_line = this.line;
387
+ const start_column = this.column;
388
+ // Check for JQHTML tags first
389
+ // Comments are now preprocessed out, so we don't need to check for them
390
+ // Check for invalid <%== syntax (common mistake)
391
+ if (this.match_sequence('<%==')) {
392
+ const error = new JQHTMLParseError('Invalid expression syntax: <%== is not valid JQHTML syntax', this.line, this.column - 4, // Point to the start of <%==
393
+ this.input);
394
+ error.suggestion = '\n\nValid expression syntax:\n' +
395
+ ' <%= expr %> - Escaped output (safe, default)\n' +
396
+ ' <%!= expr %> - Unescaped HTML output (raw)\n\n' +
397
+ 'Did you mean:\n' +
398
+ ' <%= ... %> for escaped output, or\n' +
399
+ ' <%!= ... %> for unescaped/raw HTML output?';
400
+ throw error;
401
+ }
402
+ if (this.match_sequence('<%!=')) {
403
+ this.add_token(TokenType.EXPRESSION_UNESCAPED, '<%!=', start, this.position);
404
+ this.scan_expression();
405
+ return;
406
+ }
407
+ if (this.match_sequence('<%=')) {
408
+ this.add_token(TokenType.EXPRESSION_START, '<%=', start, this.position);
409
+ this.scan_expression();
410
+ return;
411
+ }
412
+ if (this.match_sequence('<%')) {
413
+ this.add_token(TokenType.CODE_START, '<%', start, this.position);
414
+ this.scan_code_block();
415
+ return;
416
+ }
417
+ if (this.match_sequence('%>')) {
418
+ this.add_token(TokenType.TAG_END, '%>', start, this.position);
419
+ return;
420
+ }
421
+ // Check for Define tags
422
+ if (this.match_sequence('<Define:')) {
423
+ this.add_token(TokenType.DEFINE_START, '<Define:', start, this.position);
424
+ this.scan_component_name();
425
+ return;
426
+ }
427
+ if (this.match_sequence('</Define:')) {
428
+ this.add_token(TokenType.DEFINE_END, '</Define:', start, this.position);
429
+ this.scan_component_name();
430
+ return;
431
+ }
432
+ // Check for slot tags (v2)
433
+ if (this.match_sequence('</Slot:')) {
434
+ this.add_token(TokenType.SLOT_END, '</Slot:', start, this.position);
435
+ this.scan_slot_name();
436
+ return;
437
+ }
438
+ if (this.match_sequence('<Slot:')) {
439
+ this.add_token(TokenType.SLOT_START, '<Slot:', start, this.position);
440
+ this.scan_slot_name();
441
+ return;
442
+ }
443
+ // Check for HTML comment first
444
+ if (this.current_char() === '<' && this.peek_ahead(1) === '!' &&
445
+ this.peek_ahead(2) === '-' && this.peek_ahead(3) === '-') {
446
+ this.scan_html_comment();
447
+ return;
448
+ }
449
+ // Check for HTML tags (including components)
450
+ if (this.current_char() === '<') {
451
+ // Peek ahead to see if this is an HTML tag
452
+ if (this.peek_ahead(1) === '/') {
453
+ // Closing tag
454
+ if (this.is_tag_name_char(this.peek_ahead(2))) {
455
+ this.scan_closing_tag();
456
+ return;
457
+ }
458
+ }
459
+ else if (this.is_tag_name_char(this.peek_ahead(1))) {
460
+ // Opening tag
461
+ this.scan_opening_tag();
462
+ return;
463
+ }
464
+ }
465
+ // Single character tokens
466
+ const char = this.current_char();
467
+ // Don't tokenize < and > separately when they're part of HTML
468
+ // They should be part of TEXT tokens
469
+ /*
470
+ if (char === '<') {
471
+ this.advance();
472
+ this.add_token(TokenType.LT, '<', start, this.position);
473
+ return;
474
+ }
475
+
476
+ if (char === '>') {
477
+ this.advance();
478
+ this.add_token(TokenType.GT, '>', start, this.position);
479
+ return;
480
+ }
481
+ */
482
+ if (char === '\n') {
483
+ this.advance();
484
+ this.add_token(TokenType.NEWLINE, '\n', start, this.position);
485
+ return;
486
+ }
487
+ // Default: scan as text until next special character
488
+ this.scan_text();
489
+ }
490
+ scan_text() {
491
+ const start = this.position;
492
+ const start_line = this.line;
493
+ const start_column = this.column;
494
+ let text = '';
495
+ while (this.position < this.input.length) {
496
+ const char = this.current_char();
497
+ // Stop at any potential tag start
498
+ if (char === '<') {
499
+ // Check for HTML comment first - don't break, these should be in text
500
+ if (this.peek_ahead(1) === '!' &&
501
+ this.peek_ahead(2) === '-' &&
502
+ this.peek_ahead(3) === '-') {
503
+ break; // HTML comment will be handled separately
504
+ }
505
+ // Peek ahead for special sequences
506
+ if (this.peek_ahead(1) === '%' ||
507
+ this.peek_sequence_at(1, 'Slot:') || // Slot start
508
+ this.peek_sequence_at(1, '/Slot:') || // Slot end
509
+ this.peek_ahead(1) === 'D' && this.peek_sequence_at(1, 'Define:') ||
510
+ this.peek_ahead(1) === '/' && this.peek_sequence_at(1, '/Define:')) {
511
+ break;
512
+ }
513
+ // Also stop for HTML tags
514
+ if (this.peek_ahead(1) === '/' && this.is_tag_name_char(this.peek_ahead(2))) {
515
+ break; // Closing tag
516
+ }
517
+ if (this.is_tag_name_char(this.peek_ahead(1))) {
518
+ break; // Opening tag
519
+ }
520
+ }
521
+ if (char === '%' && this.peek_ahead(1) === '>') {
522
+ break;
523
+ }
524
+ text += char;
525
+ this.advance();
526
+ }
527
+ if (text.length > 0) {
528
+ this.add_token(TokenType.TEXT, text, start, this.position, start_line, start_column);
529
+ }
530
+ }
531
+ scan_code_block() {
532
+ // After <%, save the original position INCLUDING whitespace
533
+ const position_with_whitespace = this.position;
534
+ // Now skip whitespace to check for keywords
535
+ this.skip_whitespace();
536
+ const saved_position = this.position;
537
+ // It's regular JavaScript code - rewind to include whitespace
538
+ this.position = position_with_whitespace;
539
+ this.scan_javascript();
540
+ }
541
+ scan_comment() {
542
+ // Scan comment from <%-- to --%>
543
+ const start = this.position - 4; // Already consumed <%--
544
+ let comment = '';
545
+ while (this.position < this.input.length) {
546
+ if (this.match_sequence('--%>')) {
547
+ // Found end of comment
548
+ // Don't trim - we need to preserve whitespace for line mapping
549
+ this.add_token(TokenType.COMMENT, comment, start, this.position);
550
+ return;
551
+ }
552
+ const char = this.current_char();
553
+ comment += char;
554
+ this.advance();
555
+ }
556
+ // Error: unterminated comment
557
+ throw new JQHTMLParseError('Unterminated comment', this.line, this.column, this.input);
558
+ }
559
+ scan_html_comment() {
560
+ // Scan HTML comment from <!-- to -->
561
+ // Everything inside should be treated as raw text, no parsing
562
+ const start = this.position;
563
+ // Consume <!--
564
+ this.advance(); // <
565
+ this.advance(); // !
566
+ this.advance(); // -
567
+ this.advance(); // -
568
+ let comment = '<!--';
569
+ // Scan until we find -->
570
+ while (this.position < this.input.length) {
571
+ if (this.current_char() === '-' &&
572
+ this.peek_ahead(1) === '-' &&
573
+ this.peek_ahead(2) === '>') {
574
+ // Found end of HTML comment
575
+ comment += '-->';
576
+ this.advance(); // -
577
+ this.advance(); // -
578
+ this.advance(); // >
579
+ // Add the entire HTML comment as a TEXT token
580
+ // This ensures it gets passed through as-is to the output
581
+ this.add_token(TokenType.TEXT, comment, start, this.position);
582
+ return;
583
+ }
584
+ const char = this.current_char();
585
+ comment += char;
586
+ // Track line numbers for error reporting
587
+ if (char === '\n') {
588
+ this.line++;
589
+ this.column = 1;
590
+ }
591
+ else {
592
+ this.column++;
593
+ }
594
+ this.advance();
595
+ }
596
+ // Error: unterminated HTML comment
597
+ throw new JQHTMLParseError('Unterminated HTML comment', this.line, this.column, this.input);
598
+ }
599
+ scan_expression() {
600
+ // After <%=, scan JavaScript until %>
601
+ // Strip line comments from interpolation blocks to avoid breaking parser
602
+ this.scan_javascript(true);
603
+ }
604
+ scan_javascript(strip_line_comments = false) {
605
+ const start = this.position;
606
+ let code = '';
607
+ let in_string = false;
608
+ let string_delimiter = '';
609
+ let escape_next = false;
610
+ while (this.position < this.input.length) {
611
+ const char = this.current_char();
612
+ // Handle escape sequences in strings
613
+ if (escape_next) {
614
+ code += char;
615
+ this.advance();
616
+ escape_next = false;
617
+ continue;
618
+ }
619
+ // Handle backslash (escape character)
620
+ if (char === '\\' && in_string) {
621
+ escape_next = true;
622
+ code += char;
623
+ this.advance();
624
+ continue;
625
+ }
626
+ // Handle string delimiters
627
+ if ((char === '"' || char === "'" || char === '`') && !in_string) {
628
+ in_string = true;
629
+ string_delimiter = char;
630
+ }
631
+ else if (char === string_delimiter && in_string) {
632
+ in_string = false;
633
+ string_delimiter = '';
634
+ }
635
+ // Strip line comments in interpolation blocks (outside strings)
636
+ if (strip_line_comments && !in_string && char === '/' && this.peek_ahead(1) === '/') {
637
+ // Replace EVERY character from // up to (but not including) newline with = for debugging
638
+ // This maintains exact position alignment for sourcemaps
639
+ // Replace first /
640
+ code += ' ';
641
+ this.advance();
642
+ // Replace second /
643
+ code += ' ';
644
+ this.advance();
645
+ // Replace all comment text with spaces until we hit newline or %>
646
+ while (this.position < this.input.length) {
647
+ const next = this.current_char();
648
+ // Found newline - preserve it and stop
649
+ if (next === '\n') {
650
+ code += next;
651
+ this.advance();
652
+ break;
653
+ }
654
+ // Handle \r\n or \r
655
+ if (next === '\r') {
656
+ code += next;
657
+ this.advance();
658
+ // Check for \n following \r
659
+ if (this.current_char() === '\n') {
660
+ code += '\n';
661
+ this.advance();
662
+ }
663
+ break;
664
+ }
665
+ // Found closing %> - stop (don't consume it)
666
+ if (next === '%' && this.peek_ahead(1) === '>') {
667
+ break;
668
+ }
669
+ // Replace this comment character with space
670
+ code += ' ';
671
+ this.advance();
672
+ }
673
+ continue;
674
+ }
675
+ // Only look for %> when not inside a string
676
+ if (!in_string && char === '%' && this.peek_ahead(1) === '>') {
677
+ break;
678
+ }
679
+ code += char;
680
+ this.advance();
681
+ }
682
+ // Don't trim when stripping comments - preserve whitespace for proper parsing
683
+ const finalCode = strip_line_comments ? code : code.trim();
684
+ if (finalCode.trim().length > 0) {
685
+ this.add_token(TokenType.JAVASCRIPT, finalCode, start, this.position);
686
+ }
687
+ }
688
+ scan_component_name() {
689
+ const start = this.position;
690
+ let name = '';
691
+ while (this.position < this.input.length) {
692
+ const char = this.current_char();
693
+ // Component names are alphanumeric with underscores
694
+ if ((char >= 'a' && char <= 'z') ||
695
+ (char >= 'A' && char <= 'Z') ||
696
+ (char >= '0' && char <= '9') ||
697
+ char === '_') {
698
+ name += char;
699
+ this.advance();
700
+ }
701
+ else {
702
+ break;
703
+ }
704
+ }
705
+ if (name.length > 0) {
706
+ this.add_token(TokenType.COMPONENT_NAME, name, start, this.position);
707
+ }
708
+ // Skip whitespace after component name
709
+ this.skip_whitespace();
710
+ // Also skip newlines after component name (for multiline Define tags)
711
+ while (this.current_char() === '\n' || this.current_char() === '\r') {
712
+ if (this.current_char() === '\n') {
713
+ this.add_token(TokenType.NEWLINE, '\n', this.position, this.position + 1);
714
+ }
715
+ this.advance();
716
+ this.skip_whitespace();
717
+ }
718
+ // If we see attributes, scan them
719
+ if (this.is_attribute_start_char(this.current_char())) {
720
+ this.scan_attributes();
721
+ }
722
+ else if (this.current_char() === '>') {
723
+ // Otherwise scan the closing >
724
+ const gt_start = this.position;
725
+ this.advance();
726
+ this.add_token(TokenType.GT, '>', gt_start, this.position);
727
+ }
728
+ }
729
+ scan_slot_name() {
730
+ const start = this.position;
731
+ let name = '';
732
+ while (this.position < this.input.length) {
733
+ const char = this.current_char();
734
+ // Slot names are alphanumeric with underscores, same as components
735
+ if ((char >= 'a' && char <= 'z') ||
736
+ (char >= 'A' && char <= 'Z') ||
737
+ (char >= '0' && char <= '9') ||
738
+ char === '_') {
739
+ name += char;
740
+ this.advance();
741
+ }
742
+ else {
743
+ break;
744
+ }
745
+ }
746
+ if (name.length > 0) {
747
+ this.add_token(TokenType.SLOT_NAME, name, start, this.position);
748
+ }
749
+ // Skip whitespace before attributes or closing
750
+ this.skip_whitespace();
751
+ // For self-closing slots, check for />
752
+ if (this.current_char() === '/' && this.peek_ahead(1) === '>') {
753
+ const slash_start = this.position;
754
+ this.advance(); // consume /
755
+ this.add_token(TokenType.SLASH, '/', slash_start, this.position);
756
+ const gt_start = this.position;
757
+ this.advance(); // consume >
758
+ this.add_token(TokenType.GT, '>', gt_start, this.position);
759
+ }
760
+ else if (this.current_char() === '>') {
761
+ // Regular closing >
762
+ const gt_start = this.position;
763
+ this.advance();
764
+ this.add_token(TokenType.GT, '>', gt_start, this.position);
765
+ }
766
+ }
767
+ match_sequence(sequence) {
768
+ if (this.position + sequence.length > this.input.length) {
769
+ return false;
770
+ }
771
+ for (let i = 0; i < sequence.length; i++) {
772
+ if (this.input[this.position + i] !== sequence[i]) {
773
+ return false;
774
+ }
775
+ }
776
+ // Consume the sequence
777
+ for (let i = 0; i < sequence.length; i++) {
778
+ this.advance();
779
+ }
780
+ return true;
781
+ }
782
+ match_keyword(keyword) {
783
+ const start = this.position;
784
+ // Match the keyword
785
+ for (let i = 0; i < keyword.length; i++) {
786
+ if (this.position + i >= this.input.length ||
787
+ this.input[this.position + i] !== keyword[i]) {
788
+ return false;
789
+ }
790
+ }
791
+ // Ensure it's not part of a larger word
792
+ const next_pos = this.position + keyword.length;
793
+ if (next_pos < this.input.length) {
794
+ const next_char = this.input[next_pos];
795
+ if ((next_char >= 'a' && next_char <= 'z') ||
796
+ (next_char >= 'A' && next_char <= 'Z') ||
797
+ (next_char >= '0' && next_char <= '9') ||
798
+ next_char === '_') {
799
+ return false;
800
+ }
801
+ }
802
+ // Consume the keyword
803
+ for (let i = 0; i < keyword.length; i++) {
804
+ this.advance();
805
+ }
806
+ return true;
807
+ }
808
+ peek_sequence(sequence) {
809
+ if (this.position + sequence.length > this.input.length) {
810
+ return false;
811
+ }
812
+ for (let i = 0; i < sequence.length; i++) {
813
+ if (this.input[this.position + i] !== sequence[i]) {
814
+ return false;
815
+ }
816
+ }
817
+ return true;
818
+ }
819
+ peek_sequence_at(offset, sequence) {
820
+ const start = this.position + offset;
821
+ if (start + sequence.length > this.input.length) {
822
+ return false;
823
+ }
824
+ for (let i = 0; i < sequence.length; i++) {
825
+ if (this.input[start + i] !== sequence[i]) {
826
+ return false;
827
+ }
828
+ }
829
+ return true;
830
+ }
831
+ skip_whitespace() {
832
+ while (this.position < this.input.length) {
833
+ const char = this.current_char();
834
+ if (char === ' ' || char === '\t' || char === '\r') {
835
+ this.advance();
836
+ }
837
+ else {
838
+ break;
839
+ }
840
+ }
841
+ }
842
+ current_char() {
843
+ return this.input[this.position] || '';
844
+ }
845
+ peek_ahead(offset) {
846
+ return this.input[this.position + offset] || '';
847
+ }
848
+ advance() {
849
+ if (this.current_char() === '\n') {
850
+ this.line++;
851
+ this.column = 1;
852
+ }
853
+ else {
854
+ this.column++;
855
+ }
856
+ this.position++;
857
+ }
858
+ add_token(type, value, start, end, line, column) {
859
+ // Calculate start position details
860
+ const startLine = line ?? this.line;
861
+ const startColumn = column ?? this.column;
862
+ // Calculate end position by scanning the value
863
+ let endLine = startLine;
864
+ let endColumn = startColumn;
865
+ let endOffset = end;
866
+ // Count lines and columns in the value to get accurate end position
867
+ for (let i = 0; i < value.length; i++) {
868
+ if (value[i] === '\n') {
869
+ endLine++;
870
+ endColumn = 1;
871
+ }
872
+ else {
873
+ endColumn++;
874
+ }
875
+ }
876
+ // For single character tokens, end column is start + 1
877
+ if (value.length === 1 && value !== '\n') {
878
+ endColumn = startColumn + 1;
879
+ }
880
+ this.tokens.push({
881
+ type,
882
+ value,
883
+ line: startLine, // Keep for backward compatibility
884
+ column: startColumn, // Keep for backward compatibility
885
+ start, // Keep for backward compatibility
886
+ end, // Keep for backward compatibility
887
+ loc: {
888
+ start: {
889
+ line: startLine,
890
+ column: startColumn,
891
+ offset: start
892
+ },
893
+ end: {
894
+ line: endLine,
895
+ column: endColumn,
896
+ offset: endOffset
897
+ }
898
+ }
899
+ });
900
+ }
901
+ is_tag_name_char(char) {
902
+ if (!char)
903
+ return false;
904
+ return (char >= 'a' && char <= 'z') ||
905
+ (char >= 'A' && char <= 'Z');
906
+ }
907
+ is_tag_name_continue_char(char) {
908
+ if (!char)
909
+ return false;
910
+ return this.is_tag_name_char(char) ||
911
+ (char >= '0' && char <= '9') ||
912
+ char === '-' || char === '_' || char === ':';
913
+ }
914
+ scan_opening_tag() {
915
+ const start = this.position;
916
+ this.advance(); // consume <
917
+ this.add_token(TokenType.TAG_OPEN, '<', start, this.position);
918
+ // Scan tag name
919
+ const name_start = this.position;
920
+ let name = '';
921
+ while (this.position < this.input.length &&
922
+ this.is_tag_name_continue_char(this.current_char())) {
923
+ name += this.current_char();
924
+ this.advance();
925
+ }
926
+ if (name.length > 0) {
927
+ this.add_token(TokenType.TAG_NAME, name, name_start, this.position);
928
+ }
929
+ // Scan attributes until > or />
930
+ this.scan_attributes();
931
+ }
932
+ scan_closing_tag() {
933
+ const start = this.position;
934
+ this.advance(); // consume <
935
+ this.advance(); // consume /
936
+ this.add_token(TokenType.TAG_CLOSE, '</', start, this.position);
937
+ // Scan tag name
938
+ const name_start = this.position;
939
+ let name = '';
940
+ while (this.position < this.input.length &&
941
+ this.is_tag_name_continue_char(this.current_char())) {
942
+ name += this.current_char();
943
+ this.advance();
944
+ }
945
+ if (name.length > 0) {
946
+ this.add_token(TokenType.TAG_NAME, name, name_start, this.position);
947
+ }
948
+ // Skip whitespace
949
+ this.skip_whitespace();
950
+ // Expect >
951
+ if (this.current_char() === '>') {
952
+ const gt_start = this.position;
953
+ this.advance();
954
+ this.add_token(TokenType.GT, '>', gt_start, this.position);
955
+ }
956
+ }
957
+ scan_attributes() {
958
+ while (this.position < this.input.length) {
959
+ this.skip_whitespace();
960
+ // Also skip newlines in attribute area
961
+ while (this.current_char() === '\n' || this.current_char() === '\r') {
962
+ if (this.current_char() === '\n') {
963
+ this.add_token(TokenType.NEWLINE, '\n', this.position, this.position + 1);
964
+ }
965
+ this.advance();
966
+ this.skip_whitespace();
967
+ }
968
+ const char = this.current_char();
969
+ // Check for />
970
+ if (char === '/' && this.peek_ahead(1) === '>') {
971
+ const slash_start = this.position;
972
+ this.advance(); // consume /
973
+ this.advance(); // consume >
974
+ this.add_token(TokenType.SELF_CLOSING, '/>', slash_start, this.position);
975
+ return;
976
+ }
977
+ // Check for >
978
+ if (char === '>') {
979
+ const gt_start = this.position;
980
+ this.advance();
981
+ this.add_token(TokenType.GT, '>', gt_start, this.position);
982
+ return;
983
+ }
984
+ // Check for <% (conditional attribute start)
985
+ if (char === '<' && this.peek_ahead(1) === '%') {
986
+ const start = this.position;
987
+ this.advance(); // <
988
+ this.advance(); // %
989
+ this.add_token(TokenType.CODE_START, '<%', start, this.position);
990
+ this.scan_code_block();
991
+ // Consume the %> that scan_code_block left behind
992
+ if (this.current_char() === '%' && this.peek_ahead(1) === '>') {
993
+ const tag_end_start = this.position;
994
+ this.advance(); // %
995
+ this.advance(); // >
996
+ this.add_token(TokenType.TAG_END, '%>', tag_end_start, this.position);
997
+ }
998
+ // Continue scanning attributes - DO NOT return
999
+ continue;
1000
+ }
1001
+ // Must be an attribute
1002
+ if (this.is_attribute_start_char(char)) {
1003
+ this.scan_attribute();
1004
+ }
1005
+ else {
1006
+ break;
1007
+ }
1008
+ }
1009
+ }
1010
+ is_attribute_start_char(char) {
1011
+ if (!char)
1012
+ return false;
1013
+ return this.is_tag_name_char(char) || char === '$' || char === ':' || char === '@';
1014
+ }
1015
+ scan_attribute() {
1016
+ const start = this.position;
1017
+ let name = '';
1018
+ // Scan attribute name
1019
+ while (this.position < this.input.length) {
1020
+ const char = this.current_char();
1021
+ if (char === '=' || char === ' ' || char === '\t' ||
1022
+ char === '\n' || char === '\r' || char === '>' ||
1023
+ (char === '/' && this.peek_ahead(1) === '>') ||
1024
+ (char === '<' && this.peek_ahead(1) === '%')) {
1025
+ break;
1026
+ }
1027
+ name += char;
1028
+ this.advance();
1029
+ }
1030
+ if (name.length > 0) {
1031
+ this.add_token(TokenType.ATTR_NAME, name, start, this.position);
1032
+ }
1033
+ this.skip_whitespace();
1034
+ // Check for = and value
1035
+ if (this.current_char() === '=') {
1036
+ const eq_start = this.position;
1037
+ this.advance();
1038
+ this.add_token(TokenType.EQUALS, '=', eq_start, this.position);
1039
+ this.skip_whitespace();
1040
+ // Scan attribute value
1041
+ this.scan_attribute_value();
1042
+ }
1043
+ }
1044
+ scan_attribute_value() {
1045
+ const char = this.current_char();
1046
+ // Check for common mistake: attr=<%= instead of attr="<%=
1047
+ if (char === '<' && this.peek_ahead(1) === '%') {
1048
+ const attr_context = this.get_current_attribute_context(); // Returns "attrname="
1049
+ const is_dollar_attr = attr_context.startsWith('$');
1050
+ const error = new JQHTMLParseError(`Attribute value cannot be assigned directly to <%= %> interpolation block.\n` +
1051
+ ` Attribute: ${attr_context}<%`, this.line, this.column, this.input);
1052
+ if (is_dollar_attr) {
1053
+ error.suggestion = `\n\n$ attributes can use either:\n\n` +
1054
+ `1. Quoted interpolation (for string values):\n` +
1055
+ ` ✗ Wrong: ${attr_context}<%= JSON.stringify(data) %>\n` +
1056
+ ` ✓ Correct: ${attr_context}"<%= JSON.stringify(data) %>"\n\n` +
1057
+ `2. Unquoted literal JavaScript (preferred):\n` +
1058
+ ` ✓ Correct: ${attr_context}JSON.stringify(data)\n` +
1059
+ ` ✓ Correct: ${attr_context}this.data.options\n` +
1060
+ ` ✓ Correct: ${attr_context}myVariable\n\n` +
1061
+ `The preferred approach is to use unquoted literal JavaScript\n` +
1062
+ `without <%= %> tags for $ attributes.`;
1063
+ }
1064
+ else {
1065
+ error.suggestion = `\n\nRegular attributes must use quoted values:\n\n` +
1066
+ ` ✗ Wrong: ${attr_context}<%= value %>\n` +
1067
+ ` ✓ Correct: ${attr_context}"<%= value %>"\n` +
1068
+ ` ✓ Correct: ${attr_context}"static text"\n\n` +
1069
+ `Always wrap attribute values in quotes.`;
1070
+ }
1071
+ throw error;
1072
+ }
1073
+ if (char === '"' || char === "'") {
1074
+ // Check if this is an @ event attribute - they MUST be unquoted
1075
+ const attr_context = this.get_current_attribute_context();
1076
+ if (attr_context.startsWith('@')) {
1077
+ const error = new JQHTMLParseError(`Event attributes (@) must have unquoted values to pass function references.\n` +
1078
+ ` Attribute: ${attr_context}=`, this.line, this.column, this.input);
1079
+ error.suggestion = `\n\nEvent attributes must be unquoted:\n` +
1080
+ ` ✗ Wrong: @click="handleClick" (passes string, not function)\n` +
1081
+ ` ✓ Correct: @click=handleClick (passes function reference)\n` +
1082
+ ` ✓ Correct: @click=this.handleClick (passes method reference)\n\n` +
1083
+ `Quoted values only pass strings and cannot pass functions or callbacks.\n` +
1084
+ `In the component scope, 'this' refers to the component instance.`;
1085
+ throw error;
1086
+ }
1087
+ // Quoted value - check for interpolation
1088
+ const quote = char;
1089
+ const quote_start = this.position;
1090
+ this.advance(); // consume opening quote
1091
+ // Check if value contains <%= or <%!=
1092
+ if (this.value_contains_interpolation(quote)) {
1093
+ // Rewind and scan with interpolation
1094
+ this.position = quote_start;
1095
+ this.advance(); // skip quote again
1096
+ this.scan_interpolated_attribute_value(quote);
1097
+ }
1098
+ else {
1099
+ // Simple value without interpolation
1100
+ const value_start = this.position - 1; // Include opening quote
1101
+ let value = quote; // Start with the quote
1102
+ while (this.position < this.input.length && this.current_char() !== quote) {
1103
+ value += this.current_char();
1104
+ this.advance();
1105
+ }
1106
+ if (this.current_char() === quote) {
1107
+ value += quote; // Add closing quote
1108
+ this.advance(); // consume closing quote
1109
+ }
1110
+ if (value.length > 2 || value === '""' || value === "''") {
1111
+ this.add_token(TokenType.ATTR_VALUE, value, value_start, this.position);
1112
+ }
1113
+ }
1114
+ }
1115
+ else if (char === '(') {
1116
+ // Parenthesized expression: $attr=(condition ? 'online' : 'offline')
1117
+ const value_start = this.position;
1118
+ let value = '';
1119
+ let paren_depth = 0;
1120
+ let in_string = false;
1121
+ let string_delimiter = '';
1122
+ let escape_next = false;
1123
+ while (this.position < this.input.length) {
1124
+ const ch = this.current_char();
1125
+ // Handle escape sequences in strings
1126
+ if (escape_next) {
1127
+ value += ch;
1128
+ this.advance();
1129
+ escape_next = false;
1130
+ continue;
1131
+ }
1132
+ // Handle backslash (escape character) inside strings
1133
+ if (ch === '\\' && in_string) {
1134
+ escape_next = true;
1135
+ value += ch;
1136
+ this.advance();
1137
+ continue;
1138
+ }
1139
+ // Handle string delimiters
1140
+ if ((ch === '"' || ch === "'" || ch === '`') && !in_string) {
1141
+ in_string = true;
1142
+ string_delimiter = ch;
1143
+ }
1144
+ else if (ch === string_delimiter && in_string) {
1145
+ in_string = false;
1146
+ string_delimiter = '';
1147
+ }
1148
+ // Count parentheses only outside strings
1149
+ if (!in_string) {
1150
+ if (ch === '(') {
1151
+ paren_depth++;
1152
+ }
1153
+ else if (ch === ')') {
1154
+ paren_depth--;
1155
+ value += ch;
1156
+ this.advance();
1157
+ // Stop when we close the last parenthesis
1158
+ if (paren_depth === 0) {
1159
+ break;
1160
+ }
1161
+ continue;
1162
+ }
1163
+ // Stop at whitespace or tag end if not inside parentheses
1164
+ if (paren_depth === 0 && (ch === ' ' || ch === '\t' || ch === '\n' ||
1165
+ ch === '\r' || ch === '>' ||
1166
+ (ch === '/' && this.peek_ahead(1) === '>'))) {
1167
+ break;
1168
+ }
1169
+ }
1170
+ value += ch;
1171
+ this.advance();
1172
+ }
1173
+ if (value.length > 0) {
1174
+ this.add_token(TokenType.ATTR_VALUE, value, value_start, this.position);
1175
+ }
1176
+ }
1177
+ else {
1178
+ // Unquoted value - JavaScript identifier or member expression
1179
+ // Valid chars: alphanumeric, underscore, period, dollar sign
1180
+ // Can be prefixed with ! for negation
1181
+ // Examples: myVar, this.method, obj.prop.subprop, $element, !this.canEdit
1182
+ //
1183
+ // RULES:
1184
+ // - @ event attributes: MUST be unquoted (to pass functions)
1185
+ // - $ attributes: Can be quoted (string) or unquoted (any JS value)
1186
+ // - Regular attributes: MUST be quoted (strings only)
1187
+ // Check attribute type
1188
+ const attr_context = this.get_current_attribute_context();
1189
+ const is_event_attr = attr_context.startsWith('@');
1190
+ const is_dollar_attr = attr_context.startsWith('$');
1191
+ // Regular attributes (not @ or $) must be quoted
1192
+ if (!is_event_attr && !is_dollar_attr) {
1193
+ const error = new JQHTMLParseError(`Regular HTML attributes must have quoted values.\n` +
1194
+ ` Attribute: ${attr_context}`, this.line, this.column, this.input);
1195
+ error.suggestion = `\n\nRegular attributes must be quoted:\n` +
1196
+ ` ✗ Wrong: ${attr_context}myValue\n` +
1197
+ ` ✓ Correct: ${attr_context}"myValue"\n` +
1198
+ ` ✓ Correct: ${attr_context}"prefix <%= this.data.value %> suffix"\n\n` +
1199
+ `Only @ event attributes (unquoted) and $ attributes (either) allow unquoted values:\n` +
1200
+ ` ✓ Correct: @click=this.handleClick (passes function reference)\n` +
1201
+ ` ✓ Correct: $data=this.complexObject (passes object)\n` +
1202
+ ` ✓ Correct: $sid="my-id" (passes string)`;
1203
+ throw error;
1204
+ }
1205
+ const value_start = this.position;
1206
+ let value = '';
1207
+ let isFirstChar = true;
1208
+ while (this.position < this.input.length) {
1209
+ const ch = this.current_char();
1210
+ // Allow ! only as the first character (negation operator)
1211
+ if (isFirstChar && ch === '!') {
1212
+ value += ch;
1213
+ this.advance();
1214
+ isFirstChar = false;
1215
+ continue;
1216
+ }
1217
+ isFirstChar = false;
1218
+ // Check if character is valid for JavaScript identifier/member expression/function call
1219
+ const isValidChar = (ch >= 'a' && ch <= 'z') ||
1220
+ (ch >= 'A' && ch <= 'Z') ||
1221
+ (ch >= '0' && ch <= '9') ||
1222
+ ch === '_' ||
1223
+ ch === '.' ||
1224
+ ch === '$' ||
1225
+ ch === '(' || // Allow parentheses for function calls
1226
+ ch === ')' ||
1227
+ ch === ',' || // Allow commas in function arguments
1228
+ ch === '"' || // Allow quoted strings in function arguments
1229
+ ch === "'"; // Allow quoted strings in function arguments
1230
+ if (!isValidChar) {
1231
+ // Stop at first non-valid character
1232
+ break;
1233
+ }
1234
+ value += ch;
1235
+ this.advance();
1236
+ }
1237
+ if (value.length > 0) {
1238
+ // Validate the pattern before accepting it
1239
+ this.validate_unquoted_value(value, attr_context);
1240
+ this.add_token(TokenType.ATTR_VALUE, value, value_start, this.position);
1241
+ }
1242
+ }
1243
+ }
1244
+ validate_unquoted_value(value, attr_context) {
1245
+ // Allowed patterns:
1246
+ // 1. Literals: true, false, null, undefined, 123, 45.67
1247
+ // 2. Identifiers: myVar, $variable, _private
1248
+ // 3. Property chains: obj.prop, MyClass.method, deep.nested.property
1249
+ // 4. Function calls: func(), obj.method(), func(arg1, arg2)
1250
+ // 5. Chains with calls: obj.method().property.another()
1251
+ //
1252
+ // NOT allowed:
1253
+ // - Operators: +, -, *, /, %, =, ==, ===, &&, ||, etc.
1254
+ // - Objects: {key: value}
1255
+ // - Arrays: [1, 2, 3]
1256
+ // - Ternary: condition ? a : b
1257
+ // Check for disallowed operators
1258
+ if (/[+\-*/%=<>!&|^~?:]/.test(value)) {
1259
+ const error = new JQHTMLParseError(`Operators are not allowed in unquoted $ attribute values.\n` +
1260
+ ` Found: ${attr_context}${value}`, this.line, this.column, this.input);
1261
+ error.suggestion = `\n\nUnquoted $ attribute values must be simple references or function calls:\n\n` +
1262
+ `✓ Allowed patterns:\n` +
1263
+ ` - Literals: $count=42 or $active=true\n` +
1264
+ ` - Variables: $data=myVariable\n` +
1265
+ ` - Property access: $handler=Controller.method\n` +
1266
+ ` - Function calls: $value=getData()\n` +
1267
+ ` - Complex chains: $fetch=API.users.getAll()\n\n` +
1268
+ `✗ Not allowed:\n` +
1269
+ ` - Operators: $value=a+b (use quoted string or component logic)\n` +
1270
+ ` - Ternary: $class=active?'on':'off' (use quoted string)\n` +
1271
+ ` - Comparisons: $show=count>5 (handle in component logic)\n\n` +
1272
+ `If you need complex expressions, handle them in the component's on_load() or on_ready() method.`;
1273
+ throw error;
1274
+ }
1275
+ // Check for object literals
1276
+ if (value.trim().startsWith('{')) {
1277
+ const error = new JQHTMLParseError(`Object literals are not allowed in unquoted $ attribute values.\n` +
1278
+ ` Found: ${attr_context}${value}`, this.line, this.column, this.input);
1279
+ error.suggestion = `\n\nUnquoted $ attribute values cannot contain object literals.\n\n` +
1280
+ `If you need to pass an object, create it in the component:\n` +
1281
+ ` ✗ Wrong: $config={key:"value"}\n` +
1282
+ ` ✓ Correct: In component: this.data.config = {key: "value"}\n` +
1283
+ ` In template: $config=this.data.config`;
1284
+ throw error;
1285
+ }
1286
+ // Check for array literals
1287
+ if (value.trim().startsWith('[')) {
1288
+ const error = new JQHTMLParseError(`Array literals are not allowed in unquoted $ attribute values.\n` +
1289
+ ` Found: ${attr_context}${value}`, this.line, this.column, this.input);
1290
+ error.suggestion = `\n\nUnquoted $ attribute values cannot contain array literals.\n\n` +
1291
+ `If you need to pass an array, create it in the component:\n` +
1292
+ ` ✗ Wrong: $items=[1,2,3]\n` +
1293
+ ` ✓ Correct: In component: this.data.items = [1, 2, 3]\n` +
1294
+ ` In template: $items=this.data.items`;
1295
+ throw error;
1296
+ }
1297
+ // Validate that parentheses are balanced
1298
+ let parenDepth = 0;
1299
+ let inString = false;
1300
+ let stringChar = '';
1301
+ for (let i = 0; i < value.length; i++) {
1302
+ const ch = value[i];
1303
+ // Track string boundaries
1304
+ if ((ch === '"' || ch === "'") && !inString) {
1305
+ inString = true;
1306
+ stringChar = ch;
1307
+ }
1308
+ else if (ch === stringChar && inString) {
1309
+ inString = false;
1310
+ stringChar = '';
1311
+ }
1312
+ // Only count parentheses outside strings
1313
+ if (!inString) {
1314
+ if (ch === '(')
1315
+ parenDepth++;
1316
+ if (ch === ')')
1317
+ parenDepth--;
1318
+ if (parenDepth < 0) {
1319
+ const error = new JQHTMLParseError(`Unmatched closing parenthesis in unquoted $ attribute value.\n` +
1320
+ ` Found: ${attr_context}${value}`, this.line, this.column, this.input);
1321
+ error.suggestion = `\n\nCheck for mismatched parentheses in the attribute value.`;
1322
+ throw error;
1323
+ }
1324
+ }
1325
+ }
1326
+ if (parenDepth !== 0) {
1327
+ const error = new JQHTMLParseError(`Unmatched opening parenthesis in unquoted $ attribute value.\n` +
1328
+ ` Found: ${attr_context}${value}`, this.line, this.column, this.input);
1329
+ error.suggestion = `\n\nCheck for mismatched parentheses in the attribute value.`;
1330
+ throw error;
1331
+ }
1332
+ // Validate the overall pattern using regex
1333
+ // Pattern: identifier(.identifier)*(( args? ))*
1334
+ // This allows: var, obj.prop, func(), obj.method(arg1, arg2).chain().more
1335
+ const pattern = /^!?[a-zA-Z_$][a-zA-Z0-9_$]*(\.[a-zA-Z_$][a-zA-Z0-9_$]*|\([^)]*\))*$|^(true|false|null|undefined|\d+(\.\d+)?)$/;
1336
+ if (!pattern.test(value)) {
1337
+ const error = new JQHTMLParseError(`Invalid syntax in unquoted $ attribute value.\n` +
1338
+ ` Found: ${attr_context}${value}`, this.line, this.column, this.input);
1339
+ error.suggestion = `\n\nUnquoted $ attribute values must follow these patterns:\n\n` +
1340
+ `✓ Allowed:\n` +
1341
+ ` - Number literals: 42, 3.14\n` +
1342
+ ` - Boolean literals: true, false\n` +
1343
+ ` - Null/undefined: null, undefined\n` +
1344
+ ` - Identifiers: myVar, _private, $jQuery\n` +
1345
+ ` - Property chains: Controller.method, obj.deep.property\n` +
1346
+ ` - Function calls: getData(), API.fetch("url")\n` +
1347
+ ` - Mixed chains: obj.method().property.call()\n\n` +
1348
+ `✗ Not allowed:\n` +
1349
+ ` - Spaces in names\n` +
1350
+ ` - Complex expressions with operators\n` +
1351
+ ` - Object or array literals\n\n` +
1352
+ `The value should be a simple reference to data, not complex logic.`;
1353
+ throw error;
1354
+ }
1355
+ }
1356
+ get_current_attribute_context() {
1357
+ // Look back in tokens to find the current attribute name for error reporting
1358
+ let i = this.tokens.length - 1;
1359
+ while (i >= 0) {
1360
+ const token = this.tokens[i];
1361
+ if (token.type === TokenType.ATTR_NAME) {
1362
+ // The @ or $ is already part of the attribute name
1363
+ return token.value + '=';
1364
+ }
1365
+ // Stop if we hit a tag boundary
1366
+ if (token.type === TokenType.TAG_OPEN || token.type === TokenType.TAG_CLOSE) {
1367
+ break;
1368
+ }
1369
+ i--;
1370
+ }
1371
+ return '';
1372
+ }
1373
+ value_contains_interpolation(quote) {
1374
+ // Look ahead to see if this quoted value contains <%= or <%!=
1375
+ let pos = this.position;
1376
+ while (pos < this.input.length && this.input[pos] !== quote) {
1377
+ if (pos + 2 < this.input.length &&
1378
+ this.input[pos] === '<' &&
1379
+ this.input[pos + 1] === '%') {
1380
+ return true;
1381
+ }
1382
+ pos++;
1383
+ }
1384
+ return false;
1385
+ }
1386
+ scan_interpolated_attribute_value(quote) {
1387
+ let text_start = this.position;
1388
+ let text = '';
1389
+ while (this.position < this.input.length && this.current_char() !== quote) {
1390
+ // Check for interpolation start
1391
+ if (this.current_char() === '<' && this.peek_ahead(1) === '%') {
1392
+ // Save any text before the interpolation
1393
+ if (text.length > 0) {
1394
+ this.add_token(TokenType.ATTR_VALUE, text, text_start, this.position);
1395
+ text = '';
1396
+ }
1397
+ // Check what kind of expression
1398
+ if (this.peek_ahead(2) === '!' && this.peek_ahead(3) === '=') {
1399
+ // <%!= expression %>
1400
+ this.advance(); // <
1401
+ this.advance(); // %
1402
+ this.advance(); // !
1403
+ this.advance(); // =
1404
+ this.add_token(TokenType.EXPRESSION_UNESCAPED, '<%!=', this.position - 4, this.position);
1405
+ }
1406
+ else if (this.peek_ahead(2) === '=') {
1407
+ // <%= expression %>
1408
+ this.advance(); // <
1409
+ this.advance(); // %
1410
+ this.advance(); // =
1411
+ this.add_token(TokenType.EXPRESSION_START, '<%=', this.position - 3, this.position);
1412
+ }
1413
+ else {
1414
+ // Just add as text
1415
+ text += this.current_char();
1416
+ this.advance();
1417
+ continue;
1418
+ }
1419
+ // Scan the JavaScript expression
1420
+ this.scan_javascript();
1421
+ // Consume %>
1422
+ if (this.current_char() === '%' && this.peek_ahead(1) === '>') {
1423
+ const tag_end_start = this.position;
1424
+ this.advance(); // %
1425
+ this.advance(); // >
1426
+ this.add_token(TokenType.TAG_END, '%>', tag_end_start, this.position);
1427
+ }
1428
+ // Reset text tracking
1429
+ text_start = this.position;
1430
+ }
1431
+ else {
1432
+ text += this.current_char();
1433
+ this.advance();
1434
+ }
1435
+ }
1436
+ // Add any remaining text
1437
+ if (text.length > 0) {
1438
+ this.add_token(TokenType.ATTR_VALUE, text, text_start, this.position);
1439
+ }
1440
+ // Consume closing quote
1441
+ if (this.current_char() === quote) {
1442
+ this.advance();
1443
+ }
1444
+ }
1445
+ }
1446
+ //# sourceMappingURL=lexer.js.map