@@ -50,7 +50,9 @@ def _placholder_index(s: str) -> int:
5050 return int (s [_PP_LEN :])
5151
5252
53- def _instrument (strings : tuple [str , ...]) -> t .Iterable [str ]:
53+ def _instrument (
54+ strings : tuple [str , ...], callable_ids : tuple [int | None , ...]
55+ ) -> t .Iterable [str ]:
5456 """
5557 Join the strings with placeholders in between where interpolations go.
5658
@@ -62,27 +64,62 @@ def _instrument(strings: tuple[str, ...]) -> t.Iterable[str]:
6264 """
6365 count = len (strings )
6466
65- # TODO: special case callables() so that we use the same placeholder
66- # to open *and* close tags.
67+ callable_placeholders : dict [int , str ] = {}
6768
6869 for i , s in enumerate (strings ):
6970 yield s
7071 # There are always count-1 placeholders between count strings.
7172 if i < count - 1 :
72- yield _placeholder (i )
73+ # Special case for component callables: if the interpolation
74+ # is a callable, we need to make sure that any matching closing
75+ # tag uses the same placeholder.
76+ callable_id = callable_ids [i ]
77+ if callable_id is not None :
78+ # This interpolation is a callable, so we need to make sure
79+ # that any matching closing tag uses the same placeholder.
80+ if callable_id not in callable_placeholders :
81+ callable_placeholders [callable_id ] = _placeholder (i )
82+ yield callable_placeholders [callable_id ]
83+ else :
84+ yield _placeholder (i )
7385
7486
7587@lru_cache ()
76- def _instrument_and_parse (strings : tuple [str , ...]) -> Node :
88+ def _instrument_and_parse_internal (
89+ strings : tuple [str , ...], callable_ids : tuple [int | None , ...]
90+ ) -> Node :
7791 """
7892 Instrument the strings and parse the resulting HTML.
7993
8094 The result is cached to avoid re-parsing the same template multiple times.
8195 """
82- instrumented = _instrument (strings )
96+ instrumented = _instrument (strings , callable_ids )
8397 return parse_html_iter (instrumented )
8498
8599
100+ def _callable_id (value : object ) -> int | None :
101+ """Return a unique identifier for a callable, or None if not callable."""
102+ return id (value ) if callable (value ) else None
103+
104+
105+ def _instrument_and_parse (template : Template ) -> Node :
106+ """Instrument and parse a template, returning a tree of Nodes."""
107+ # This is a thin wrapper around the cached internal function that does the
108+ # actual work. This exists to handle the syntax we've settled on for
109+ # component invocation, namely that callables are directly included as
110+ # interpolations both in the open *and* the close tags. We need to make
111+ # sure that matching tags... match!
112+ #
113+ # If we used `tdom`'s approach of component closing tags of <//> then we
114+ # wouldn't have to do this. But I worry that tdom's syntax is harder to read
115+ # (it's easy to miss the closing tag) and may prove unfamiliar for
116+ # users coming from other templating systems.
117+ callable_ids = tuple (
118+ _callable_id (interpolation .value ) for interpolation in template .interpolations
119+ )
120+ return _instrument_and_parse_internal (template .strings , callable_ids )
121+
122+
86123# --------------------------------------------------------------------------
87124# Placeholder Substitution
88125# --------------------------------------------------------------------------
@@ -181,38 +218,13 @@ def _substitute_attr(
181218 # General handling for all other attributes:
182219 match value :
183220 case str ():
184- yield (key , str ( value ) )
221+ yield (key , value )
185222 case True :
186223 yield (key , None )
187224 case False | None :
188225 pass
189- case dict () as d :
190- for sub_k , sub_v in d .items ():
191- if sub_v is True :
192- yield sub_k , None
193- elif sub_v not in (False , None ):
194- yield sub_k , str (sub_v )
195- case Iterable () as it :
196- for item in it :
197- match item :
198- case tuple () if len (item ) == 2 :
199- sub_k , sub_v = item
200- if sub_v is True :
201- yield sub_k , None
202- elif sub_v not in (False , None ):
203- yield sub_k , str (sub_v )
204- case str () | Markup ():
205- yield str (item ), None
206- case _:
207- raise TypeError (
208- f"Cannot use { type (item ).__name__ } as attribute "
209- f"key-value pair in iterable for attribute '{ key } '"
210- )
211226 case _:
212- raise TypeError (
213- f"Cannot use { type (value ).__name__ } as attribute value for "
214- f"attribute '{ key } '"
215- )
227+ yield (key , str (value ))
216228
217229
218230def _substitute_attrs (
@@ -279,6 +291,36 @@ def _node_from_value(value: object) -> Node:
279291 return Text (str (value ))
280292
281293
294+ def _invoke_component (
295+ tag : str ,
296+ new_attrs : dict [str , str | None ],
297+ new_children : list [Node ],
298+ interpolations : tuple [Interpolation , ...],
299+ ) -> Node :
300+ """Substitute a component invocation based on the corresponding interpolations."""
301+ index = _placholder_index (tag )
302+ interpolation = interpolations [index ]
303+ value = format_interpolation (interpolation )
304+ if not callable (value ):
305+ raise TypeError (
306+ f"Expected a callable for component invocation, got { type (value ).__name__ } "
307+ )
308+ # Call the component and return the resulting node
309+ result = value (* new_children , ** new_attrs )
310+ match result :
311+ case Node ():
312+ return result
313+ case Template ():
314+ return html (result )
315+ case HasHTMLDunder () | str ():
316+ return Text (result )
317+ case _:
318+ raise TypeError (
319+ f"Component callable must return a Node, Template, str, or "
320+ f"HasHTMLDunder, got { type (result ).__name__ } "
321+ )
322+
323+
282324def _substitute_node (p_node : Node , interpolations : tuple [Interpolation , ...]) -> Node :
283325 """Substitute placeholders in a node based on the corresponding interpolations."""
284326 match p_node :
@@ -290,7 +332,10 @@ def _substitute_node(p_node: Node, interpolations: tuple[Interpolation, ...]) ->
290332 case Element (tag = tag , attrs = attrs , children = children ):
291333 new_attrs = _substitute_attrs (attrs , interpolations )
292334 new_children = _substitute_and_flatten_children (children , interpolations )
293- return Element (tag = tag , attrs = new_attrs , children = new_children )
335+ if tag .startswith (_PLACEHOLDER_PREFIX ):
336+ return _invoke_component (tag , new_attrs , new_children , interpolations )
337+ else :
338+ return Element (tag = tag , attrs = new_attrs , children = new_children )
294339 case Fragment (children = children ):
295340 new_children = _substitute_and_flatten_children (children , interpolations )
296341 return Fragment (children = new_children )
@@ -307,5 +352,5 @@ def html(template: Template) -> Node:
307352 """Parse a t-string and return a tree of Nodes."""
308353 # Parse the HTML, returning a tree of nodes with placeholders
309354 # where interpolations go.
310- p_node = _instrument_and_parse (template . strings )
355+ p_node = _instrument_and_parse (template )
311356 return _substitute_node (p_node , template .interpolations )
0 commit comments