From d55180697679570145fdb8cfcbcba9a1da62139f Mon Sep 17 00:00:00 2001 From: giles Date: Wed, 4 Mar 2026 13:53:08 +0000 Subject: [PATCH] Add SVG namespace auto-detection, custom elements, html: prefix, and fix filter/map tag collision MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix filter/map dispatching as HO functions when used as SVG/HTML tags (peek at first arg — Keyword means tag call, not function call) - Add html: prefix escape hatch to force any name to render as an element - Support custom elements (hyphenated names) per Web Components spec - SVG/MathML namespace auto-detection: client threads ns param through render chain; server uses _svg_context ContextVar so unknown tags inside (svg ...) or (math ...) render as elements without enumeration Co-Authored-By: Claude Opus 4.6 --- shared/static/scripts/sx.js | 103 ++++++++++++++++++++------------- shared/sx/async_eval.py | 111 ++++++++++++++++++++++++++---------- shared/sx/html.py | 39 +++++++++++-- 3 files changed, 179 insertions(+), 74 deletions(-) diff --git a/shared/static/scripts/sx.js b/shared/static/scripts/sx.js index cfa98e7..5a04b7d 100644 --- a/shared/static/scripts/sx.js +++ b/shared/static/scripts/sx.js @@ -627,7 +627,10 @@ var sf = SPECIAL_FORMS[head.name]; if (sf) return sf(expr, env); var ho = HO_FORMS[head.name]; - if (ho) return ho(expr, env); + if (ho) { + // If name is also an HTML tag and first arg is Keyword → not a HO call + if (!(HTML_TAGS[head.name] && expr.length > 1 && isKw(expr[1]))) return ho(expr, env); + } // Macro expansion if (head.name in env) { @@ -1056,12 +1059,16 @@ ); var SVG_NS = "http://www.w3.org/2000/svg"; + var MATH_NS = "http://www.w3.org/1998/Math/MathML"; /** * Render an s-expression to DOM node(s). * Returns a DocumentFragment, Element, or Text node. + * @param {*} expr - s-expression + * @param {Object} env - variable bindings + * @param {string|null} ns - namespace URI (SVG_NS or MATH_NS) when inside svg/math */ - function renderDOM(expr, env) { + function renderDOM(expr, env, ns) { // nil / false → empty if (isNil(expr) || expr === false || expr === true) return document.createDocumentFragment(); @@ -1079,7 +1086,7 @@ if (typeof expr === "number") return document.createTextNode(String(expr)); // Symbol → evaluate then render - if (isSym(expr)) return renderDOM(sxEval(expr, env), env); + if (isSym(expr)) return renderDOM(sxEval(expr, env), env, ns); // Keyword → text if (isKw(expr)) return document.createTextNode(expr.name); @@ -1093,7 +1100,7 @@ // List → dispatch if (Array.isArray(expr)) { if (!expr.length) return document.createDocumentFragment(); - return renderList(expr, env); + return renderList(expr, env, ns); } return document.createTextNode(String(expr)); @@ -1102,34 +1109,34 @@ /** Render-aware special forms for DOM output. */ var RENDER_FORMS = {}; - RENDER_FORMS["if"] = function (expr, env) { + RENDER_FORMS["if"] = function (expr, env, ns) { var cond = sxEval(expr[1], env); - if (isSxTruthy(cond)) return renderDOM(expr[2], env); - return expr.length > 3 ? renderDOM(expr[3], env) : document.createDocumentFragment(); + if (isSxTruthy(cond)) return renderDOM(expr[2], env, ns); + return expr.length > 3 ? renderDOM(expr[3], env, ns) : document.createDocumentFragment(); }; - RENDER_FORMS["when"] = function (expr, env) { + RENDER_FORMS["when"] = function (expr, env, ns) { if (!isSxTruthy(sxEval(expr[1], env))) return document.createDocumentFragment(); var frag = document.createDocumentFragment(); - for (var i = 2; i < expr.length; i++) frag.appendChild(renderDOM(expr[i], env)); + for (var i = 2; i < expr.length; i++) frag.appendChild(renderDOM(expr[i], env, ns)); return frag; }; - RENDER_FORMS["cond"] = function (expr, env) { + RENDER_FORMS["cond"] = function (expr, env, ns) { var branch = _evalCond(expr.slice(1), env); - return branch ? renderDOM(branch, env) : document.createDocumentFragment(); + return branch ? renderDOM(branch, env, ns) : document.createDocumentFragment(); }; - RENDER_FORMS["let"] = RENDER_FORMS["let*"] = function (expr, env) { + RENDER_FORMS["let"] = RENDER_FORMS["let*"] = function (expr, env, ns) { var local = _processBindings(expr[1], env); var frag = document.createDocumentFragment(); - for (var k = 2; k < expr.length; k++) frag.appendChild(renderDOM(expr[k], local)); + for (var k = 2; k < expr.length; k++) frag.appendChild(renderDOM(expr[k], local, ns)); return frag; }; - RENDER_FORMS["begin"] = RENDER_FORMS["do"] = function (expr, env) { + RENDER_FORMS["begin"] = RENDER_FORMS["do"] = function (expr, env, ns) { var frag = document.createDocumentFragment(); - for (var i = 1; i < expr.length; i++) frag.appendChild(renderDOM(expr[i], env)); + for (var i = 1; i < expr.length; i++) frag.appendChild(renderDOM(expr[i], env, ns)); return frag; }; @@ -1140,45 +1147,45 @@ RENDER_FORMS["defmacro"] = function (expr, env) { sxEval(expr, env); return document.createDocumentFragment(); }; RENDER_FORMS["defhandler"] = function (expr, env) { sxEval(expr, env); return document.createDocumentFragment(); }; - RENDER_FORMS["map"] = function (expr, env) { + RENDER_FORMS["map"] = function (expr, env, ns) { var fn = sxEval(expr[1], env), coll = sxEval(expr[2], env); var frag = document.createDocumentFragment(); for (var i = 0; i < coll.length; i++) { - var val = isLambda(fn) ? renderLambdaDOM(fn, [coll[i]], env) : renderDOM(fn(coll[i]), env); + var val = isLambda(fn) ? renderLambdaDOM(fn, [coll[i]], env, ns) : renderDOM(fn(coll[i]), env, ns); frag.appendChild(val); } return frag; }; - RENDER_FORMS["map-indexed"] = function (expr, env) { + RENDER_FORMS["map-indexed"] = function (expr, env, ns) { var fn = sxEval(expr[1], env), coll = sxEval(expr[2], env); var frag = document.createDocumentFragment(); for (var i = 0; i < coll.length; i++) { - var val = isLambda(fn) ? renderLambdaDOM(fn, [i, coll[i]], env) : renderDOM(fn(i, coll[i]), env); + var val = isLambda(fn) ? renderLambdaDOM(fn, [i, coll[i]], env, ns) : renderDOM(fn(i, coll[i]), env, ns); frag.appendChild(val); } return frag; }; - RENDER_FORMS["filter"] = function (expr, env) { + RENDER_FORMS["filter"] = function (expr, env, ns) { var result = sxEval(expr, env); - return renderDOM(result, env); + return renderDOM(result, env, ns); }; - RENDER_FORMS["for-each"] = function (expr, env) { + RENDER_FORMS["for-each"] = function (expr, env, ns) { var fn = sxEval(expr[1], env), coll = sxEval(expr[2], env); var frag = document.createDocumentFragment(); for (var i = 0; i < coll.length; i++) { - var val = isLambda(fn) ? renderLambdaDOM(fn, [coll[i]], env) : renderDOM(fn(coll[i]), env); + var val = isLambda(fn) ? renderLambdaDOM(fn, [coll[i]], env, ns) : renderDOM(fn(coll[i]), env, ns); frag.appendChild(val); } return frag; }; - function renderLambdaDOM(fn, args, env) { + function renderLambdaDOM(fn, args, env, ns) { var local = merge({}, fn.closure, env); for (var i = 0; i < fn.params.length; i++) local[fn.params[i]] = args[i]; - return renderDOM(fn.body, local); + return renderDOM(fn.body, local, ns); } /** True when the array expr is a render-only form (HTML tag, <>, raw!, ~comp). */ @@ -1187,7 +1194,7 @@ var h = v[0]; if (!isSym(h)) return false; var n = h.name; - return !!(HTML_TAGS[n] || SVG_TAGS[n] || n === "<>" || n === "raw!" || n.charAt(0) === "~"); + return !!(HTML_TAGS[n] || SVG_TAGS[n] || n === "<>" || n === "raw!" || n.charAt(0) === "~" || n.indexOf("html:") === 0 || n.indexOf("-") > 0); } function renderComponentDOM(comp, args, env) { @@ -1235,7 +1242,7 @@ return renderDOM(comp.body, local); } - function renderList(expr, env) { + function renderList(expr, env, ns) { var head = expr[0]; if (isSym(head)) { @@ -1273,21 +1280,28 @@ // <> → fragment if (name === "<>") { var f = document.createDocumentFragment(); - for (var fi = 1; fi < expr.length; fi++) f.appendChild(renderDOM(expr[fi], env)); + for (var fi = 1; fi < expr.length; fi++) f.appendChild(renderDOM(expr[fi], env, ns)); return f; } + // html: prefix → force element rendering + if (name.indexOf("html:") === 0) return renderElement(name.substring(5), expr.slice(1), env, ns); + // Render-aware special forms - if (RENDER_FORMS[name]) return RENDER_FORMS[name](expr, env); + // If name is also an HTML tag and first arg is Keyword → tag call + if (RENDER_FORMS[name]) { + if (HTML_TAGS[name] && expr.length > 1 && isKw(expr[1])) return renderElement(name, expr.slice(1), env, ns); + return RENDER_FORMS[name](expr, env, ns); + } // Macro expansion if (name in env && isMacro(env[name])) { var mExpanded = expandMacro(env[name], expr.slice(1), env); - return renderDOM(mExpanded, env); + return renderDOM(mExpanded, env, ns); } // HTML tag - if (HTML_TAGS[name]) return renderElement(name, expr.slice(1), env); + if (HTML_TAGS[name]) return renderElement(name, expr.slice(1), env, ns); // Component if (name.charAt(0) === "~") { @@ -1303,23 +1317,34 @@ return warn; } + // Custom element (hyphenated name) → render as element + if (name.indexOf("-") > 0) return renderElement(name, expr.slice(1), env, ns); + + // SVG/MathML namespace auto-detection: inside (svg ...) or (math ...), + // unknown tags are created with the inherited namespace + if (ns) return renderElement(name, expr.slice(1), env, ns); + // Fallback: evaluate then render - return renderDOM(sxEval(expr, env), env); + return renderDOM(sxEval(expr, env), env, ns); } // Lambda/list head → evaluate - if (isLambda(head) || Array.isArray(head)) return renderDOM(sxEval(expr, env), env); + if (isLambda(head) || Array.isArray(head)) return renderDOM(sxEval(expr, env), env, ns); // Data list var dl = document.createDocumentFragment(); - for (var di = 0; di < expr.length; di++) dl.appendChild(renderDOM(expr[di], env)); + for (var di = 0; di < expr.length; di++) dl.appendChild(renderDOM(expr[di], env, ns)); return dl; } - function renderElement(tag, args, env) { - var el = SVG_TAGS[tag] - ? document.createElementNS(SVG_NS, tag) - : document.createElement(tag); + function renderElement(tag, args, env, ns) { + // Detect namespace from tag: svg → SVG_NS, math → MATH_NS + if (tag === "svg") ns = SVG_NS; + else if (tag === "math") ns = MATH_NS; + + var el = ns + ? document.createElementNS(ns, tag) + : (SVG_TAGS[tag] ? document.createElementNS(SVG_NS, tag) : document.createElement(tag)); var extraClass = null; var i = 0; @@ -1345,7 +1370,7 @@ } else { // Child if (!(tag in VOID_ELEMENTS)) { - el.appendChild(renderDOM(arg, env)); + el.appendChild(renderDOM(arg, env, ns)); } i++; } diff --git a/shared/sx/async_eval.py b/shared/sx/async_eval.py index 34febdc..fb8b3e9 100644 --- a/shared/sx/async_eval.py +++ b/shared/sx/async_eval.py @@ -29,7 +29,7 @@ from .primitives_io import IO_PRIMITIVES, RequestContext, execute_io from .parser import SxExpr, serialize from .html import ( HTML_TAGS, VOID_ELEMENTS, BOOLEAN_ATTRS, - escape_text, escape_attr, _RawHTML, css_class_collector, + escape_text, escape_attr, _RawHTML, css_class_collector, _svg_context, ) @@ -643,9 +643,16 @@ async def _arender_list(expr: list, env: dict[str, Any], ctx: RequestContext) -> parts.append(await _arender(child, env, ctx)) return "".join(parts) + # html: prefix → force tag rendering + if name.startswith("html:"): + return await _arender_element(name[5:], expr[1:], env, ctx) + # Render-aware special forms + # If name is also an HTML tag and first arg is Keyword → tag call arsf = _ASYNC_RENDER_FORMS.get(name) if arsf is not None: + if name in HTML_TAGS and len(expr) > 1 and isinstance(expr[1], Keyword): + return await _arender_element(name, expr[1:], env, ctx) return await arsf(expr, env, ctx) # Macro expansion @@ -665,6 +672,14 @@ async def _arender_list(expr: list, env: dict[str, Any], ctx: RequestContext) -> if isinstance(val, Component): return await _arender_component(val, expr[1:], env, ctx) + # Custom element (hyphenated name) → render as tag + if "-" in name: + return await _arender_element(name, expr[1:], env, ctx) + + # SVG/MathML context → unknown names are child elements + if _svg_context.get(False): + return await _arender_element(name, expr[1:], env, ctx) + # Fallback — evaluate then render result = await async_eval(expr, env, ctx) return await _arender(result, env, ctx) @@ -731,9 +746,19 @@ async def _arender_element( if tag in VOID_ELEMENTS: return opening - child_parts = [] - for child in children: - child_parts.append(await _arender(child, env, ctx)) + # SVG/MathML namespace auto-detection: set context for children + token = None + if tag in ("svg", "math"): + token = _svg_context.set(True) + + try: + child_parts = [] + for child in children: + child_parts.append(await _arender(child, env, ctx)) + finally: + if token is not None: + _svg_context.reset(token) + return f"{opening}{''.join(child_parts)}" @@ -1063,14 +1088,21 @@ async def _aser(expr: Any, env: dict[str, Any], ctx: RequestContext) -> Any: if name == "raw!": return await _aser_call("raw!", expr[1:], env, ctx) + # html: prefix → force tag serialization + if name.startswith("html:"): + return await _aser_call(name[5:], expr[1:], env, ctx) + # Component call — serialize (don't expand) if name.startswith("~"): return await _aser_call(name, expr[1:], env, ctx) # Serialize-mode special/HO forms (checked BEFORE HTML_TAGS - # because some names like "map" are both HTML tags and sx forms) + # because some names like "map" are both HTML tags and sx forms). + # If name is also an HTML tag and first arg is Keyword → tag call. sf = _ASER_FORMS.get(name) if sf is not None: + if name in HTML_TAGS and len(expr) > 1 and isinstance(expr[1], Keyword): + return await _aser_call(name, expr[1:], env, ctx) return await sf(expr, env, ctx) # HTML tag — serialize (don't render to HTML) @@ -1084,6 +1116,14 @@ async def _aser(expr: Any, env: dict[str, Any], ctx: RequestContext) -> Any: expanded = _expand_macro(val, expr[1:], env) return await _aser(expanded, env, ctx) + # Custom element (hyphenated name) → serialize as tag + if "-" in name: + return await _aser_call(name, expr[1:], env, ctx) + + # SVG/MathML context → unknown names are child elements + if _svg_context.get(False): + return await _aser_call(name, expr[1:], env, ctx) + # Function / lambda call — evaluate (produces data, not rendering) fn = await async_eval(head, env, ctx) args = [await async_eval(a, env, ctx) for a in expr[1:]] @@ -1150,32 +1190,41 @@ async def _aser_call( ) -> SxExpr: """Serialize ``(name :key val child ...)`` — evaluate args but keep as sx source instead of rendering to HTML.""" - parts = [name] - extra_class: str | None = None # from :style StyleValue conversion - i = 0 - while i < len(args): - arg = args[i] - if isinstance(arg, Keyword) and i + 1 < len(args): - val = await _aser(args[i + 1], env, ctx) - if val is not NIL and val is not None: - # :style StyleValue → convert to :class and register CSS - if arg.name == "style" and isinstance(val, StyleValue): - from .css_registry import register_generated_rule - register_generated_rule(val) - extra_class = val.class_name - else: - parts.append(f":{arg.name}") - parts.append(serialize(val)) - i += 2 - else: - result = await _aser(arg, env, ctx) - if result is not NIL and result is not None: - parts.append(serialize(result)) - i += 1 - # If we converted a :style to a class, merge into existing :class or add it - if extra_class: - _merge_class_into_parts(parts, extra_class) - return SxExpr("(" + " ".join(parts) + ")") + # SVG/MathML namespace auto-detection for serializer + token = None + if name in ("svg", "math"): + token = _svg_context.set(True) + + try: + parts = [name] + extra_class: str | None = None # from :style StyleValue conversion + i = 0 + while i < len(args): + arg = args[i] + if isinstance(arg, Keyword) and i + 1 < len(args): + val = await _aser(args[i + 1], env, ctx) + if val is not NIL and val is not None: + # :style StyleValue → convert to :class and register CSS + if arg.name == "style" and isinstance(val, StyleValue): + from .css_registry import register_generated_rule + register_generated_rule(val) + extra_class = val.class_name + else: + parts.append(f":{arg.name}") + parts.append(serialize(val)) + i += 2 + else: + result = await _aser(arg, env, ctx) + if result is not NIL and result is not None: + parts.append(serialize(result)) + i += 1 + # If we converted a :style to a class, merge into existing :class or add it + if extra_class: + _merge_class_into_parts(parts, extra_class) + return SxExpr("(" + " ".join(parts) + ")") + finally: + if token is not None: + _svg_context.reset(token) def _merge_class_into_parts(parts: list[str], class_name: str) -> None: diff --git a/shared/sx/html.py b/shared/sx/html.py index 2c78f09..49b7068 100644 --- a/shared/sx/html.py +++ b/shared/sx/html.py @@ -44,6 +44,12 @@ css_class_collector: contextvars.ContextVar[set[str] | None] = contextvars.Conte "css_class_collector", default=None ) +# ContextVar for SVG/MathML namespace auto-detection. +# When True, unknown tag names inside (svg ...) or (math ...) are treated as elements. +_svg_context: contextvars.ContextVar[bool] = contextvars.ContextVar( + "_svg_context", default=False +) + class _RawHTML: """Marker for pre-rendered HTML that should not be escaped.""" @@ -430,10 +436,19 @@ def _render_list(expr: list, env: dict[str, Any]) -> str: if name == "<>": return "".join(_render(child, env) for child in expr[1:]) + # --- html: prefix → force tag rendering -------------------------- + if name.startswith("html:"): + return _render_element(name[5:], expr[1:], env) + # --- Render-aware special forms -------------------------------------- # Check BEFORE HTML_TAGS because some names overlap (e.g. `map`). - if name in _RENDER_FORMS: - return _RENDER_FORMS[name](expr, env) + # But if the name is ALSO an HTML tag and first arg is a Keyword, + # it's a tag call (e.g. (filter :id "x" ...)), not a HO function. + rsf = _RENDER_FORMS.get(name) + if rsf is not None: + if name in HTML_TAGS and len(expr) > 1 and isinstance(expr[1], Keyword): + return _render_element(name, expr[1:], env) + return rsf(expr, env) # --- Macro expansion → expand then render -------------------------- if name in env: @@ -453,6 +468,14 @@ def _render_list(expr: list, env: dict[str, Any]) -> str: return _render_component(val, expr[1:], env) # Fall through to evaluation + # --- Custom element (hyphenated name) → render as tag ------------ + if "-" in name: + return _render_element(name, expr[1:], env) + + # --- SVG/MathML context → unknown names are child elements -------- + if _svg_context.get(False): + return _render_element(name, expr[1:], env) + # --- Other special forms / function calls → evaluate then render --- result = _eval(expr, env) return _render(result, env) @@ -524,7 +547,15 @@ def _render_element(tag: str, args: list, env: dict[str, Any]) -> str: if tag in VOID_ELEMENTS: return opening - # Render children - child_html = "".join(_render(child, env) for child in children) + # SVG/MathML namespace auto-detection: set context for children + token = None + if tag in ("svg", "math"): + token = _svg_context.set(True) + + try: + child_html = "".join(_render(child, env) for child in children) + finally: + if token is not None: + _svg_context.reset(token) return f"{opening}{child_html}"