Files
rspade_system/node_modules/@jqhtml/parser/dist/parser.js
root 8d92b287be Update npm packages
Add --dump-dimensions option to rsx:debug for layout debugging
Mark framework publish

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 21:48:28 +00:00

846 lines
42 KiB
JavaScript

// 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 <Define:ComponentName> 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 <Define:ComponentName> 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 <Define:ComponentName>...</Define:ComponentName>
parse_component_definition() {
const start_token = this.consume(TokenType.DEFINE_START, 'Expected <Define:');
const name_token = this.consume(TokenType.COMPONENT_NAME, 'Expected component name');
// Validate component name starts with capital letter
if (!/^[A-Z]/.test(name_token.value)) {
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);
}
// Parse attributes (like tag="span", class="card", extends="Parent", $prop=value)
const attributes = {};
const defineArgs = {}; // $ attributes (raw JS, not data- attributes)
let extendsValue;
// Skip any leading newlines before attributes
while (this.match(TokenType.NEWLINE)) {
// Skip
}
while (!this.check(TokenType.GT) && !this.is_at_end()) {
const attr_name = this.consume(TokenType.ATTR_NAME, 'Expected attribute name');
// Validate that $sid is not used in Define tags
if (attr_name.value === '$sid') {
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);
}
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 <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);
error.message += '\n\n' +
' For dynamic classes, set the class attribute on the component invocation instead:\n' +
' ❌ <Define:MyComponent class="<%= this.args.col_class || \'default\' %>">\n' +
' ✅ <Define:MyComponent class="static-class">\n' +
' ...\n' +
' </Define:MyComponent>\n\n' +
' Then when using the component:\n' +
' <MyComponent class="col-md-8 col-xl-4" />\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 <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);
error.message += '\n\n' +
` For dynamic "${attr_name.value}" attribute values, set them dynamically in lifecycle methods:\n` +
' ❌ <Define:MyComponent ' + attr_name.value + '="<%= expression %>">\n' +
' ✅ <Define:MyComponent>\n' +
' ...\n' +
' </Define:MyComponent>\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 </Define:');
const closing_name = this.consume(TokenType.COMPONENT_NAME, 'Expected component name');
if (closing_name.value !== name_token.value) {
throw mismatchedTagError(`Define:${name_token.value}`, `Define:${closing_name.value}`, closing_name.line, closing_name.column, this.source, this.filename);
}
const end_token = this.consume(TokenType.GT, '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 <Slot:name>...</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 <Slot:name>content</Slot:name> or <Slot: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 </Slot:');
const closing_name = this.consume(TokenType.SLOT_NAME, 'Expected slot name');
if (closing_name.value !== name_token.value) {
throw mismatchedTagError(name_token.value, closing_name.value, closing_name.line, closing_name.column, this.source, this.filename);
}
const end_token = this.consume(TokenType.GT, '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 <div $redrawable> to <Redrawable tag="div">
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 <input> 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 </');
const close_name = this.consume(TokenType.TAG_NAME, 'Expected tag name');
// For $redrawable transforms, accept either original or transformed tag name
const is_valid_closing = close_name.value === tag_name ||
(original_tag_name && close_name.value === original_tag_name);
if (!is_valid_closing) {
throw mismatchedTagError(original_tag_name || tag_name, // Show original name in error
close_name.value, close_name.line, close_name.column, this.source, this.filename);
}
const end_token = this.consume(TokenType.GT, '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();
// Preserve whitespace in interpolated attribute values - spaces between
// expressions and text are significant (e.g., "<%= expr %> suffix")
// Only skip completely empty parts
if (token.value.length > 0) {
parts.push({ type: 'text', value: token.value, 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' +
' ✅ <tag style="<%= expression %>">\n' +
' ✅ <tag class="<%= condition ? \'active\' : \'\' %>">\n\n' +
' Or use conditional logic before the tag:\n' +
' ✅ <% let attrs = expression ? \'value\' : \'\'; %>\n' +
' <tag attr="<%= attrs %>">\n\n' +
' Or set attributes in on_ready() using jQuery:\n' +
' ✅ <tag $sid="my_element">\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 <Slot: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