Skip to content

Commit 1e5c25b

Browse files
Restore support for text expressions (#148)
* Restore support for text expressions * Use AST-native text interpolation codegen * Normalize multiline text indentation
1 parent cbc7b3c commit 1e5c25b

3 files changed

Lines changed: 221 additions & 8 deletions

File tree

collagraph/sfc/compiler.py

Lines changed: 164 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import ast
44
import logging
5+
import re
56
import sys
67
import textwrap
78
from collections import defaultdict
@@ -368,6 +369,118 @@ def ast_create_list_fragment(name: str, parent: str | None) -> ast.Assign:
368369
)
369370

370371

372+
def parse_text_interpolations(text: str) -> list[tuple[bool, str]]:
373+
"""
374+
Parse text with {{expression}} interpolations.
375+
376+
Returns a list of tuples where:
377+
- (False, str) represents static text
378+
- (True, str) represents an expression to be evaluated
379+
380+
Examples:
381+
"Hello {{name}}" -> [(False, "Hello "), (True, "name")]
382+
"Static text" -> [(False, "Static text")]
383+
"{{a}} and {{b}}" -> [(True, "a"), (False, " and "), (True, "b")]
384+
"""
385+
result = []
386+
i = 0
387+
n = len(text)
388+
389+
while i < n:
390+
# Look for start of interpolation
391+
start = text.find("{{", i)
392+
if start == -1:
393+
# No more interpolations, add remaining text
394+
if i < n:
395+
result.append((False, text[i:]))
396+
break
397+
398+
# Check for escape sequence \{{
399+
if start > 0 and text[start - 1] == "\\":
400+
# Escaped - include text up to and including the {{ as static
401+
# but remove the backslash
402+
if i < start - 1:
403+
result.append((False, text[i : start - 1]))
404+
result.append((False, "{{"))
405+
i = start + 2
406+
continue
407+
408+
# Add static text before the interpolation
409+
if start > i:
410+
result.append((False, text[i:start]))
411+
412+
# Find the end of the interpolation
413+
end = text.find("}}", start + 2)
414+
if end == -1:
415+
# Unclosed interpolation - treat rest as static
416+
result.append((False, text[start:]))
417+
break
418+
419+
# Extract the expression
420+
expr = text[start + 2 : end].strip()
421+
if expr:
422+
result.append((True, expr))
423+
424+
i = end + 2
425+
426+
return result
427+
428+
429+
def normalize_multiline_text(text: str) -> str:
430+
if "\n" not in text and "\r" not in text:
431+
return text
432+
return re.sub(r"(\r?\n)[ \t]+", r"\1", text)
433+
434+
435+
def ast_set_text_bind(
436+
el: str,
437+
parts: list[tuple[bool, str]],
438+
names: set[str],
439+
list_names: list[dict[str, set[str]]],
440+
) -> ast.Expr:
441+
joined_values: list[ast.Constant | ast.FormattedValue] = []
442+
for is_expr, part in parts:
443+
if is_expr:
444+
expression_ast = ast.parse(part, mode="eval")
445+
joined_values.append(
446+
ast.FormattedValue(
447+
value=expression_ast.body,
448+
conversion=-1,
449+
)
450+
)
451+
elif part:
452+
joined_values.append(ast.Constant(value=part))
453+
454+
source = ast.Expression(
455+
body=ast.Call(
456+
func=ast.Attribute(
457+
value=ast.Name(id=el, ctx=ast.Load()),
458+
attr="set_bind",
459+
ctx=ast.Load(),
460+
),
461+
args=[
462+
ast.Constant(value="content"),
463+
ast.Lambda(
464+
args=ast.arguments(
465+
posonlyargs=[],
466+
args=[],
467+
kwonlyargs=[],
468+
kw_defaults=[],
469+
defaults=[],
470+
),
471+
body=ast.JoinedStr(values=joined_values),
472+
),
473+
],
474+
keywords=[],
475+
)
476+
)
477+
return ast_named_lambda(
478+
source,
479+
{"renderer", "new", el, "watch"} | names,
480+
list_names,
481+
)
482+
483+
371484
def safe_tag(tag):
372485
return tag.replace("-", "_").replace(".", "_")
373486

