Add SVG namespace auto-detection, custom elements, html: prefix, and fix filter/map tag collision
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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)}</{tag}>"
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user