🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
845 lines
42 KiB
JavaScript
845 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();
|
|
// 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' +
|
|
' ✅ <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
|