Template system

Table of contents

  1. Introduction
  2. Passing variables to every template
  3. Registering a hook
  4. Drawing a slot in a template
  5. Opening a slot for other features
  6. Replacing a base feature’s hook
  7. Product layout hooks
  8. Loading a feature’s scripts
  9. Showing stored HTML
    1. Where the filter goes
    2. What the filter keeps
    3. Embedding players
    4. Showing a stored address
  10. Using the page script
    1. Asking before a form is sent
    2. Copying to the clipboard
    3. Sending a form from a field
    4. Showing a password
    5. Calling the site from a script
    6. Showing a notice
    7. Marking a button at work
  11. Reference
    1. Variables available in every template
    2. Template hook functions
  12. See also

Introduction

SPLENT templates are Flask’s Jinja templates with two additions. A feature can put variables in every template, and a feature can add HTML to a named slot of a layout without editing the layout. That is how features you add or remove change the shared pages with no template edit.

from splent_framework.hooks.template_hooks import register_template_hook


def terms_link():
    return '<a href="/terms">Terms of use</a>'


def init_feature(app):
    register_template_hook("layout.footer", terms_link)

Every page of the product now shows the link in its footer. Remove the feature and the link goes with it.

How context variables and template hooks reach a template CONTEXT VARIABLES Each feature inject_context_vars(app) returns a dict JinjaManager one context processor, base context, then features Every template gets the merged variables, on every render TEMPLATE HOOKS notes notes_scripts, order 100 reports reports_navbar, order 100 Hook registry layout.scripts notes_scripts layout.navbar.anonymous reports_navbar A layout get_template_hooks the callbacks, by order render_template_hooks their joined output
Features return context variables and register hook callbacks while they load. Every render merges the variables, and a layout calls the callbacks of each slot it names.

Passing variables to every template

def inject_context_vars(app):
    return {"notes_enabled": True, "notes_page_size": 20}

Define inject_context_vars(app) in your feature’s __init__.py and the dict reaches every template of the product, not only your feature’s. No render_template call has to pass it.

{% if notes_enabled %}
  <a href="{{ url_for('notes.index') }}">Notes</a>
{% endif %}

When two features return the same key, the one that loads later wins, so prefix your keys with the feature’s name. A function that raises, or returns something that is not a dict, is logged and skipped, and the page still renders.


Registering a hook

from splent_framework.hooks.template_hooks import register_template_hook


def notes_navbar_button():
    return '<a class="btn btn-light" href="/notes/new">New note</a>'


def init_feature(app):
    register_template_hook("layout.navbar.items", notes_navbar_button, order=50)

A hook is a function with no arguments that returns HTML. Register it from init_feature or from your feature’s hooks.py. Lower order draws first, 100 by default, and equal orders keep the order features load in.

Register hooks only while the app starts. The registry is shared by the whole process and is not safe to change while requests are served.


Drawing a slot in a template

{% for hook in get_template_hooks("layout.footer") %}
  {{ hook() | safe }}
{% endfor %}

get_template_hooks(name) gives you the slot’s functions in order. To keep markup of your own for an empty slot, use render_template_hooks(name), which returns their joined output, empty when no hook drew anything.

{% set extra = render_template_hooks("notes.sidebar") %}
{% if extra %}{{ extra }}{% else %}<p>{{ _('Nothing here yet.') }}</p>{% endif %}

Opening a slot for other features

Any feature can open slots in its own templates, so optional features extend it without either one knowing the other. Name a slot <feature>.<template>.<slot>, apart from the product’s layout.* slots.

