Template hooks
Named insertion points where features inject HTML at runtime. A feature registers a callback, 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.
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 — no manual edits needed.
Hook naming convention
Use dot-separated names: 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.
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