Template hooks
Named insertion points where features inject HTML at runtime. A feature registers a callback and the template renders it. No file modifications needed.
Table of contents
Two levels
| Level | Declared in | Example |
|---|---|---|
| Product hooks | Product’s pyproject.toml + base_template.html |
layout.navbar.authenticated |
| Inter-feature hooks | A feature’s own templates | notes.create.form_extra |
Both use the same API. The only difference is where the slot lives.
Product layout hooks
The product’s base_template.html declares hook slots. Features register callbacks for these slots.
The hooks are listed in the product’s pyproject.toml.
[tool.splent.layout]
base_template = "base_template.html"
hooks = [
"layout.head.css",
"layout.anonymous_sidebar",
"layout.authenticated_sidebar",
"layout.sidebar.authenticated.items",
"layout.navbar.anonymous",
"layout.navbar.authenticated",
"layout.footer",
"layout.scripts",
]
The 8 standard hooks
| Hook | Location in layout | Typical use |
|---|---|---|
layout.head.css |
<head> |
Feature-specific CSS |
layout.anonymous_sidebar |
Sidebar (logged out) | Login/signup links |
layout.authenticated_sidebar |
Sidebar (logged in) | Logout link |
layout.sidebar.authenticated.items |
Sidebar (logged in, below main items) | Edit profile, settings |
layout.navbar.anonymous |
Navbar (logged out) | Sign in button |
layout.navbar.authenticated |
Navbar (logged in) | User name, avatar |
layout.footer |
Footer | Footer links |
layout.scripts |
Before </body> |
Feature-specific JS |
How the template renders hooks
{% for hook in get_template_hooks("layout.navbar.authenticated") %}
{{ hook() | safe }}
{% endfor %}
JinjaManager registers get_template_hooks as a Jinja global during app initialization.
Registering a hook
# splent_feature_auth/hooks.py
from splent_framework.hooks.template_hooks import register_template_hook
from flask import render_template
def navbar_anonymous():
return render_template("hooks/navbar_anonymous.html")
register_template_hook("layout.navbar.anonymous", navbar_anonymous)
Multiple features, same hook
Hooks are additive. Both auth and profile can register for layout.navbar.authenticated. Both execute in feature load order (UVL-determined). The HTML is concatenated.
Inter-feature hooks
Product hooks live in the product layout. Inter-feature hooks live in a feature’s own templates. They let other features inject UI into a base feature.
The pattern
- Base feature declares a slot in its template.
{% for hook in get_template_hooks("notes.create.form_extra") %}
{{ hook() | safe }}
{% endfor %}
- Refinement feature registers a callback.
register_template_hook("notes.create.form_extra", tags_form_field)
If no feature registers for a slot, it renders nothing.
Passing context to hooks
Hook callbacks can receive arguments.
{% for note in notes %}
<h5>{{ note.title }}</h5>
{% for hook in get_template_hooks("notes.index.note_extra") %}
{{ hook(note) | 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)
Making form hooks work end-to-end
When a hook injects a form field, the base feature’s route passes extra data through.
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)
The base route doesn’t know about tags. It just passes extra form data through. Remove the refinement and **extra is empty.
Bundle registration via layout.scripts
Features register their compiled JS bundles through the layout.scripts hook.
def auth_scripts():
return render_template("hooks/scripts.html")
register_template_hook("layout.scripts", auth_scripts)
Adding or removing a feature automatically adds or removes its JS, with no manual edits needed.
Hook naming convention
Use dot-separated names of the form feature.view.slot.
| Hook name | Where it renders |
|---|---|
notes.index.before_list |
Above the notes list |
notes.create.form_extra |
Extra fields in the create form |
auth.login.form_footer |
Below the login form |
layout.sidebar.authenticated.items |
Product sidebar items |
Product-level hooks start with layout.. Feature-level hooks start with the feature’s short name.
Slots with a fallback (render_template_hooks)
Sometimes a page has its own default markup for a slot and only wants to
step aside when a feature contributes something. Iterating
get_template_hooks cannot tell an empty contribution from an absent one, so
the framework also exposes render_template_hooks(name): it calls every hook
of the slot and returns their joined output as one Markup string. Empty
means nobody rendered anything (no feature registered, or every registered
hook returned "" for this page).
{% set hero = render_template_hooks("home.hero") %}
{% if hero %}{{ hero }}{% else %}…the page's own hero…{% endif %}
The home page of splent_feature_public uses this for home.hero (the
editions feature renders the current edition there) next to the additive
home.section slot. Both are inferred into the feature contract by
feature:contract, which recognises render_template_hooks(...) as well as
get_template_hooks(...).
Hook replacement
| Function | Behavior | Use case |
|---|---|---|
register_template_hook(name, func) |
Additive, appends | Multiple features contribute |
replace_template_hook(name, func) |
Exclusive, replaces all | Refiner replaces entirely |
Use
replace_template_hooksparingly. It removes all prior registrations for that slot.
See also
- Refinement. How features override other features
- Extension points. Declaring what can be overridden
feature:xray. Visualize hook usage