diff --git a/el-compiler/src/lexer.el b/el-compiler/src/lexer.el index 2c5af22..5b93f81 100644 --- a/el-compiler/src/lexer.el +++ b/el-compiler/src/lexer.el @@ -713,8 +713,13 @@ fn lex(source: String) -> [Map] { let tokens = native_list_append(tokens, make_tok("QuestionMark", "?")) let i = i + 1 } else { - // unknown char — skip - let i = i + 1 + if ch == "#" { + let tokens = native_list_append(tokens, make_tok("Hash", "#")) + let i = i + 1 + } else { + // unknown char — skip + let i = i + 1 + } } } } diff --git a/el-compiler/src/parser.el b/el-compiler/src/parser.el index 2ea9c96..fd3a223 100644 --- a/el-compiler/src/parser.el +++ b/el-compiler/src/parser.el @@ -142,6 +142,460 @@ fn parse_params(tokens: [Map], pos: Int) -> Map { // ── Expression parsing ──────────────────────────────────────────────────────── +// ── HTML template parser ────────────────────────────────────────────────────── +// +// HTML templates are written as unquoted HTML in expression position: +// return

{title}

+// +// The parser detects an HTML template when parse_primary sees Lt followed +// by a lowercase ident (a known or assumed HTML element name) or `!` (for +// ). It then recursively parses the HTML into an AST. +// +// AST nodes produced: +// { "expr": "HtmlTemplate", "root": child_node } +// { "html": "Element", "tag": "div", "attrs": [...], "children": [...], "self_closing": bool } +// { "html": "Text", "text": "..." } +// { "html": "Interp", "value": expr_node } +// { "html": "Each", "list": expr_node, "item": "name", "body": [...] } +// { "html": "Doctype" } +// { "html": "Raw", "value": expr_node } + +fn is_html_tag_name(name: String) -> Bool { + if str_eq(name, "a") { return true } + if str_eq(name, "abbr") { return true } + if str_eq(name, "address") { return true } + if str_eq(name, "area") { return true } + if str_eq(name, "article") { return true } + if str_eq(name, "aside") { return true } + if str_eq(name, "audio") { return true } + if str_eq(name, "b") { return true } + if str_eq(name, "base") { return true } + if str_eq(name, "blockquote") { return true } + if str_eq(name, "body") { return true } + if str_eq(name, "br") { return true } + if str_eq(name, "button") { return true } + if str_eq(name, "canvas") { return true } + if str_eq(name, "caption") { return true } + if str_eq(name, "cite") { return true } + if str_eq(name, "code") { return true } + if str_eq(name, "col") { return true } + if str_eq(name, "colgroup") { return true } + if str_eq(name, "data") { return true } + if str_eq(name, "datalist") { return true } + if str_eq(name, "dd") { return true } + if str_eq(name, "del") { return true } + if str_eq(name, "details") { return true } + if str_eq(name, "dfn") { return true } + if str_eq(name, "dialog") { return true } + if str_eq(name, "div") { return true } + if str_eq(name, "dl") { return true } + if str_eq(name, "dt") { return true } + if str_eq(name, "em") { return true } + if str_eq(name, "embed") { return true } + if str_eq(name, "fieldset") { return true } + if str_eq(name, "figcaption") { return true } + if str_eq(name, "figure") { return true } + if str_eq(name, "footer") { return true } + if str_eq(name, "form") { return true } + if str_eq(name, "h1") { return true } + if str_eq(name, "h2") { return true } + if str_eq(name, "h3") { return true } + if str_eq(name, "h4") { return true } + if str_eq(name, "h5") { return true } + if str_eq(name, "h6") { return true } + if str_eq(name, "head") { return true } + if str_eq(name, "header") { return true } + if str_eq(name, "hr") { return true } + if str_eq(name, "html") { return true } + if str_eq(name, "i") { return true } + if str_eq(name, "iframe") { return true } + if str_eq(name, "img") { return true } + if str_eq(name, "input") { return true } + if str_eq(name, "ins") { return true } + if str_eq(name, "kbd") { return true } + if str_eq(name, "label") { return true } + if str_eq(name, "legend") { return true } + if str_eq(name, "li") { return true } + if str_eq(name, "link") { return true } + if str_eq(name, "main") { return true } + if str_eq(name, "map") { return true } + if str_eq(name, "mark") { return true } + if str_eq(name, "menu") { return true } + if str_eq(name, "meta") { return true } + if str_eq(name, "meter") { return true } + if str_eq(name, "nav") { return true } + if str_eq(name, "noscript") { return true } + if str_eq(name, "object") { return true } + if str_eq(name, "ol") { return true } + if str_eq(name, "optgroup") { return true } + if str_eq(name, "option") { return true } + if str_eq(name, "output") { return true } + if str_eq(name, "p") { return true } + if str_eq(name, "param") { return true } + if str_eq(name, "picture") { return true } + if str_eq(name, "pre") { return true } + if str_eq(name, "progress") { return true } + if str_eq(name, "q") { return true } + if str_eq(name, "rp") { return true } + if str_eq(name, "rt") { return true } + if str_eq(name, "ruby") { return true } + if str_eq(name, "s") { return true } + if str_eq(name, "samp") { return true } + if str_eq(name, "script") { return true } + if str_eq(name, "section") { return true } + if str_eq(name, "select") { return true } + if str_eq(name, "small") { return true } + if str_eq(name, "source") { return true } + if str_eq(name, "span") { return true } + if str_eq(name, "strong") { return true } + if str_eq(name, "style") { return true } + if str_eq(name, "sub") { return true } + if str_eq(name, "summary") { return true } + if str_eq(name, "sup") { return true } + if str_eq(name, "table") { return true } + if str_eq(name, "tbody") { return true } + if str_eq(name, "td") { return true } + if str_eq(name, "template") { return true } + if str_eq(name, "textarea") { return true } + if str_eq(name, "tfoot") { return true } + if str_eq(name, "th") { return true } + if str_eq(name, "thead") { return true } + if str_eq(name, "time") { return true } + if str_eq(name, "title") { return true } + if str_eq(name, "tr") { return true } + if str_eq(name, "track") { return true } + if str_eq(name, "u") { return true } + if str_eq(name, "ul") { return true } + if str_eq(name, "var") { return true } + if str_eq(name, "video") { return true } + if str_eq(name, "wbr") { return true } + false +} + +fn is_void_element(name: String) -> Bool { + if str_eq(name, "area") { return true } + if str_eq(name, "base") { return true } + if str_eq(name, "br") { return true } + if str_eq(name, "col") { return true } + if str_eq(name, "embed") { return true } + if str_eq(name, "hr") { return true } + if str_eq(name, "img") { return true } + if str_eq(name, "input") { return true } + if str_eq(name, "link") { return true } + if str_eq(name, "meta") { return true } + if str_eq(name, "param") { return true } + if str_eq(name, "source") { return true } + if str_eq(name, "track") { return true } + if str_eq(name, "wbr") { return true } + false +} + +// Collect tokens as text content until we hit Lt, LBrace, Eof, or a +// closing-tag marker (Lt Slash). Returns { "text": "...", "pos": p } +fn parse_html_text_tokens(tokens: [Map], pos: Int) -> Map { + let parts: [String] = native_list_empty() + let p = pos + let running = true + while running { + let k = tok_kind(tokens, p) + if str_eq(k, "Eof") { + let running = false + } else { + if str_eq(k, "Lt") { + let running = false + } else { + if str_eq(k, "LBrace") { + let running = false + } else { + // Check for ). +fn parse_html_attrs(tokens: [Map], pos: Int) -> Map { + let attrs: [Map] = native_list_empty() + let p = pos + let running = true + while running { + let k = tok_kind(tokens, p) + if str_eq(k, "Gt") { + let running = false + } else { + if str_eq(k, "Slash") { + let running = false + } else { + if str_eq(k, "Eof") { + let running = false + } else { + // Attribute name: could be Ident or keyword used as attr name + let attr_name = tok_value(tokens, p) + let p = p + 1 + let k2 = tok_kind(tokens, p) + if str_eq(k2, "Eq") { + let p = p + 1 + let k3 = tok_kind(tokens, p) + if str_eq(k3, "Str") { + // static: attr="value" + let attr_val = tok_value(tokens, p) + let p = p + 1 + let attrs = native_list_append(attrs, { "name": attr_name, "kind": "static", "value": attr_val }) + } else { + if str_eq(k3, "LBrace") { + // dynamic: attr={expr} + let r = parse_expr(tokens, p + 1) + let val_node = r["node"] + let p = r["pos"] + let p = expect(tokens, p, "RBrace") + let attrs = native_list_append(attrs, { "name": attr_name, "kind": "dynamic", "value": val_node }) + } else { + // malformed, skip + } + } + } else { + // boolean attribute + let attrs = native_list_append(attrs, { "name": attr_name, "kind": "bool" }) + } + } + } + } + } + { "attrs": attrs, "pos": p } +} + +// Parse the children of an HTML element until we see the closing tag +// or EOF. Returns { "children": [...], "pos": p_after_closing_tag } +fn parse_html_children(tokens: [Map], pos: Int, parent_tag: String) -> Map { + let children: [Map] = native_list_empty() + let p = pos + let running = true + while running { + let k = tok_kind(tokens, p) + if str_eq(k, "Eof") { + let running = false + } else { + if str_eq(k, "Lt") { + // Check for closing tag: — consume and stop + let p = p + 2 + // skip tag name + let close_name = tok_value(tokens, p) + let p = p + 1 + // consume > + let p = expect(tokens, p, "Gt") + let running = false + } else { + if str_eq(k2, "Not") { + // Possible + let k3_v = tok_value(tokens, p + 2) + if str_eq(k3_v, "doctype") { + // consume + let p = p + 2 + // skip until > + let scanning = true + while scanning { + let ck = tok_kind(tokens, p) + if str_eq(ck, "Eof") { let scanning = false } + if str_eq(ck, "Gt") { + let p = p + 1 + let scanning = false + } else { + let p = p + 1 + } + } + let children = native_list_append(children, { "html": "Doctype" }) + } else { + let p = p + 1 + } + } else { + // nested element + let r = parse_html_element(tokens, p) + let child = r["node"] + let p = r["pos"] + let children = native_list_append(children, child) + } + } + } else { + if str_eq(k, "LBrace") { + // Interpolation: {expr} or {#each ...} or {/each} + let k2 = tok_kind(tokens, p + 1) + if str_eq(k2, "Hash") { + // {#each list as item} + let k3_v = tok_value(tokens, p + 2) + if str_eq(k3_v, "each") { + let p = p + 3 + // parse list expr up to "as" keyword + let prev_no_block: String = state_get("__no_block_expr") + state_set("__no_block_expr", "1") + let r_list = parse_expr(tokens, p) + state_set("__no_block_expr", prev_no_block) + let list_expr = r_list["node"] + let p = r_list["pos"] + // expect "as" + let p = expect(tokens, p, "As") + // item variable name + let item_name = tok_value(tokens, p) + let p = p + 1 + // consume closing } + let p = expect(tokens, p, "RBrace") + // parse body until {/each} + let r_body = parse_html_each_body(tokens, p) + let body_children = r_body["children"] + let p = r_body["pos"] + let each_node: Map = { "html": "Each", "list": list_expr, "item": item_name, "body": body_children } + let children = native_list_append(children, each_node) + } else { + let p = p + 1 + } + } else { + if str_eq(k2, "Slash") { + // {/each} — end of each block, stop + // skip {/each} + let p = p + 2 + // skip "each" + let p = p + 1 + // skip } + let p = expect(tokens, p, "RBrace") + let running = false + } else { + // regular {expr} + let r = parse_expr(tokens, p + 1) + let interp_val = r["node"] + let p = r["pos"] + let p = expect(tokens, p, "RBrace") + // Check if the expr is a call to raw() + let is_raw_call = false + let interp_kind: String = interp_val["expr"] + if str_eq(interp_kind, "Call") { + let fn_node = interp_val["func"] + let fn_kind: String = fn_node["expr"] + if str_eq(fn_kind, "Ident") { + let fn_name_v: String = fn_node["name"] + if str_eq(fn_name_v, "raw") { + let is_raw_call = true + } + } + } + if is_raw_call { + let raw_args = interp_val["args"] + let raw_inner = native_list_get(raw_args, 0) + let children = native_list_append(children, { "html": "Raw", "value": raw_inner }) + } else { + let children = native_list_append(children, { "html": "Interp", "value": interp_val }) + } + } + } + } else { + // Text tokens — collect run of non-special tokens + let r_text = parse_html_text_tokens(tokens, p) + let text_str: String = r_text["text"] + let p = r_text["pos"] + let text_trimmed: String = str_trim(text_str) + if !str_eq(text_trimmed, "") { + let children = native_list_append(children, { "html": "Text", "text": text_trimmed }) + } + } + } + } + } + { "children": children, "pos": p } +} + +// Parse body of {#each} until {/each}. Mirrors parse_html_children but +// stops at the {/each} sentinel rather than a closing element tag. +fn parse_html_each_body(tokens: [Map], pos: Int) -> Map { + parse_html_children(tokens, pos, "__each__") +} + +// Parse a single HTML element: children +// or self-closing: +// Pos points to the Lt token. +fn parse_html_element(tokens: [Map], pos: Int) -> Map { + let p = pos + // consume < + let p = expect(tokens, p, "Lt") + // tag name + let tag_name = tok_value(tokens, p) + let p = p + 1 + // parse attributes + let r_attrs = parse_html_attrs(tokens, p) + let attrs = r_attrs["attrs"] + let p = r_attrs["pos"] + // check for self-closing /> or void element + let k = tok_kind(tokens, p) + let self_closing = false + if str_eq(k, "Slash") { + // /> + let p = p + 1 + let p = expect(tokens, p, "Gt") + let self_closing = true + return make_result({ "html": "Element", "tag": tag_name, "attrs": attrs, "children": native_list_empty(), "self_closing": true }, p) + } + // consume > + let p = expect(tokens, p, "Gt") + // void elements have no children, no closing tag + if is_void_element(tag_name) { + return make_result({ "html": "Element", "tag": tag_name, "attrs": attrs, "children": native_list_empty(), "self_closing": true }, p) + } + // parse children + let r_children = parse_html_children(tokens, p, tag_name) + let children = r_children["children"] + let p = r_children["pos"] + make_result({ "html": "Element", "tag": tag_name, "attrs": attrs, "children": children, "self_closing": false }, p) +} + +// Entry point for HTML template parsing. +// Pos points to Lt (or Lt Not for ). +// May parse an optional prefix followed by the root element. +fn parse_html_template(tokens: [Map], pos: Int) -> Map { + let p = pos + // Check for + let doctype = false + let k = tok_kind(tokens, p) + let k2 = tok_kind(tokens, p + 1) + if str_eq(k, "Lt") { + if str_eq(k2, "Not") { + let k3_v = tok_value(tokens, p + 2) + if str_eq(k3_v, "doctype") { + let doctype = true + // consume + let p = p + 2 + let scanning = true + while scanning { + let ck = tok_kind(tokens, p) + if str_eq(ck, "Eof") { let scanning = false } + if str_eq(ck, "Gt") { + let p = p + 1 + let scanning = false + } else { + let p = p + 1 + } + } + } + } + } + // Parse root element + let r = parse_html_element(tokens, p) + let root = r["node"] + let p = r["pos"] + let root_with_doctype = root + if doctype { + let root_with_doctype = { "html": root["html"], "tag": root["tag"], "attrs": root["attrs"], "children": root["children"], "self_closing": root["self_closing"], "doctype": true } + } + make_result({ "expr": "HtmlTemplate", "root": root_with_doctype }, p) +} + fn parse_primary(tokens: [Map], pos: Int) -> Map { let k = tok_kind(tokens, pos) let v = tok_value(tokens, pos) @@ -166,6 +620,22 @@ fn parse_primary(tokens: [Map], pos: Int) -> Map { return make_result({ "expr": "Bool", "value": v }, pos + 1) } + // HTML template literal: ... or ... + // Detected in value position only; `<` in comparison position is already + // consumed by parse_binop before parse_primary is reached. + if k == "Lt" { + let k2 = tok_kind(tokens, pos + 1) + if str_eq(k2, "Not") { + return parse_html_template(tokens, pos) + } + if str_eq(k2, "Ident") { + let tag_candidate = tok_value(tokens, pos + 1) + if is_html_tag_name(tag_candidate) { + return parse_html_template(tokens, pos) + } + } + } + // Identifier if k == "Ident" { return make_result({ "expr": "Ident", "name": v }, pos + 1)