// JQHTML Parser - Builds AST from tokens // Simple recursive descent parser, no complex libraries import { TokenType } from './lexer.js'; import { NodeType, createNode } from './ast.js'; import { JQHTMLParseError, unclosedError, mismatchedTagError, syntaxError, getSuggestion } from './errors.js'; import { CodeGenerator } from './codegen.js'; export class Parser { tokens; current = 0; source; filename; // HTML5 void elements that cannot have closing tags // These are automatically treated as self-closing static VOID_ELEMENTS = new Set([ 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'source', 'track', 'wbr' ]); constructor(tokens, source, filename) { this.tokens = tokens; this.source = source; this.filename = filename; } /** * Validate JavaScript code for common mistakes */ validate_javascript_code(code, token) { // Check for this.innerHTML usage (should use content() function instead) if (/\bthis\.innerHTML\b/.test(code)) { const error = new JQHTMLParseError(`Invalid usage: this.innerHTML is not available in JQHTML templates.\n` + `Did you mean to use the content() function instead?`, token.line, token.column, this.source); error.suggestion = `\nJQHTML uses content() to render child elements, not this.innerHTML:\n\n` + ` ❌ Wrong: <%= this.innerHTML %>\n` + ` ✓ Correct: <%= content() %>\n\n` + ` ❌ Wrong: <% if (condition) { %> this.innerHTML <% } %>\n` + ` ✓ Correct: <% if (condition) { %> <%= content() %> <% } %>\n\n` + `Why content() instead of this.innerHTML?\n` + `- content() is a function that returns the rendered child content\n` + `- this.innerHTML is a DOM property, not available during template compilation\n` + `- content() supports named slots: content('slot_name')\n` + `- content() can pass data: content('row', rowData)`; throw error; } } // Main entry point - parse tokens into AST parse() { const body = []; const start = this.current_token(); // Skip leading whitespace and newlines while (!this.is_at_end() && (this.match(TokenType.NEWLINE) || (this.match(TokenType.TEXT) && this.previous_token().value.trim() === ''))) { // Skip whitespace } // Check if file is empty (only whitespace) if (this.is_at_end()) { // Empty file is allowed return createNode(NodeType.PROGRAM, { body: [] }, start.start, start.end, start.line, start.column, this.create_location(start, start)); } // Must have exactly one Define tag at top level if (!this.check(TokenType.DEFINE_START)) { const token = this.current_token(); throw syntaxError('JQHTML files must have exactly one top-level tag', token.line, token.column, this.source, this.filename); } // Parse the single component definition const component = this.parse_component_definition(); if (component) { body.push(component); } // Skip trailing whitespace and newlines while (!this.is_at_end() && (this.match(TokenType.NEWLINE) || this.match(TokenType.TEXT) && this.previous_token().value.trim() === '')) { // Skip whitespace } // Ensure no other content after the Define tag if (!this.is_at_end()) { const token = this.current_token(); throw syntaxError('JQHTML files must have exactly one top-level tag. Found additional content after the component definition.', token.line, token.column, this.source, this.filename); } const end = this.previous_token(); return createNode(NodeType.PROGRAM, { body }, start.start, end.end, start.line, start.column, this.create_location(start, end)); } // Parse top-level constructs parse_top_level() { // Skip whitespace-only text nodes at top level if (this.match(TokenType.NEWLINE)) { return null; } // Component definition if (this.check(TokenType.DEFINE_START)) { return this.parse_component_definition(); } // Regular content return this.parse_content(); } // Parse ... parse_component_definition() { const start_token = this.consume(TokenType.DEFINE_START, 'Expected tags. Component definitions cannot have scoped IDs.', attr_name.line, attr_name.column, this.source, this.filename); } this.consume(TokenType.EQUALS, 'Expected ='); const attr_value = this.parse_attribute_value(); // Check if attribute value contains template expressions (dynamic content) // Define tag attributes must be static - they're part of the template definition, not runtime values const hasInterpolation = attr_value && typeof attr_value === 'object' && (attr_value.interpolated === true || (attr_value.parts && attr_value.parts.some((p) => p.type === 'expression'))); if (hasInterpolation) { // Special error message for class attribute - most common case if (attr_name.value === 'class') { const error = syntaxError(`Template expressions cannot be used in tag attributes. The tag is a static template definition, not a live component instance.`, attr_name.line, attr_name.column, this.source, this.filename); error.message += '\n\n' + ' For dynamic classes, set the class attribute on the component invocation instead:\n' + ' ❌ \n' + ' ✅ \n' + ' ...\n' + ' \n\n' + ' Then when using the component:\n' + ' \n\n' + ' Or set attributes dynamically in on_create() or on_ready():\n' + ' on_ready() {\n' + ' const classes = this.args.col_class || \'col-12 col-md-6\';\n' + ' this.$.addClass(classes);\n' + ' }'; throw error; } else { // General error for other attributes const error = syntaxError(`Template expressions cannot be used in tag attributes. The tag is a static template definition, not a live component instance.`, attr_name.line, attr_name.column, this.source, this.filename); error.message += '\n\n' + ` For dynamic "${attr_name.value}" attribute values, set them dynamically in lifecycle methods:\n` + ' ❌ \n' + ' ✅ \n' + ' ...\n' + ' \n\n' + ' Then in your component class:\n' + ' on_create() {\n' + ` this.$.attr('${attr_name.value}', this.args.some_value);\n` + ' }\n\n' + ' Or use on_ready() for attributes that need DOM to be fully initialized.'; throw error; } } // Handle special attributes on Define tags if (attr_name.value === 'extends') { // extends="ParentComponent" - explicit template inheritance if (typeof attr_value === 'object' && attr_value.quoted) { extendsValue = attr_value.value; } else if (typeof attr_value === 'string') { extendsValue = attr_value; } else { throw syntaxError(`extends attribute must be a quoted string with the parent component name`, attr_name.line, attr_name.column, this.source, this.filename); } } else if (attr_name.value.startsWith('$')) { // $ attributes on Define tags are raw JS assignments (like component invocations) // Store them separately - they don't become data- attributes const propName = attr_name.value.substring(1); // Remove $ defineArgs[propName] = attr_value; } else { // Regular attributes (tag="span", class="card", etc.) attributes[attr_name.value] = attr_value; } // Skip newlines between attributes while (this.match(TokenType.NEWLINE)) { // Skip } } this.consume(TokenType.GT, 'Expected >'); const body = []; // Parse until we find the closing tag while (!this.check(TokenType.DEFINE_END)) { if (this.is_at_end()) { const error = unclosedError('component definition', name_token.value, name_token.line, name_token.column, this.source, this.filename); error.message += getSuggestion(error.message); throw error; } const node = this.parse_content(); if (node) { body.push(node); } } // Consume closing tag this.consume(TokenType.DEFINE_END, 'Expected '); // Detect slot-only templates for inheritance let isSlotOnly = false; let slotNames = []; // Check if body contains only slot nodes (ignoring whitespace-only text) const nonWhitespaceNodes = body.filter(node => { if (node.type === NodeType.TEXT) { return node.content.trim() !== ''; } return true; }); if (nonWhitespaceNodes.length > 0) { // Check if ALL non-whitespace nodes are slots const allSlots = nonWhitespaceNodes.every(node => node.type === NodeType.SLOT); if (allSlots) { isSlotOnly = true; slotNames = nonWhitespaceNodes.map(node => node.name); } } return createNode(NodeType.COMPONENT_DEFINITION, { name: name_token.value, body, attributes, extends: extendsValue, defineArgs: Object.keys(defineArgs).length > 0 ? defineArgs : undefined, isSlotOnly, slotNames }, start_token.start, end_token.end, start_token.line, start_token.column, this.create_location(start_token, end_token)); } // Parse content (text, expressions, control flow) parse_content() { // Comments are now preprocessed into whitespace by the lexer // Plain text if (this.match(TokenType.TEXT)) { const token = this.previous(); return createNode(NodeType.TEXT, { content: token.value }, token.start, token.end, token.line, token.column, this.create_location(token, token)); } // Expression <%= ... %> or <%!= ... %> if (this.match(TokenType.EXPRESSION_START) || this.match(TokenType.EXPRESSION_UNESCAPED)) { return this.parse_expression(); } // Code block <% ... %> if (this.match(TokenType.CODE_START)) { return this.parse_code_block(); } // Slot <#name>... if (this.match(TokenType.SLOT_START)) { return this.parse_slot(); } // HTML tags and component invocations if (this.match(TokenType.TAG_OPEN)) { return this.parse_tag(); } // Skip newlines in content if (this.match(TokenType.NEWLINE)) { const token = this.previous(); return createNode(NodeType.TEXT, { content: token.value }, token.start, token.end, token.line, token.column, this.create_location(token, token)); } // Advance if we don't recognize the token if (!this.is_at_end()) { this.advance(); } return null; } // Parse <%= expression %> or <%!= expression %> parse_expression() { const start_token = this.previous(); // EXPRESSION_START or EXPRESSION_UNESCAPED const code_token = this.consume(TokenType.JAVASCRIPT, 'Expected JavaScript code'); // Validate JavaScript code for common mistakes this.validate_javascript_code(code_token.value, code_token); const end_token = this.consume(TokenType.TAG_END, 'Expected %>'); return createNode(NodeType.EXPRESSION, { code: code_token.value, escaped: start_token.type === TokenType.EXPRESSION_START }, start_token.start, end_token.end, start_token.line, start_token.column, this.create_location(start_token, end_token)); } // Parse <% code %> - collect tokens with their types for proper transformation parse_code_block() { const start_token = this.previous(); // CODE_START // Collect tokens with their types const tokens = []; while (!this.check(TokenType.TAG_END)) { if (this.is_at_end()) { throw syntaxError('Unterminated code block - expected %>', start_token.line, start_token.column, this.source, this.filename); } const token = this.advance(); // Validate JavaScript tokens for common mistakes if (token.type === TokenType.JAVASCRIPT) { this.validate_javascript_code(token.value, token); } tokens.push({ type: token.type, value: token.value }); } const end_token = this.consume(TokenType.TAG_END, 'Expected %>'); return createNode(NodeType.CODE_BLOCK, { tokens }, // Pass tokens array instead of concatenated code start_token.start, end_token.end, start_token.line, start_token.column, this.create_location(start_token, end_token)); } // JavaScript reserved words that cannot be used as slot names static JAVASCRIPT_RESERVED_WORDS = new Set([ // Keywords 'break', 'case', 'catch', 'class', 'const', 'continue', 'debugger', 'default', 'delete', 'do', 'else', 'enum', 'export', 'extends', 'false', 'finally', 'for', 'function', 'if', 'import', 'in', 'instanceof', 'let', 'new', 'null', 'return', 'super', 'switch', 'this', 'throw', 'true', 'try', 'typeof', 'var', 'void', 'while', 'with', 'yield', // Future reserved words 'implements', 'interface', 'package', 'private', 'protected', 'public', 'static', 'await', // Other problematic words 'arguments', 'eval' ]); // Parse slot <#name>content or <#name /> parse_slot() { const start_token = this.previous(); // SLOT_START const name_token = this.consume(TokenType.SLOT_NAME, 'Expected slot name'); // Validate slot name against JavaScript reserved words if (Parser.JAVASCRIPT_RESERVED_WORDS.has(name_token.value.toLowerCase())) { 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); } // TODO: Parse attributes for let:prop syntax in future const attributes = {}; // Check for self-closing slot if (this.match(TokenType.SLASH)) { const end_token = this.consume(TokenType.GT, 'Expected >'); return createNode(NodeType.SLOT, { name: name_token.value, attributes, children: [], selfClosing: true }, start_token.start, end_token.end, start_token.line, start_token.column, this.create_location(start_token, end_token)); } // Regular slot with content this.consume(TokenType.GT, 'Expected >'); const children = []; // Parse until we find the closing tag while (!this.check(TokenType.SLOT_END)) { if (this.is_at_end()) { const error = unclosedError('slot', name_token.value, name_token.line, name_token.column, this.source, this.filename); error.message += getSuggestion(error.message); throw error; } const node = this.parse_content(); if (node) { children.push(node); } } // Consume closing tag this.consume(TokenType.SLOT_END, 'Expected '); return createNode(NodeType.SLOT, { name: name_token.value, attributes, children, selfClosing: false }, start_token.start, end_token.end, start_token.line, start_token.column, this.create_location(start_token, end_token)); } // Token navigation helpers // Parse HTML tag or component invocation parse_tag() { const start_token = this.previous(); // TAG_OPEN const name_token = this.consume(TokenType.TAG_NAME, 'Expected tag name'); let tag_name = name_token.value; let original_tag_name = null; // Track original for $redrawable // Check for forbidden tags const tag_lower = tag_name.toLowerCase(); if (tag_lower === 'script' || tag_lower === 'style') { throw syntaxError(`<${tag_name}> tags are not allowed in JQHTML templates. ` + `Use external files or inline styles via attributes instead.`, name_token.line, name_token.column, this.source, this.filename); } // Determine if this is a component (starts with capital letter) or HTML tag let is_component = tag_name[0] >= 'A' && tag_name[0] <= 'Z'; // Check if this is an HTML5 void element (only for HTML tags, not components) const is_void_element = !is_component && Parser.VOID_ELEMENTS.has(tag_lower); // Parse attributes const { attributes, conditionalAttributes } = this.parse_attributes(); // Check for $redrawable attribute transformation // Transform
to if (attributes['$redrawable'] !== undefined || attributes['data-redrawable'] !== undefined) { const redrawable_attr = attributes['$redrawable'] !== undefined ? '$redrawable' : 'data-redrawable'; // Remove the $redrawable attribute delete attributes[redrawable_attr]; // Store original tag name for closing tag matching original_tag_name = tag_name; // Add tag="original_tag_name" attribute attributes['tag'] = { quoted: true, value: tag_name }; // Transform tag name to Redrawable (reserved component name) tag_name = 'Redrawable'; is_component = true; // Now it's a component } // Check for explicit self-closing syntax if (this.match(TokenType.SELF_CLOSING)) { const end_token = this.previous(); if (is_component) { return createNode(NodeType.COMPONENT_INVOCATION, { name: tag_name, attributes, conditionalAttributes: conditionalAttributes.length > 0 ? conditionalAttributes : undefined, children: [], selfClosing: true }, start_token.start, end_token.end, start_token.line, start_token.column, this.create_location(start_token, end_token)); } else { return createNode(NodeType.HTML_TAG, { name: tag_name, attributes, conditionalAttributes: conditionalAttributes.length > 0 ? conditionalAttributes : undefined, children: [], selfClosing: true }, start_token.start, end_token.end, start_token.line, start_token.column, this.create_location(start_token, end_token)); } } // Auto-close void elements even without explicit /> syntax // This matches standard HTML5 authoring where doesn't need /> if (is_void_element) { // Skip newlines before > while (this.match(TokenType.NEWLINE)) { // Skip newlines } const end_token = this.consume(TokenType.GT, 'Expected >'); // Void elements are always HTML tags (not components) return createNode(NodeType.HTML_TAG, { name: tag_name, attributes, conditionalAttributes: conditionalAttributes.length > 0 ? conditionalAttributes : undefined, children: [], selfClosing: true }, start_token.start, end_token.end, start_token.line, start_token.column, this.create_location(start_token, end_token)); } // Must be a paired tag - skip newlines before > while (this.match(TokenType.NEWLINE)) { // Skip newlines } this.consume(TokenType.GT, 'Expected >'); // Parse children const children = []; // Keep parsing content until we hit the closing tag // For $redrawable transforms, accept either original or transformed name while (!this.check_closing_tag(tag_name) && !(original_tag_name && this.check_closing_tag(original_tag_name))) { if (this.is_at_end()) { const error = unclosedError(is_component ? 'component' : 'tag', tag_name, start_token.line, start_token.column, this.source, this.filename); throw error; } const child = this.parse_content(); if (child) { children.push(child); } } // Consume closing tag this.consume(TokenType.TAG_CLOSE, 'Expected '); if (is_component) { // Validate mixed content mode for components this.validate_component_children(children, tag_name, start_token); return createNode(NodeType.COMPONENT_INVOCATION, { name: tag_name, attributes, conditionalAttributes: conditionalAttributes.length > 0 ? conditionalAttributes : undefined, children, selfClosing: false }, start_token.start, end_token.end, start_token.line, start_token.column, this.create_location(start_token, end_token)); } else { // Check if this tag needs whitespace preservation const tag_lower = tag_name.toLowerCase(); const preserveWhitespace = tag_lower === 'textarea' || tag_lower === 'pre'; return createNode(NodeType.HTML_TAG, { name: tag_name, attributes, conditionalAttributes: conditionalAttributes.length > 0 ? conditionalAttributes : undefined, children, selfClosing: false, preserveWhitespace }, start_token.start, end_token.end, start_token.line, start_token.column, this.create_location(start_token, end_token)); } } // Parse attributes from tokens parse_attributes() { const attributes = {}; const conditionalAttributes = []; // Skip any leading newlines while (this.match(TokenType.NEWLINE)) { // Skip } while (this.check(TokenType.ATTR_NAME) || this.check(TokenType.CODE_START)) { // Check for conditional attribute: <% if (condition) { %> if (this.check(TokenType.CODE_START)) { // Check if this is a closing brace <% } %> - if so, stop parsing attributes // Peek ahead to see if the next token after CODE_START is a closing brace const peek_next = this.tokens[this.current + 1]; if (peek_next && peek_next.type === TokenType.JAVASCRIPT && peek_next.value.trim() === '}') { // This is a closing brace, not a new conditional attribute // Stop parsing and return control to the caller break; } const condAttr = this.parse_conditional_attribute(); if (condAttr) { conditionalAttributes.push(condAttr); } // Skip newlines after conditional attribute while (this.match(TokenType.NEWLINE)) { // Skip } continue; } const name_token = this.advance(); let name = name_token.value; let value = true; // Default for boolean attributes // Check for equals sign and value if (this.match(TokenType.EQUALS)) { // Check if this is a compound value with interpolation if (this.check(TokenType.ATTR_VALUE) || this.check(TokenType.EXPRESSION_START) || this.check(TokenType.EXPRESSION_UNESCAPED)) { value = this.parse_attribute_value(); } } // Handle special attribute prefixes if (name.startsWith('$')) { // Special case: $sid becomes data-sid (needed for scoped ID system) // All other $ attributes stay as-is (handled by instruction-processor.ts) if (name === '$sid') { name = 'data-sid'; } // Keep $ prefix for other attributes - they get stored via .data() at runtime // Keep the value object intact to preserve quoted/unquoted distinction } else if (name.startsWith(':')) { // Property binding: :prop="value" becomes data-bind-prop // Preserve whether value was quoted or not for proper code generation name = 'data-bind-' + name.substring(1); // Keep the value object intact to preserve quoted/unquoted distinction } else if (name.startsWith('@')) { // Event binding: @click="handler" becomes data-__-on-click // Preserve whether value was quoted or not for proper code generation name = 'data-__-on-' + name.substring(1); // Keep the value object intact to preserve quoted/unquoted distinction } attributes[name] = value; // Skip newlines between attributes while (this.match(TokenType.NEWLINE)) { // Skip } } return { attributes, conditionalAttributes }; } // Parse conditional attribute: <% if (condition) { %>attr="value"<% } %> parse_conditional_attribute() { const start_token = this.peek(); // Consume <% this.consume(TokenType.CODE_START, 'Expected <%'); let condition; // Only brace style supported: CODE_START → JAVASCRIPT "if (condition) {" → TAG_END if (this.check(TokenType.JAVASCRIPT)) { const jsToken = this.consume(TokenType.JAVASCRIPT, 'Expected if statement'); const jsCode = jsToken.value.trim(); // Verify it starts with 'if' and contains both ( and { if (!jsCode.startsWith('if')) { throw syntaxError('Only if statements are allowed in attribute context. Use <% if (condition) { %>attr="value"<% } %>', jsToken.line, jsToken.column, this.source); } // Extract condition from: if (condition) { const openParen = jsCode.indexOf('('); const closeBrace = jsCode.lastIndexOf('{'); if (openParen === -1 || closeBrace === -1) { throw syntaxError('Expected format: <% if (condition) { %>', jsToken.line, jsToken.column, this.source); } // Extract just the condition part (between parens, including parens) condition = jsCode.substring(openParen, closeBrace).trim(); } else { // Not an if statement throw syntaxError('Only if statements are allowed in attribute context. Use <% if (condition) { %>attr="value"<% } %>', this.peek().line, this.peek().column, this.source); } // Consume %> this.consume(TokenType.TAG_END, 'Expected %>'); // Now parse the attributes inside the conditional const innerAttrs = this.parse_attributes(); // These should be plain attributes only (no nested conditionals) if (innerAttrs.conditionalAttributes.length > 0) { throw syntaxError('Nested conditional attributes are not supported', start_token.line, start_token.column, this.source); } // Consume <% } %> this.consume(TokenType.CODE_START, 'Expected <% to close conditional attribute'); const closeToken = this.consume(TokenType.JAVASCRIPT, 'Expected }'); if (closeToken.value.trim() !== '}') { throw syntaxError('Expected } to close if statement', closeToken.line, closeToken.column, this.source); } this.consume(TokenType.TAG_END, 'Expected %>'); return createNode(NodeType.CONDITIONAL_ATTRIBUTE, { condition, attributes: innerAttrs.attributes }, start_token.start, this.previous().end, start_token.line, start_token.column); } // Parse potentially compound attribute value parse_attribute_value() { const parts = []; // For simple string values that are quoted in the source, return them with a quoted flag // This helps the codegen distinguish between $foo="bar" and $foo=bar const firstToken = this.peek(); const isSimpleValue = this.check(TokenType.ATTR_VALUE) && !this.check_ahead(1, TokenType.EXPRESSION_START) && !this.check_ahead(1, TokenType.EXPRESSION_UNESCAPED); // Collect all parts of the attribute value while (this.check(TokenType.ATTR_VALUE) || this.check(TokenType.EXPRESSION_START) || this.check(TokenType.EXPRESSION_UNESCAPED)) { if (this.check(TokenType.ATTR_VALUE)) { const token = this.advance(); // Trim whitespace from attribute value text parts to avoid extra newlines const trimmedValue = token.value.trim(); if (trimmedValue.length > 0) { parts.push({ type: 'text', value: trimmedValue, escaped: true }); } } else if (this.check(TokenType.EXPRESSION_START) || this.check(TokenType.EXPRESSION_UNESCAPED)) { const is_escaped = this.peek().type === TokenType.EXPRESSION_START; this.advance(); // consume <%= or <%!= const expr_token = this.consume(TokenType.JAVASCRIPT, 'Expected expression'); this.consume(TokenType.TAG_END, 'Expected %>'); parts.push({ type: 'expression', value: expr_token.value, escaped: is_escaped }); } } // If it's a single text part, check if it's quoted if (parts.length === 1 && parts[0].type === 'text') { const value = parts[0].value; // Check if the value has quotes (preserved by lexer for quoted strings) if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { // Return a marker that this was a quoted string return { quoted: true, value: value.slice(1, -1) }; } // Check if it's a parenthesized expression: $attr=(expr) if (value.startsWith('(') && value.endsWith(')')) { // Return as an expression - remove the parentheses return { expression: true, value: value.slice(1, -1) }; } // Check if it's a bare identifier or member expression: $attr=identifier or $attr=this.method // Valid identifiers can include dots for member access (e.g., this.handleClick, data.user.name) // Can be prefixed with ! for negation (e.g., !this.canEdit) // Pattern: optional ! then starts with letter/$/_ then any combo of letters/numbers/$/_ and dots if (/^!?[a-zA-Z_$][a-zA-Z0-9_$.]*$/.test(value)) { // Return as an identifier expression return { identifier: true, value: value }; } // Check if it contains function calls (parentheses) // If the lexer allowed it (passed validation), treat it as an identifier/expression // Examples: getData(), obj.method(arg), rsx.route('A','B').url(123) if (value.includes('(') || value.includes(')')) { // Return as an identifier expression (function call chain) return { identifier: true, value: value }; } // Otherwise, treat as a JavaScript expression (includes numeric literals like 42, 3.14, etc.) return { expression: true, value: value }; } // Any expression or multiple parts needs interpolation handling return { interpolated: true, parts }; } // Check if we're at a closing tag for the given name check_closing_tag(tag_name) { if (!this.check(TokenType.TAG_CLOSE)) { return false; } // Look ahead to see if the tag name matches const next_pos = this.current + 1; if (next_pos < this.tokens.length && this.tokens[next_pos].type === TokenType.TAG_NAME && this.tokens[next_pos].value === tag_name) { return true; } return false; } match(...types) { for (const type of types) { if (this.check(type)) { this.advance(); return true; } } return false; } check(type) { if (this.is_at_end()) return false; return this.peek().type === type; } check_ahead(offset, type) { if (this.current + offset >= this.tokens.length) { return false; } return this.tokens[this.current + offset].type === type; } check_sequence(...types) { for (let i = 0; i < types.length; i++) { if (this.current + i >= this.tokens.length) { return false; } if (this.tokens[this.current + i].type !== types[i]) { return false; } } return true; } advance() { if (!this.is_at_end()) this.current++; return this.previous(); } is_at_end() { return this.peek().type === TokenType.EOF; } peek() { return this.tokens[this.current]; } peek_ahead(offset) { const pos = this.current + offset; if (pos >= this.tokens.length) { return this.tokens[this.tokens.length - 1]; // Return EOF token } return this.tokens[pos]; } previous() { return this.tokens[this.current - 1]; } current_token() { return this.tokens[this.current] || this.tokens[this.tokens.length - 1]; } previous_token() { return this.tokens[Math.max(0, this.current - 1)]; } /** * Create a SourceLocation from start and end tokens * Propagates loc field if available, falls back to old fields for compatibility */ create_location(start, end) { if (start.loc && end.loc) { // Use new loc field if available return { start: start.loc.start, end: end.loc.end }; } // Fall back to old fields for backward compatibility return undefined; } consume(type, message) { if (this.check(type)) return this.advance(); const token = this.peek(); // Special case: Detecting template expressions inside HTML tag attributes if (type === TokenType.GT && (token.type === TokenType.EXPRESSION_START || token.type === TokenType.EXPRESSION_UNESCAPED)) { const error = syntaxError('Template expressions (<% %>) cannot be used as attribute values inside HTML tags', token.line, token.column, this.source, this.filename); // Add helpful remediation examples error.message += '\n\n' + ' Use template expressions INSIDE attribute values instead:\n' + ' ✅ \n' + ' ✅ \n\n' + ' Or use conditional logic before the tag:\n' + ' ✅ <% let attrs = expression ? \'value\' : \'\'; %>\n' + ' \n\n' + ' Or set attributes in on_ready() using jQuery:\n' + ' ✅ \n' + ' on_ready() {\n' + ' if (this.args.required) this.$sid(\'my_element\').attr(\'required\', true);\n' + ' }'; throw error; } const error = syntaxError(`${message}. Got ${token.type} instead`, token.line, token.column, this.source, this.filename); throw error; } // Validate component children to prevent mixed content mode validate_component_children(children, componentName, startToken) { let hasSlots = false; let hasNonSlotContent = false; for (const child of children) { if (child.type === NodeType.SLOT) { hasSlots = true; } else if (child.type === NodeType.TEXT) { // Check if it's non-whitespace text const textContent = child.content; if (textContent.trim() !== '') { hasNonSlotContent = true; } } else { // Any other node type (expressions, tags, etc.) is non-slot content hasNonSlotContent = true; } } // If component has both slots and non-slot content, throw error if (hasSlots && hasNonSlotContent) { throw syntaxError(`Mixed content not allowed: when using slots, all content must be inside <#slotname> tags`, startToken.line, startToken.column, this.source, this.filename); } } /** * Compile method for simplified API * Parses the template and returns component metadata and render function */ compile() { // Parse to get AST const ast = this.parse(); // Generate code with sourcemap const generator = new CodeGenerator(); const result = generator.generateWithSourceMap(ast, this.filename || 'template.jqhtml', this.source || ''); // Extract the single component (should only be one per file) const componentEntries = Array.from(result.components.entries()); if (componentEntries.length === 0) { throw new Error('No component definition found in template'); } if (componentEntries.length > 1) { const names = componentEntries.map(([name]) => name).join(', '); throw new Error(`Multiple component definitions found: ${names}. Only one component per file is allowed.`); } // Extract component information const [name, componentDef] = componentEntries[0]; return { name: name, tagName: componentDef.tagName || 'div', defaultAttributes: componentDef.defaultAttributes || {}, renderFunction: componentDef.render_function }; } } //# sourceMappingURL=parser.js.map