Template hooks
Table of contents
Introduction
A hook is a named slot in a template. Features register callbacks for it, and the template renders whatever they return, so your feature adds UI to a page without editing the page’s file.
{% for hook in get_template_hooks("notes.create.form_extra") %}
{{ hook() | safe }}
{% endfor %}
# hooks.py of another feature
from splent_framework.hooks.template_hooks import register_template_hook
register_template_hook("notes.create.form_extra", tags_form_field)
A slot nobody fills renders nothing.
How a slot fills
Hooks add up. Any number of features can register for one slot. Callbacks run sorted by their order argument (default 100), then in feature load order, and their HTML is joined.
register_template_hook("home.section", latest_notes, order=30)
Slots live in two places, with the same API.
| Level | Slot lives in | Example |
|---|---|---|
| Product hooks | The product’s base_template.html |
layout.scripts |
| Feature hooks | A feature’s own templates | home.section, notes.create.form_extra |
Filling the product’s layout
The product’s base_template.html renders the layout slots, and its pyproject.toml lists them.
[tool.splent.layout]
base_template = "base_template.html"
hooks = [
"layout.head.css",
"layout.sidebar.top",
"layout.anonymous_sidebar",
"layout.authenticated_sidebar",
"layout.sidebar.authenticated.items",
"layout.navbar.anonymous",
"layout.navbar.authenticated",
"layout.footer",
"layout.scripts",
]
A feature adds its compiled JavaScript through layout.scripts, so adding or removing the feature adds or removes its bundle.
def notes_scripts():
return render_template("hooks/scripts.html")
register_template_hook("layout.scripts", notes_scripts)
Declare menu entries in the menu registry rather than in the sidebar and navbar slots. A shell draws the registry or the menu slots, never both. See What the application shell provides.
Opening a slot in your feature
Render get_template_hooks(name) where other features may add something. Name the slot feature.view.slot, starting with your feature’s short name.
Passing context
Callbacks can take arguments.
{% for note in notes %}
<h5>{{ note.title }}</h5>
{% for hook in get_template_hooks("notes.index.note_extra") %}
{{ (hook(note) or "") | safe }}
{% endfor %}
{% endfor %}
def note_tag_badges(note):
tags = note.get_tags_list()
if not tags:
return ""
return render_template("hooks/note_tag_badges.html", tags=tags)
register_template_hook("notes.index.note_extra", note_tag_badges)
or "" tolerates a callback that returns None.
Accepting form fields from a hook
When a hook injects a form field, the base route passes the extra data through without knowing it.
FORM_BASE_FIELDS = {"title", "content"}
@notes_bp.route("/notes/create", methods=["GET", "POST"])
@login_required
def create():
if request.method == "POST":
title = request.form.get("title", "").strip()
content = request.form.get("content", "").strip()
extra = {k: v for k, v in request.form.items() if k not in FORM_BASE_FIELDS}
notes_service.create(user_id=current_user.id, title=title, content=content, **extra)
Without a feature filling the slot, extra is empty.
Keeping your own markup until a feature fills the slot
render_template_hooks(name) calls every callback and returns their joined output as one Markup string, empty when nobody rendered anything.
{% set hero = render_template_hooks("home.hero") %}
{% if hero %}{{ hero }}{% else %}…the page's own hero…{% endif %}
feature:contract records the slots you open with either function in your contract.
Replacing a base’s callbacks
A refiner can swap what its base puts in a slot.
from splent_framework.hooks.template_hooks import replace_template_hook
replace_template_hook("layout.navbar.authenticated", navbar_with_coupon_badge)
It removes the callbacks the caller’s base registered there, keeps everyone else’s, and adds yours.
- The refiner must list the slot in
[tool.splent.refinement.overrides] hooks, or startup stops withFeatureError. One refiner of a base may replace a given slot. - A replacement asked for while features load waits until every feature has loaded, so the base’s callbacks are there to replace.
- A declared override never applied logs a warning, since the base’s callbacks stay.
- A call from outside any feature, such as a test or a script, replaces the whole slot.
Prefer filling a slot to replacing it. A replacement claims the slot for one refiner, while any number of features can add to it.
Reference
| Function | Does |
|---|---|
register_template_hook(name, func, order=100) |
Adds func to the slot |
replace_template_hook(name, func, order=100) |
Swaps the base’s callbacks for func |
remove_template_hook(name, func) |
Takes func out of the slot |
get_template_hooks(name) |
The slot’s callbacks in order. A Jinja global |
render_template_hooks(name) |
Their joined output, empty when nothing rendered. A Jinja global |
| Layout hook | Where | Typical use |
|---|---|---|
layout.head.css |
<head> |
Feature CSS |
layout.sidebar.top |
Sidebar, above the first section | Home link |
layout.anonymous_sidebar, layout.authenticated_sidebar |
Sidebar, signed out or in | Sign-in links, admin screens |
layout.sidebar.authenticated.items |
Sidebar, signed in, below the main items | A feature’s own pages |
layout.navbar.anonymous, layout.navbar.authenticated |
Navbar, signed out or in | Sign-in button, user name |
layout.footer |
Footer | Footer links |
layout.scripts |
Before </body> |
Feature JavaScript bundles |
See also
- Refine, extend or compose, when a slot is enough
- Refinement, how a feature overrides another
feature:xray, see which features fill which slots