@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/LICENSE +21 -0
- package/README.md +30 -0
- package/bin/jqhtml-compile +218 -0
- package/dist/ast.d.ts +102 -0
- package/dist/ast.d.ts.map +1 -0
- package/dist/ast.js +30 -0
- package/dist/ast.js.map +1 -0
- package/dist/codegen.d.ts +108 -0
- package/dist/codegen.d.ts.map +1 -0
- package/dist/codegen.js +1377 -0
- package/dist/codegen.js.map +1 -0
- package/dist/compiler.d.ts +25 -0
- package/dist/compiler.d.ts.map +1 -0
- package/dist/compiler.js +273 -0
- package/dist/compiler.js.map +1 -0
- package/dist/errors.d.ts +27 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +155 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +31 -0
- package/dist/index.js.map +1 -0
- package/dist/integration.d.ts +3 -0
- package/dist/integration.d.ts.map +1 -0
- package/dist/integration.js +47 -0
- package/dist/integration.js.map +1 -0
- package/dist/lexer.d.ts +123 -0
- package/dist/lexer.d.ts.map +1 -0
- package/dist/lexer.js +1446 -0
- package/dist/lexer.js.map +1 -0
- package/dist/parser.d.ts +56 -0
- package/dist/parser.d.ts.map +1 -0
- package/dist/parser.js +845 -0
- package/dist/parser.js.map +1 -0
- package/dist/runtime.d.ts +6 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +106 -0
- package/dist/runtime.js.map +1 -0
- package/package.json +60 -0
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
|