From e7eb2cd7109b1230254b67d87c735571b2f549fc Mon Sep 17 00:00:00 2001 From: Larry Ogrodnek Date: Tue, 3 Sep 2024 20:20:52 -0400 Subject: [PATCH] initial template include support (#38) --- examples/app.py | 8 ++ examples/views/__init__.py | 2 + examples/views/includes/__init__.py | 1 + examples/views/includes/_logo.svg | 22 +++++ examples/views/includes/_navbar.html | 19 +++++ examples/views/includes/includes.css | 99 ++++++++++++++++++++++ examples/views/includes/includes.html | 22 +++++ examples/views/includes/includes.py | 34 ++++++++ pyproject.toml | 2 +- pyview/vendor/ibis/nodes.py | 19 ++++- tests/vendor/ibis/test_template_include.py | 22 +++++ 11 files changed, 246 insertions(+), 4 deletions(-) create mode 100644 examples/views/includes/__init__.py create mode 100644 examples/views/includes/_logo.svg create mode 100644 examples/views/includes/_navbar.html create mode 100644 examples/views/includes/includes.css create mode 100644 examples/views/includes/includes.html create mode 100644 examples/views/includes/includes.py create mode 100644 tests/vendor/ibis/test_template_include.py diff --git a/examples/app.py b/examples/app.py index 8a1013b..6aeb43f 100644 --- a/examples/app.py +++ b/examples/app.py @@ -2,8 +2,11 @@ from starlette.staticfiles import StaticFiles from starlette.routing import Route from pyview import PyView, defaultRootTemplate +from pyview.vendor import ibis +from pyview.vendor.ibis.loaders import FileReloader from markupsafe import Markup from .format_examples import ExampleEntry, format_examples +import os from .views import ( CountLiveView, @@ -21,6 +24,7 @@ MapLiveView, FileUploadDemoLiveView, KanbanLiveView, + IncludesLiveView, ) app = PyView() @@ -62,6 +66,9 @@ def content_wrapper(_context, content: Markup) -> Markup: app.rootTemplate = defaultRootTemplate(css=Markup(css), content_wrapper=content_wrapper) +current_file_dir = os.path.dirname(os.path.abspath(__file__)) +ibis.loader = FileReloader(os.path.join(current_file_dir, "views")) + routes = [ ("/count", CountLiveView), ("/count_pubsub", CountLiveViewPubSub), @@ -86,6 +93,7 @@ def content_wrapper(_context, content: Markup) -> Markup: ("/maps", MapLiveView), ("/file_upload", FileUploadDemoLiveView), ("/kanban", KanbanLiveView), + ("/includes", IncludesLiveView), ] diff --git a/examples/views/__init__.py b/examples/views/__init__.py index 2181521..3d9bb86 100644 --- a/examples/views/__init__.py +++ b/examples/views/__init__.py @@ -13,6 +13,7 @@ from .kanban import KanbanLiveView from .count_pubsub import CountLiveViewPubSub from .count import CountLiveView +from .includes import IncludesLiveView __all__ = [ "CountLiveView", @@ -30,4 +31,5 @@ "MapLiveView", "FileUploadDemoLiveView", "KanbanLiveView", + "IncludesLiveView", ] diff --git a/examples/views/includes/__init__.py b/examples/views/includes/__init__.py new file mode 100644 index 0000000..9926388 --- /dev/null +++ b/examples/views/includes/__init__.py @@ -0,0 +1 @@ +from .includes import IncludesLiveView diff --git a/examples/views/includes/_logo.svg b/examples/views/includes/_logo.svg new file mode 100644 index 0000000..b53427f --- /dev/null +++ b/examples/views/includes/_logo.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/examples/views/includes/_navbar.html b/examples/views/includes/_navbar.html new file mode 100644 index 0000000..d584f3a --- /dev/null +++ b/examples/views/includes/_navbar.html @@ -0,0 +1,19 @@ + \ No newline at end of file diff --git a/examples/views/includes/includes.css b/examples/views/includes/includes.css new file mode 100644 index 0000000..256db3b --- /dev/null +++ b/examples/views/includes/includes.css @@ -0,0 +1,99 @@ +body > a:first-of-type { + display: none; +} + +.navbar { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 20px; + background-color: #f0f0f0; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +.navbar-left { + display: flex; + align-items: center; +} + + +.company-name { + font-size: 18px; + font-weight: bold; + margin-left: 10px; + color: #333; +} + +.navbar-center { + display: flex; + align-items: center; + gap: 20px; +} + +.navbar-center a { + text-decoration: none; + color: #333; + font-size: 18px; + padding: 5px 10px; + border-radius: 4px; + text-transform: capitalize; + transition: background-color 0.3s ease, color 0.3s ease; +} + +.navbar-center a:hover { + background-color: #f1f1f1; +} + +.navbar-center a[aria-current="page"] { + background-color: #007BFF; + color: #ffffff; + font-weight: bold; + pointer-events: none; +} + +.navbar-right { + display: flex; + align-items: center; +} + +.user-profile { + display: flex; + align-items: center; + cursor: pointer; +} + +.avatar { + width: 40px; + height: 40px; + border-radius: 50%; + border: 2px solid #ccc; +} + +.content { + padding: 20px; + max-width: 900px; + margin: 20px auto; + background-color: #f0f0f0; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + border-radius: 8px; +} + +.content h1 { + margin-bottom: 20px; + font-size: 32px; + text-transform: capitalize; +} + +.content p { + margin-bottom: 10px; +} + +code { + background-color: #f5f7fa; + color: #2e3a59; + padding: 1em; + border-radius: 8px; + border: 1px solid #d1d5db; + margin: 1em 0; + display: block; +} diff --git a/examples/views/includes/includes.html b/examples/views/includes/includes.html new file mode 100644 index 0000000..edd2d79 --- /dev/null +++ b/examples/views/includes/includes.html @@ -0,0 +1,22 @@ +{% include "includes/_navbar.html" with avatar_url=user.avatar_url %} + +
+

{{current_page}}

+

+ This is a simple example of how to re-use template code in PyView. +

+

+ This page includes a navbar that is defined in a separate file, and passes the user's avatar as a parameter: +

+ + {{ '{% include "includes/_navbar.html" with avatar_url=user.avatar_url %}' }} + +

+ Templates do have access to the parent template context, but it can be useful to have template parameters + (e.g. using a template in a for loop and passing each element as a parameter). +

+

+ You read more about the include tag + feature in the template engine documentation. +

+
\ No newline at end of file diff --git a/examples/views/includes/includes.py b/examples/views/includes/includes.py new file mode 100644 index 0000000..6b218bc --- /dev/null +++ b/examples/views/includes/includes.py @@ -0,0 +1,34 @@ +from pyview import LiveView +from dataclasses import dataclass, field +import random + + +@dataclass +class User: + user_id: int = field(default_factory=lambda: random.randint(1, 100)) + + @property + def avatar_url(self): + return f"https://avatar.iran.liara.run/public/{self.user_id}" + + +@dataclass +class IncludesContext: + user: User = field(default_factory=User) + pages: list[str] = field(default_factory=lambda: ["home", "about", "contact"]) + current_page: str = "home" + + +class IncludesLiveView(LiveView): + """ + Template Includes + + This example shows how to include templates in other templates. + """ + + async def mount(self, socket, session): + socket.context = IncludesContext() + + async def handle_params(self, url, params, socket): + if "page" in params: + socket.context.current_page = params["page"][0] diff --git a/pyproject.toml b/pyproject.toml index ab3cfb3..672d604 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ packages = [ { include = "pyview" }, ] -version = "0.0.21" +version = "0.0.22" description = "LiveView in Python" authors = ["Larry Ogrodnek "] license = "MIT" diff --git a/pyview/vendor/ibis/nodes.py b/pyview/vendor/ibis/nodes.py index dcfd439..b3bd92a 100644 --- a/pyview/vendor/ibis/nodes.py +++ b/pyview/vendor/ibis/nodes.py @@ -203,6 +203,8 @@ def tree_parts(self, context) -> PartsTree: resp.add_static(child.token.text if child.token else "") elif isinstance(child, PrintNode): resp.add_dynamic(child.wrender(context)) + elif isinstance(child, IncludeNode): + resp.add_dynamic(child.tree_parts(context)) else: resp.add_dynamic(child.tree_parts(context)) @@ -624,7 +626,7 @@ def process_token(self, token): else: raise errors.TemplateSyntaxError("Malformed 'include' tag.", token) - def wrender(self, context): + def visit_node(self, context, visitor: NodeVisitor): template_name = self.template_expr.eval(context) if isinstance(template_name, str): if ibis.loader: @@ -632,9 +634,8 @@ def wrender(self, context): context.push() for name, expr in self.variables.items(): context[name] = expr.eval(context) - rendered = template.root_node.render(context) + visitor(context, template.root_node) context.pop() - return rendered else: msg = f"No template loader has been specified. " msg += f"A template loader is required by the 'include' tag in " @@ -645,6 +646,18 @@ def wrender(self, context): msg += f"The variable '{self.template_arg}' should evaluate to a string. " msg += f"This variable has the value: {repr(template_name)}." raise errors.TemplateRenderingError(msg, self.token) + + def wrender(self, context): + output = [] + self.visit_node(context, lambda ctx, node: output.append(node.render(ctx))) + return "".join(output) + + def tree_parts(self, context) -> PartsTree: + output = [] + def visitor(ctx, node): + output.append(node.tree_parts(ctx)) + self.visit_node(context, visitor) + return output[0] # ExtendsNodes implement template inheritance. They indicate that the current template inherits diff --git a/tests/vendor/ibis/test_template_include.py b/tests/vendor/ibis/test_template_include.py new file mode 100644 index 0000000..06f8b31 --- /dev/null +++ b/tests/vendor/ibis/test_template_include.py @@ -0,0 +1,22 @@ +from pyview.vendor.ibis import Template +import pyview.vendor.ibis as ibis +from pyview.vendor.ibis.loaders import DictLoader +import pytest + +@pytest.fixture +def template_loader(): + ibis.loader = DictLoader( + { + "header.html": "

{{gr}}, World!

", + } + ) + +def test_template_include(template_loader): + a = Template("
{% include 'header.html' with gr=greeting %}
") + d = {"greeting": "Hello"} + + assert a.tree(d) == { + "s": ["
", "
"], + "0": {"s": ["

", ", World!

"], "0": "Hello"}, + } + assert a.render(d) == "

Hello, World!

" \ No newline at end of file