@@ -550,9 +663,57 @@ def create_children(
550663
# Ignore comments
551664
continue
552665
if isinstance(child, TextElement):
553-
# children_args.extend(args_for_text_element(child, names=names))
554-
# continue
555-
raise NotImplementedError()
666+
# Create a text element fragment
667+
text_el = f"text{counter['text']}"
668+
counter["text"] += 1
669+
670+
# Create the TEXT_ELEMENT fragment
671+
result.append(
672+
ast.Assign(
673+
targets=[ast.Name(id=text_el, ctx=ast.Store())],
674+
value=ast.Call(
675+
func=ast.Name(id="Fragment", ctx=ast.Load()),
676+
args=[ast.Name(id="renderer", ctx=ast.Load())],
677+
keywords=[
678+
ast.keyword(
679+
arg="tag", value=ast.Constant(value="TEXT_ELEMENT")
680+
),
681+
ast.keyword(
682+
arg="parent",
683+
value=ast.Name(id=target, ctx=ast.Load())
684+
if target
685+
else ast.Constant(value=None),
686+
),
687+
],
688+
),
689+
)
690+
)
691+
692+
# Parse the text content for interpolations
693+
text_content = normalize_multiline_text(child.content)
694+
has_interpolation = "{{" in text_content and "}}" in text_content
695+
696+
if has_interpolation:
697+
parts = parse_text_interpolations(text_content)
698+
if any(is_expr for is_expr, _ in parts):
699+
result.append(
700+
ast_set_text_bind(
701+
text_el,
702+
parts,
703+
names,
704+
list_names,
705+
)
706+
)
707+
else:
708+
# Only escaped/unclosed interpolation-like text was found.
709+
result.append(
710+
ast_set_attribute(text_el, "content", text_content)
711+
)
712+
else:
713+
# Static text - set attribute directly
714+
result.append(ast_set_attribute(text_el, "content", text_content))
715+
716+
continue
556717

557718
# Create element name
558719
tag = safe_tag(child.tag)

tests/data/text/expressions.cgx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,14 @@
33
<p>Static content</p>
44
<p>Dynamic {{content}}</p>
55
<p>Even {{more}} dyna{}mic\{} {{content}}</p>
6+
<p>Quote "{{content}}" and slash \\ {{more}}</p>
7+
<p>Line "{{content}}"
8+
next {{more}}</p>
9+
<p>First line
10+
second line</p>
11+
<p>Complex "{{content}}"
12+
middle \{{literal}} {{more}}
13+
tail {{content}}/{{more}}</p>
614
<p>Split<!--by a comment--> and split by <span>an</span> element</p>
715
</div>
816
</template>

tests/test_text_expressions.py

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
1-
import pytest
21
from observ import reactive
32

43
from collagraph import Collagraph, DictRenderer, EventLoopType
54
from collagraph.renderers.dict_renderer import format_dict
65

76

8-
@pytest.mark.xfail
97
def test_text_elements():
108
from tests.data.text.expressions import Example
119

@@ -19,35 +17,81 @@ def test_text_elements():
1917

2018
assert result["type"] == "div"
2119

22-
static_p, dynamic_p, multiple_p, split_p = result["children"]
20+
(
21+
static_p,
22+
dynamic_p,
23+
multiple_p,
24+
quoted_p,
25+
multiline_p,
26+
static_multiline_p,
27+
complex_multiline_p,
28+
split_p,
29+
) = result["children"]
2330

2431
assert static_p["type"] == "p"
2532
assert dynamic_p["type"] == "p"
2633
assert multiple_p["type"] == "p"
34+
assert quoted_p["type"] == "p"
35+
assert multiline_p["type"] == "p"
36+
assert static_multiline_p["type"] == "p"
37+
assert complex_multiline_p["type"] == "p"
2738
assert split_p["type"] == "p"
2839

2940
assert (
30-
"children" in static_p and "children" in dynamic_p and "children" in multiple_p
41+
"children" in static_p
42+
and "children" in dynamic_p
43+
and "children" in multiple_p
44+
and "children" in quoted_p
45+
and "children" in multiline_p
46+
and "children" in static_multiline_p
47+
and "children" in complex_multiline_p
3148
), format_dict(container)
3249

3350
assert (
3451
len(static_p["children"])
3552
== len(dynamic_p["children"])
3653
== len(multiple_p["children"])
54+
== len(quoted_p["children"])
55+
== len(multiline_p["children"])
56+
== len(static_multiline_p["children"])
57+
== len(complex_multiline_p["children"])
3758
== 1
3859
), format_dict(container)
3960

40-
static, dynamic, multiple = (
61+
(
62+
static,
63+
dynamic,
64+
multiple,
65+
quoted,
66+
multiline,
67+
static_multiline,
68+
complex_multiline,
69+
) = (
4170
static_p["children"][0],
4271
dynamic_p["children"][0],
4372
multiple_p["children"][0],
73+
quoted_p["children"][0],
74+
multiline_p["children"][0],
75+
static_multiline_p["children"][0],
76+
complex_multiline_p["children"][0],
4477
)
4578
assert static["type"] == "TEXT_ELEMENT"
4679
assert dynamic["type"] == "TEXT_ELEMENT"
4780
assert multiple["type"] == "TEXT_ELEMENT"
81+
assert quoted["type"] == "TEXT_ELEMENT"
82+
assert multiline["type"] == "TEXT_ELEMENT"
83+
assert static_multiline["type"] == "TEXT_ELEMENT"
84+
assert complex_multiline["type"] == "TEXT_ELEMENT"
4885
assert static["attrs"]["content"] == "Static content"
4986
assert dynamic["attrs"]["content"] == "Dynamic foo"
5087
assert multiple["attrs"]["content"] == r"Even bar dyna{}mic\{} foo"
88+
assert quoted["attrs"]["content"] == 'Quote "foo" and slash \\\\ bar'
89+
assert multiline["attrs"]["content"] == 'Line "foo"\nnext bar'
90+
assert static_multiline["attrs"]["content"] == "First line\nsecond line"
91+
assert (
92+
complex_multiline["attrs"]["content"]
93+
== 'Complex "foo"\nmiddle {{literal}} bar\ntail foo/bar'
94+
)
5195

5296
assert len(split_p["children"]) == 4
5397
assert split_p["children"][0]["type"] == "TEXT_ELEMENT"

0 commit comments

Comments
 (0)