Simple HTML Generation https://minihtml.trendels.name/

make template rendering lazy

+81 -44
+2 -1
src/minihtml/__init__.py
··· 15 15 safe, 16 16 text, 17 17 ) 18 - from ._template import component_scripts, component_styles, template 18 + from ._template import Template, component_scripts, component_styles, template 19 19 20 20 __all__ = [ 21 21 "CircularReferenceError", ··· 31 31 "PrototypeNonEmpty", 32 32 "SlotContext", 33 33 "Slots", 34 + "Template", 34 35 "Text", 35 36 "component", 36 37 "component_scripts",
+52 -33
src/minihtml/_template.py
··· 13 13 TemplateImplLayout: TypeAlias = Callable[Concatenate[Component, P], None] 14 14 15 15 16 - def _render(result: Node | HasNodes, doctype: bool) -> str: 17 - buf = io.StringIO() 18 - if doctype: 19 - buf.write("<!doctype html>\n") 20 - Node.render_list(buf, iter_nodes([result])) 21 - buf.write("\n") 22 - return buf.getvalue() 16 + class Template: 17 + def __init__(self, callback: Callable[[], list[Node]]): 18 + self._callback = callback 19 + 20 + def render(self, *, doctype: bool = True) -> str: 21 + nodes = self._callback() 22 + buf = io.StringIO() 23 + if doctype: 24 + buf.write("<!doctype html>\n") 25 + Node.render_list(buf, nodes) 26 + buf.write("\n") 27 + return buf.getvalue() 23 28 24 29 25 30 @overload 26 - def template( 27 - *, doctype: bool = ... 28 - ) -> Callable[[TemplateImpl[P]], Callable[P, str]]: ... 31 + def template() -> Callable[[TemplateImpl[P]], Callable[P, Template]]: ... 29 32 30 33 31 34 @overload 32 35 def template( 33 - layout: ComponentWrapper[...], *, doctype: bool = ... 34 - ) -> Callable[[TemplateImplLayout[P]], Callable[P, str]]: ... 36 + *, layout: ComponentWrapper[...] 37 + ) -> Callable[[TemplateImplLayout[P]], Callable[P, Template]]: ... 35 38 36 39 37 40 def template( 38 - layout: ComponentWrapper[...] | None = None, 39 - *, 40 - doctype: bool = True, 41 + *, layout: ComponentWrapper[...] | None = None 41 42 ) -> ( 42 - Callable[[TemplateImpl[P]], Callable[P, str]] 43 - | Callable[[TemplateImplLayout[P]], Callable[P, str]] 43 + Callable[[TemplateImpl[P]], Callable[P, Template]] 44 + | Callable[[TemplateImplLayout[P]], Callable[P, Template]] 44 45 ): 45 46 if layout is None: 46 47 47 - def plain_decorator(fn: TemplateImpl[P]) -> Callable[P, str]: 48 + def plain_decorator(fn: TemplateImpl[P]) -> Callable[P, Template]: 48 49 @wraps(fn) 49 - def wrapper(*args: P.args, **kwargs: P.kwargs) -> str: 50 - with template_context(): 51 - result = fn(*args, **kwargs) 52 - return _render(result, doctype=doctype) 50 + def wrapper(*args: P.args, **kwargs: P.kwargs) -> Template: 51 + def callback() -> list[Node]: 52 + with template_context(): 53 + result = fn(*args, **kwargs) 54 + return list(iter_nodes([result])) 55 + 56 + return Template(callback) 53 57 54 58 return wrapper 55 59 ··· 57 61 58 62 else: 59 63 60 - def layout_decorator(fn: TemplateImplLayout[P]) -> Callable[P, str]: 64 + def layout_decorator(fn: TemplateImplLayout[P]) -> Callable[P, Template]: 61 65 @wraps(fn) 62 - def wrapper(*args: P.args, **kwargs: P.kwargs) -> str: 63 - with template_context(): 64 - with layout() as result: 65 - fn(result, *args, **kwargs) 66 - return _render(result, doctype=doctype) 66 + def wrapper(*args: P.args, **kwargs: P.kwargs) -> Template: 67 + def callback() -> list[Node]: 68 + with template_context(): 69 + with layout() as result: 70 + fn(result, *args, **kwargs) 71 + return list(iter_nodes([result])) 72 + 73 + return Template(callback) 67 74 68 75 return wrapper 69 76 ··· 71 78 72 79 73 80 class ResourceWrapper(Node): 74 - def __init__(self, callback: Callable[[], Iterable[Node]]): 75 - self._callback = callback 81 + def __init__(self, nodes: Iterable[Node]): 82 + self._nodes = nodes 76 83 self._inline = False 77 84 78 85 def write(self, f: TextIO, indent: int = 0) -> None: 79 - nodes = list(self._callback()) 86 + nodes = list(self._nodes) 80 87 n = len(nodes) 81 88 for i, node in enumerate(nodes): 82 89 node.write(f, indent) ··· 86 93 87 94 88 95 def component_styles() -> ResourceWrapper: 89 - wrapper = ResourceWrapper(lambda: get_template_context().styles) 96 + """ 97 + Placeholder element for component styles. 98 + 99 + Can only be used in code called from a :deco:`template`. Inserts the style 100 + nodes collected from all components used in the current template. 101 + """ 102 + wrapper = ResourceWrapper(get_template_context().styles) 90 103 register_with_context(wrapper) 91 104 return wrapper 92 105 93 106 94 107 def component_scripts() -> ResourceWrapper: 95 - wrapper = ResourceWrapper(lambda: get_template_context().scripts) 108 + """ 109 + Placeholder element for component scripts. 110 + 111 + Can only be used in code called from a :deco:`template`. Inserts the script 112 + nodes collected from all components used in the current template. 113 + """ 114 + wrapper = ResourceWrapper(get_template_context().scripts) 96 115 register_with_context(wrapper) 97 116 return wrapper
+27 -10
tests/test_template.py
··· 2 2 3 3 from minihtml import ( 4 4 Component, 5 + Context, 5 6 Element, 6 7 Slots, 7 8 component, ··· 13 14 from minihtml.tags import body, div, head, html, main, script, style, title 14 15 15 16 16 - def test_template_returns_html_with_doctype_and_trailing_newline(): 17 + def test_template_renders_as_html_with_doctype_and_trailing_newline(): 17 18 @template() 18 19 def my_template(message: str) -> Element: 19 20 return div(message) 20 21 21 - assert my_template("hello") == dedent("""\ 22 + assert my_template("hello").render() == dedent("""\ 22 23 <!doctype html> 23 24 <div>hello</div> 24 25 """) 25 26 26 27 27 28 def test_template_can_disable_doctype(): 28 - @template(doctype=False) 29 + @template() 29 30 def my_template(message: str) -> Element: 30 31 return div(message) 31 32 32 - assert my_template("hello") == dedent("""\ 33 - <div>hello</div> 34 - """) 33 + assert my_template("hello").render(doctype=False) == "<div>hello</div>\n" 35 34 36 35 37 36 def test_template_with_layout_component(): ··· 52 51 text("my title") 53 52 div(message) 54 53 55 - assert my_template("hello") == dedent("""\ 54 + assert my_template("hello").render() == dedent("""\ 56 55 <!doctype html> 57 56 <html> 58 57 <head> ··· 67 66 """) 68 67 69 68 # Test that layout is not cached 70 - assert my_template("goodbye") == dedent("""\ 69 + assert my_template("goodbye").render() == dedent("""\ 71 70 <!doctype html> 72 71 <html> 73 72 <head> ··· 113 112 my_component() 114 113 my_component() 115 114 116 - assert my_template() == dedent("""\ 115 + assert my_template().render() == dedent("""\ 117 116 <!doctype html> 118 117 <html> 119 118 <head> ··· 157 156 ), 158 157 ) 159 158 160 - assert my_template() == dedent("""\ 159 + assert my_template().render() == dedent("""\ 161 160 <!doctype html> 162 161 <html> 163 162 <head> ··· 171 170 </body> 172 171 </html> 173 172 """) 173 + 174 + 175 + def test_template_is_rendered_lazily(): 176 + class MyContext(Context): 177 + def __init__(self, name: str): 178 + self.name = name 179 + 180 + @template() 181 + def my_template() -> Element: 182 + return div(MyContext.get().name) 183 + 184 + t = my_template() 185 + 186 + with MyContext(name="fred"): 187 + assert t.render(doctype=False) == "<div>fred</div>\n" 188 + 189 + with MyContext(name="barney"): 190 + assert t.render(doctype=False) == "<div>barney</div>\n"