{# splent_feature_shop/templates/shop/checkout_form.html #}
<form method="post">
  <button type="submit">Pay</button>
  {% for hook in get_template_hooks("shop.checkout.form_footer") %}{{ hook() | safe }}{% endfor %}
</form>
# splent_feature_gift_cards/hooks.py
from flask import render_template
from splent_framework.hooks.template_hooks import register_template_hook


def gift_card_link():
    return render_template("hooks/checkout_footer.html")


register_template_hook("shop.checkout.form_footer", gift_card_link)

The shop never imports the gift cards feature, which requires the shop in the UVL. Without gift cards, the slot draws nothing.


Replacing a base feature’s hook

[tool.splent.refinement.overrides]
hooks = [{ target = "layout.footer" }]
from splent_framework.hooks.template_hooks import replace_template_hook


def init_feature(app):
    replace_template_hook("layout.footer", custom_terms_link)

A refiner declares the slot in its pyproject.toml, then calls replace_template_hook. The callbacks its base put in that slot go, every other feature’s stay, and the new one takes their place once every feature has loaded.

  • A hook belongs to the feature whose code registers it, which is how only the base’s go.
  • Without the declaration the app stops at start with FeatureError. Only one refiner of a base may replace a given slot.
  • Called from outside any feature, as in a test, it replaces every callback of the slot.

See Template hooks.


Product layout hooks

The base_template.html that product:create scaffolds defines 12 standard hook slots. Fill them from any feature to add to the application shell.

Hook name Purpose
layout.head.css Extra stylesheets in <head>
layout.header.menu The header bar, before its buttons, such as a search box
layout.navbar.items The header’s buttons, before the account menu
layout.navbar.user The top of the account menu for a signed-in account, before the entries of user_menu_sections()
layout.footer Footer content
layout.scripts Extra <script> tags before </body>
layout.sidebar.top Menu slot. Sidebar, above the first section
layout.anonymous_sidebar Menu slot. Sidebar items for visitors who are not signed in
layout.authenticated_sidebar Menu slot. Sidebar items for signed-in accounts
layout.sidebar.authenticated.items Menu slot. Single items inside the signed-in sidebar
layout.navbar.anonymous Menu slot. Navbar content for visitors who are not signed in
layout.navbar.authenticated Menu slot. Navbar content for signed-in accounts

The first six are the widget slots every application shell provides, listed with its blocks in What the application shell provides.

Where the menu registry has nothing for the account, the menu slots are the whole menu. Next to it, the sidebar draws layout.anonymous_sidebar, layout.authenticated_sidebar and layout.sidebar.authenticated.items for the features that register no entry, so no entry shows twice. Declare menu entries in the registry (see From menu slots to the registry).

The public website has a shell and slots of its own, see Theme system.


Loading a feature’s scripts

# splent_feature_notes/hooks.py
from splent_framework.assets.feature_assets import feature_script
from splent_framework.hooks.template_hooks import register_template_hook


def notes_scripts():
    return feature_script("notes")


register_template_hook("layout.scripts", notes_scripts)

Each feature adds its own bundle through the layout.scripts slot and never points at another feature’s files. Adding or removing a feature then adds or removes its scripts, and no template breaks.

feature_script("notes") draws the bundle from the asset route with its version, integrity and nonce (see A feature’s bundle). A hook that draws something counts its feature as drawn on the page, for the page’s Content-Security-Policy.


Showing stored HTML

<div class="prose">{{ note.content | rich_text }}</div>

HTML your feature stores and shows again goes through the rich_text filter. Never use | safe on a stored value, which runs whatever it holds, a script included. The filter returns Markup, shows None as nothing and cleans a value that is already Markup too. In Python, call the function behind it.

from splent_framework.rich_text import sanitize_html

excerpt = sanitize_html(post.excerpt)

Cleaning twice changes nothing, so a body cleaned when it is saved and again when it is shown loses nothing more.

Where the filter goes

Where the stored value goes What to write
An element’s content, such as <div class="prose"> x \| rich_text
An attribute, such as a title or a description meta x \| striptags
A textarea where the HTML itself is edited x alone
script, style, noscript Never a stored body

What the filter keeps

Elements Attributes besides lang and title
p, h2 to h6, ul, ol, li, blockquote, pre, code, br, hr None
strong, em, b, i, u, s, span, div, figure, figcaption None
table, caption, thead, tbody, tfoot, tr None
th, td colspan, rowspan, and scope on th
a href with an http, https, mailto or relative address, and target only as _blank
img src with an http, https or relative address, alt, width, height

Every link gets rel="noopener noreferrer". Everything else goes.

  • style, class, id and every event handler, on every element.
  • An address with any other scheme, javascript: and data: included. The attribute goes and the element stays.
  • script, style, iframe, noscript and template, with their content. An iframe from a host your product lists is the exception, see below.
  • Any other element, such as h1, form or font. The tag goes and its text stays.
  • HTML comments.

Embedding players

[tool.splent.config]
RICH_TEXT_EMBED_HOSTS = ["www.youtube.com", "player.vimeo.com"]

A stored body keeps an iframe only when its src is an https address on a host your product lists, and none by default. In a .env the list is comma separated, and sanitize_html(value, embed_hosts=[...]) replaces it for one call.

A kept iframe keeps src, width, height, title, allow (only fullscreen, picture-in-picture and encrypted-media) and allowfullscreen, and nothing inside it. It always gets sandbox="allow-scripts allow-same-origin allow-popups allow-presentation", loading="lazy" and referrerpolicy="strict-origin-when-cross-origin".

  • List each host exactly as addresses write it. www.youtube.com and youtube.com are two entries, and no subdomain is included.
  • Never list your product’s own host. A frame of the page’s own origin can lift its own sandbox.
  • Add the hosts to your frame-src too when you send a policy in SPLENT_CONTENT_SECURITY_POLICY, or the browser blocks the frame.

An address with a port, a trailing dot, userinfo (user@), no scheme or http is refused, and so is a host with a space, a backslash, a percent escape or a letter outside ASCII. The host is compared in any case.

Showing a stored address

<a href="{{ event.link | safe_url }}">More info</a>

An address your feature stores goes into href or src through safe_url. It keeps https:, http:, mailto: and addresses with no scheme, such as a path on the site, and turns anything else, javascript: and data: included, into an empty string. It reads the scheme the way a browser does, so spaces, tabs and case do not sneak one past.

A form that stores an address refuses a bad one with is_safe_url(value) from splent_framework.rich_text.

A body stored as markdown goes through render_markdown instead. Keep | safe for markup your code built itself, such as a hook’s output, never for a value read from the database.


Using the page script

Every page of every shell loads splent-ui.js, a small script the framework serves in the layout.scripts slot. It gives your templates a few behaviours through data- attributes and your scripts a SPLENT object. You add nothing to use it.

Asking before a form is sent

<form method="post" action="{{ url_for('notes.delete', note_id=note.id) }}"
      data-confirm="{{ _('Delete this note?') }}"
      data-confirm-button="{{ _('Delete') }}"
      data-confirm-tone="danger">
  <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
  <button type="submit" class="btn btn-outline-danger">{{ _('Delete') }}</button>
</form>

data-confirm asks before the form is sent. It also works on a link, on a button outside a form, or on one submit button only. data-confirm-title and data-confirm-button change the title and the button’s words, and data-confirm-tone="danger" paints the button red. The question opens in Bootstrap’s modal, or in a <dialog> on a page without Bootstrap.

Use it instead of onsubmit="return confirm(...)". A Content-Security-Policy without 'unsafe-inline' does not run inline handlers, so the action would go ahead unasked.

Copying to the clipboard

<code id="api-key">{{ key }}</code>
<button type="button" class="btn btn-sm btn-light" data-copy-target="api-key">{{ _('Copy') }}</button>
<button type="button" class="btn btn-sm btn-light" data-copy="{{ invite_url }}">{{ _('Copy link') }}</button>

data-copy copies its own value, and data-copy-target the value or text of the element with that id. The button says “Copied” for two seconds, or the words of data-copied-label. With an icon, only its [data-copy-label] part changes.

Sending a form from a field

<select name="status" class="form-select" data-submit-on-change>...</select>
<input type="text" name="name" value="" data-submit-on-blur>

data-submit-on-change sends the field’s form when the field changes, as a filter or a colour does. data-submit-on-blur edits a name in place. Enter leaves the field, and leaving it sends the form when the text changed and is not empty. Both send the form as its submit button would, so data-confirm and data-busy still apply. Use them instead of onchange= or onblur=, which the Content-Security-Policy blocks.

Showing a password

<input type="password" name="password" class="form-control" data-reveal>

data-reveal adds a button that shows and hides what was typed, to the fields on the page when it loads.

Calling the site from a script

try {
    const note = await SPLENT.json('/api/v2/notes', { method: 'POST', body: { title: 'Groceries' } });
    SPLENT.notify('Saved');
} catch (error) {
    SPLENT.notify(error.message, 'danger');
}

SPLENT.json(url, options) returns the parsed body. A failed answer throws an Error whose message is the body’s detail, message, error or title, with status and body attached. SPLENT.fetch(url, options) returns the Response instead. Both add the page’s CSRF token to every call to this site that is not GET, HEAD or OPTIONS, and send a plain object as JSON.

Showing a notice

SPLENT.notify(text, tone) shows a notice in a corner of the page for five seconds, ten for danger. The tone is success (the default), danger, warning or info.

Marking a button at work

<button type="submit" class="btn btn-primary" data-busy="{{ _('Saving...') }}">{{ _('Save') }}</button>

While its form is sent, the button is disabled and shows a spinner, with the words of data-busy if it has some. From a script, call SPLENT.busy(button, true) and SPLENT.busy(button, false).


Reference

Variables available in every template

Name What it gives
SPLENT_APP The product’s name
get_template_hooks(name), render_template_hooks(name) A slot’s callbacks in order, or their joined output as Markup
get_assets(kind, shell="public"), render_assets(kind, shell="public") The "css" or "js" assets registered for a shell, as URLs or as tags with integrity and nonce
feature_script(feature, entry=None, defer=True), feature_style(feature, entry=None) A feature’s bundle, see A feature’s bundle
menu(section), menu_sections(), user_menu(), user_menu_sections() The menu entries the account sees, see Menus and shells
shell(name) The layout of the "app", "public" or "login" shell
csp_nonce() The request’s Content-Security-Policy nonce
has_endpoint(name) Whether the endpoint exists in this product
has_role(*roles, endpoint=None) Whether the account may open a screen, as role_required decides. See Access control
param(feature, key) A parameter a feature declares in [tool.splent.config_schema], typed
feature_setting(feature, key), feature_settings(feature) Settings an administrator edits, the panel’s value first, then the environment
localized(raw, default="") A string your product supplied, in the active language
icon(name, size=None, class_="", label=None, set=None) An icon of a registered set, as inline SVG
empty_state(title, text="", icon=None, action=None, compact=False) The block that says a list has nothing to show yet
pager(page) The pagination under a list paged with page_of, see Content helpers
external_url_for(endpoint, **values) A link on the site’s own address, see The site
html_lang, current_locale, supported_locales The active language and the product’s languages
What each feature’s inject_context_vars(app) returns Its variables
request, session, g, url_for, config and the rest Flask’s own
Filter What it does
rich_text Stored HTML cut down to the allowlist
safe_url A stored address, or "" when it is not safe
summary(length=160) Plain text for a description, see Content helpers
date, datetime, time_tag A date in the page’s language and the product’s time zone, time_tag as a <time> element
filesize A size in bytes as words, such as 1.5 MB

Template hook functions

All in splent_framework.hooks.template_hooks.

Function What it does
register_template_hook(name, func, order=100) Adds func to the slot. Lower order first
replace_template_hook(name, func, order=100) Replaces the callbacks the calling refiner’s base put in the slot
remove_template_hook(name, func) Removes one callback
get_template_hooks(name) The slot’s callbacks, sorted by order
render_template_hooks(name) Calls them and returns the joined output as Markup
resolve_pending_replacements() Applies the replacements asked for while features loaded. The feature loader calls it
unreplaced_hook_overrides(registry) Declared hook overrides whose refiner never called replace_template_hook
clear_hooks() Empties the registry, for tests

See also


Back to top

splent. Distributed by an LGPL license v3. Contact us: drorganvidez@us.es