mitsuhiko / minijinja

MiniJinja is a powerful but minimal dependency template engine for Rust compatible with Jinja/Jinja2
https://docs.rs/minijinja/
Apache License 2.0
1.67k stars 101 forks source link

Feature: Render Block fragment #260

Closed wrapperup closed 1 year ago

wrapperup commented 1 year ago

In essence, this feature would allow you to render only a block from a template. This is great in the context of htmx where you typically want to just re-render a small section of html to send down the wire

(See https://htmx.org/essays/template-fragments/ and https://github.com/sponsfreixes/jinja2-fragments).

Example:

{% extends "base.html" %}

{% block title %}Home{% endblock %}

{% block content %}
    <div class="stuff">
        {% block myblock %} <!-- This is a fragment I want to render! -->
            {% for i in range(5) %}
                <p>{{ i }}</p>
            {% endfor %}
        {% endblock %}
    </div>
{% endblock %}

If I ran template.render_block("myblock", &ctx), I would get

<p>0</p>
<p>1</p>
<p>2</p>
<p>3</p>
<p>4</p>

My current implementation of this feature adds a render_block method, but it's quite a bodge since it uses the pre-compiled blocks already part of the template. They are also missing all the necessary block instructions, so things like super() don't work.

Before I make a PR with some of the proposed changes below, I have some questions about the design of it:

  1. To fix super(), compiling block fragments would need to be supported at the compiler level (and/or right above it in Source). Assuming I just want to render the block with all the necessary instructions, what instructions could sensibly be stripped? EmitRaw seems like an obvious one, but I'm not super familiar with it all.
  2. Instead of using this render_block bodge method, I think it may be a better idea for Source to use some kind of syntax like template.html#block (suggested by the article) and just have users render normally. This way, it is supported by default out of the box without any additional API on templates, and Source can deal with compiling and caching the block fragments. What do you think?

I'd love to hear your thoughts. In any case, thanks for the great library!

mitsuhiko commented 1 year ago

In some sense what you want to accomplish is already supported internally. When a template extends another one, it basically needs to find all the blocks. I did not think too much about the details yet, but it should be doable with the current semantics of the language. I did not think this would require any special code other than maybe rethinking how RenderParent works.

Today if {% extends %} appears anywhere in the template, then the code generator emits a RenderParent at the very end which loads the parent instructions and resets the pc to 0. In some sense this could be implied runtime behavior (basically it means that parent_instructions was loaded). With that change and maybe an extra parameter to the vm it should be possible to eval toplevel with CaptureMode::Discard. Then once it's done you have all the blocks loaded as they should be in which case you just trigger eval as Instruction::CallBlock would do.

This would definitely require some minor refactoring, but it should not require you to do anything fancy that the engine doesn't already have to do in one form or another.

Instead of using this render_block bodge method, I think it may be a better idea for Source to use some kind of syntax like template.html#block (suggested by the article) and just have users render normally. This way, it is supported by default out of the box without any additional API on templates, and Source can deal with compiling and caching the block fragments. What do you think?

I think I would prefer an explicit render_block method. In Jinja2 the blocks are exposed on the template objects under .blocks and they can be invoked from there which is how I assume the linked Python thing works.

rgwood commented 1 year ago

This is really useful with htmx. Thank you for implementing it!

mitsuhiko commented 1 year ago

Note that I changed the interface for block rendering significantly on main for MiniJinja 1.0. I will try to get a beta out before the weekend so that folks can give feedback on the new interface.

rgwood commented 1 year ago

I gave v1.0.0-alpha.2 a try, and my usage of blocks went from:

let rendered = template.render_block(block, context)?;

To:

let rendered = template.eval_to_state(context)?.render_block(block)?;

Works for me. I have no strong feelings about the change as a user; it's not a problem for my usage of blocks.

rgwood commented 1 year ago

I have noticed one ergonomic issue with blocks (unrelated to v1.0). Say I have a template like this:

{{ foo.bar }}

{% block baz_block %}
    {{baz}}
{% endblock %}

And I attempt to render only baz_block:

let rendered = tmpl.eval_to_state(context!(baz => "baz"))?.render_block("baz_block")?;

That blows up with the following error:

----------------------------------- foo.txt -----------------------------------
   1 > {{ foo.bar }}
     i    ^^^^^^^ undefined value
   2 | 
   3 | {% block baz_block %}
   4 |     {{baz}}
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
No referenced variables
-------------------------------------------------------------------------------

Ideally this would succeed, because I only want to render baz_block which does not use foo.bar. For now I'm working around this by adding safeguards like {% if foo is defined %}.

I can raise an issue if you think this is a legitimate problem.

wrapperup commented 1 year ago

Currently it works by evaluating the entire template with your context before rendering out block, so things like inheritance work correctly. It might be possible to fix, I'm not sure how important it is to know if an expression will fail during the eval-only stage (if there's some possible side-effects there I'm not aware of, or if it can even be skipped), and just pass that onto the actual render eval.

There is also the less ideal temporary solution of using Environment::set_undefined_behavior to suppress these errors.

Feel free to make a new issue for it!