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