Roundup Tracker

Some random thoughts on using Roundup to serve html fragments suitable for use with htmx or htmz.

By default Roundup templates return entire pages. The icing macro in page.html produces the left hand menu, icon etc. The icing macro has slots for inline css, content (the main body of the page e.g. index table) etc. When we go to a new link, we send all of that css and other icing to the front end. Libraries/hacks like htmx and htmz allow the developer to replace parts of the page with new content preserving the existing css, menu, icon etc.

Suppose we wanted to get just the table markup when we choose to see the next index page. One way to do this is to use htmx or htmz on the front end to target the content slot in the web browser. But what has to happen on the backend to get Roundup to generate only the 'content' section without the rest of the icing/css/head etc.

Page.html in the html directory starts with:

<!-- vim:sw=2 sts=2
--><tal:block metal:define-macro="icing"
><!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/s\
trict.dtd">
<html>
<head>
<title metal:define-slot="head_title">title goes here</title>

Suppose we change this to read:

<!-- vim:sw=2 sts=2
--><tal:block metal:define-macro="icing"
    tal:define="make_fragment
                python:not request.form.getvalue('@fragment','FULLPAGE') == 'FULLPAGE'"
><!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
<html  tal:condition="not:make_fragment">
<head>
<title metal:define-slot="head_title">title goes here</title>

so a url like: http://tracker.example.com/demo/issue?@fragment=1 would not return anything in the <html> block. Note setting @fragment=FULLPAGE or omitting @fragment will result in a high calorie page with all the icing. So what do we use when we want a low calorie fragment?

After the closing </html> tag in page.html we can add:

</html>  <!-- closes the opening <html tal:condition.... --->
<body tal:condition="make_fragment">
  <tal:span tal:define="global trival_bool python:False" />
  <tal:span tal:define="global InBatchUpdate python:False" />

  <tal:block metal:define-slot="content">Page content goes here</tal:block>
</body>
</tal:block>  <!-- closes tal:block defining the icing macro -->

This produces an html fragment that can be used to replace the content slot in the original index page.

I am not sure how this operates in the presence of a content security policy. My guess is that scripts and css fetched via the fragment route would not work.

This mis an interesting idea for somebody to explore to speed up the front end and keep all the business logic, state, and templating in the back end. Otherwise getting a more modern experience would need to use the REST interface and a heavier framework like React/Preact/Vue etc.

Rather than passing @fragment, you can look at the value of the Sec-Fetch-Dest html header. It will have the value document when the request comes from the top level window or the value iframe when the request is made from an iframe. You can have the Sec-Fetch-Dest header passed in when using roundup-server by adding -I Sec-Fetch-Dest to the roundup-server command line. I am not sure if that header is passed in when using wsgi or cgi invocation. If it is passed in as a CGI env variable it is probably passed as HTTP_SEC_FETCH_DEST.

Also if you are using a Content Security Policy (CSP) as described in the Roundup admin guide, the example htmz addition won't work as it uses the onload iframe attribute. These are usually disabled by a CSP. You can use the following snippet in your TAL instead:

<iframe hidden name=htmz id="htmz"></iframe>
<script tal:attributes="nonce request/client/client_nonce"
        type='text/javascript'>
    htmzFrame = document.querySelector("#htmz[hidden]");
    htmzFrame.addEventListener('load', () => htmz(htmzFrame));

    function htmz(frame) {
        // Write your extensions here

        // The browser automatically scrolls DOM changes into view in
        // response to user interaction.
        // This delay prevents that, making this is the only case where we
        // don't lean on browser defaults.
        // Remove setTimeout wrapper to allow automatic scrolling.
        setTimeout(() =>
                   document
                   .querySelector(frame.contentWindow.location.hash || null)
                   ?.replaceWith(...frame.contentDocument.body.childNodes)
                  );
    }
</script>

(Note the iframe needs to be placed before the script that looks for it.)

Then you can add a link and a result div like:

<a href="https://tracker.example.net/demo/issue?...#reply" target="htmz">trigger text</a>
<div id="reply">
</div>

and use the following definition for make_fragment that supports either method. The @fragment method is useful for testing in the browser.

    tal:define="make_fragment
                python:(not request.form.getvalue('@fragment', 'FULLPAGE') == 'FULLPAGE') or
                       (request.client.env.get('SEC-FETCH-DEST') == 'iframe');"

where SEC-FETCH-DEST may have to be HTTP_SEC_FETCH_DEST if you are running Roundup via CGI.

I am not sure if we need to pass and check the SEC-FETCH-SITE header and respond with an error if the value isn't same-origin. I claim all the regular CSRF checks are done when requesting a fragment, but this needs to be verified.


CategoryDevelopment