Template system
Table of contents
- Introduction
- Passing variables to every template
- Registering a hook
- Drawing a slot in a template
- Opening a slot for other features
- Replacing a base feature’s hook
- Product layout hooks
- Loading a feature’s scripts
- Showing stored HTML
- Using the page script
- Reference
- 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.
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,idand every event handler, on every element.- An address with any other scheme,
javascript:anddata:included. The attribute goes and the element stays. script,style,iframe,noscriptandtemplate, with their content. Aniframefrom a host your product lists is the exception, see below.- Any other element, such as
h1,formorfont. 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.comandyoutube.comare 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-srctoo when you send a policy inSPLENT_CONTENT_SECURITY_POLICY, or the browser blocks the frame.
An address with a port, a trailing dot, userinfo (
user@), no scheme orhttpis 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_markdowninstead. Keep| safefor 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
- Feature loading, for when
inject_context_varsis collected. - Application initialization, for when the template globals are set up.
- Theme system, for the public shell and its slots.
- Extensibility, for how refiners extend and override base features.
feature:hooks,feature:hook-addandfeature:hook-remove, to list, add and remove a feature’s hooks.