Template system

SPLENT extends Flask’s Jinja2 engine with two mechanisms: context processors that inject variables into every template automatically, and template hooks that let features contribute HTML fragments to named slots in layouts.


Table of contents

  1. JinjaManager
    1. What it does
    2. Context processor pipeline
  2. inject_context_vars
    1. Naming collisions
    2. Access in templates
  3. Template hooks
    1. How it works
    2. Hook registry API
    3. Registering a hook
    4. Replacing a hook
    5. Calling hooks in a template
  4. Product layout hooks
    1. How features register hook callbacks
    2. Feature-defined hook slots
    3. How refinement features replace hooks
  5. Frontend asset loading
    1. Old pattern (avoid)
    2. New pattern: layout.scripts hook
    3. Convention
  6. Summary: variables available in every template
  7. See also

JinjaManager

File: managers/jinja_manager.py

Initialized in create_splent_app() as step 7 of the pipeline, after all managers but before features.

What it does

  1. Registers a context processor — merges the base context dict with every feature’s inject_context_vars() return value. The result is available in every Jinja template without passing anything to render_template.

  2. Registers Jinja globals — exposes get_template_hooks(name) as a global function callable from any template.

Context processor pipeline

On every request, Flask calls all registered context processors to build the template context. JinjaManager registers one that:

  1. Starts with the base context passed at construction (e.g., {"SPLENT_APP": "sample_splent_app"})
  2. Iterates over all registered feature context processors
  3. Calls each feature’s inject_context_vars(app) and merges the returned dict
  4. Returns the merged dict

If a feature’s inject_context_vars raises an exception or returns a non-dict, a warning is logged and that feature’s contribution is skipped — it does not crash the request.


inject_context_vars

Defined in: each feature’s __init__.py

def inject_context_vars(app):
    return {
        "is_redis_enabled": True,
        "app_version": "1.1.0",
    }

The dict returned here becomes available in every Jinja template in the application, not just templates from that feature. Use it for:

  • Feature flags (auth_enabled, profile_enabled)
  • Configuration values that templates need (mail_sender, app_name)
  • Current user utilities
  • Any computed value that is expensive to re-pass on every render_template call

Naming collisions

If two features return the same key, the last feature in the topological load order wins. Prefix your keys with the feature name to avoid collisions:

# Risky — "enabled" is too generic
return {"enabled": True}

# Safe
return {"notes_enabled": True}

Access in templates

<!-- No argument needed in render_template — available automatically -->
{% if auth_enabled %}
  <a href="{{ url_for('splent_feature_auth.login') }}">Login</a>
{% endif %}

Template hooks

File: hooks/template_hooks.py

Template hooks are named slots in layouts that features can contribute HTML to. They let features inject navigation items, scripts, styles, or any other HTML into any layout without modifying the layout file.

How it works

Feature A registers a hook → "nav_items"
Feature B registers a hook → "nav_items"

Layout template calls: get_template_hooks("nav_items")
→ returns [result_of_A, result_of_B]
→ template renders each one

Hook registry API

Function Description
register_template_hook(name, func) Add func to the hook list for slot name
replace_template_hook(name, func) Replace all existing callbacks for slot name with func
get_template_hooks(name) Return the list of hook results for slot name
clear_hooks() Reset the entire registry (used in tests)

The registry is a module-level dict and is not thread-safe for concurrent writes. Populate hooks only during app startup (inside init_feature), never during request handling.

Registering a hook

In a feature’s init_feature(app) or hooks.py:

from splent_framework.hooks.template_hooks import register_template_hook

def init_feature(app):
    register_template_hook("nav_items", render_nav_item)

def render_nav_item():
    return '<li><a href="/notes">Notes</a></li>'

register_template_hook(name, func) adds func to the list for slot name.

Replacing a hook

Refinement features can replace an existing hook’s callbacks entirely using replace_template_hook:

from splent_framework.hooks.template_hooks import replace_template_hook

def init_feature(app):
    replace_template_hook("nav_items", custom_nav_item)

def custom_nav_item():
    return '<li><a href="/custom-notes">Custom Notes</a></li>'

replace_template_hook(name, func) removes all previously registered callbacks for that slot and registers func as the sole callback. This is how refinement features override behavior from base features.

Calling hooks in a template

<!-- base.html or any layout -->
<ul class="nav">
  {% for item in get_template_hooks("nav_items") %}
    {{ item | safe }}
  {% endfor %}
</ul>

get_template_hooks(name) is injected as a Jinja global by JinjaManager. It returns the list of return values from all functions registered under name, in registration order (which follows the topological feature load order).


Product layout hooks

The product’s base_template.html defines 8 standard hook slots. Features fill these slots to contribute UI fragments to the shared layout:

Hook name Purpose
layout.head.css Extra stylesheets in <head>
layout.anonymous_sidebar Sidebar items shown to unauthenticated users
layout.authenticated_sidebar Sidebar items shown to authenticated users
layout.sidebar.authenticated.items Individual menu items inside the authenticated sidebar
layout.navbar.anonymous Navbar content for unauthenticated users
layout.navbar.authenticated Navbar content for authenticated users
layout.footer Footer content
layout.scripts Extra <script> tags before </body>

How features register hook callbacks

A feature registers callbacks during init_feature(app) or in its hooks.py:

from splent_framework.hooks.template_hooks import register_template_hook

def init_feature(app):
    register_template_hook("layout.anonymous_sidebar", render_login_link)
    register_template_hook("layout.scripts", render_auth_scripts)

def render_login_link():
    return '<li><a href="/login">Login</a></li>'

def render_auth_scripts():
    return '<script src="/static/auth/auth.bundle.js"></script>'

Feature-defined hook slots

Features are not limited to the product layout hooks listed above. Any feature can define its own hook slots in its templates, and other features can register callbacks for them. This is how optional features extend each other without coupling.

Example: auth exposes auth.login.form_footer

The auth feature’s login template renders a hook slot:

<!-- splent_feature_auth/templates/auth/login_form.html -->
<form method="post">
  ...
  <button type="submit">Login</button>

  {% for hook in get_template_hooks("auth.login.form_footer") %}
    {{ hook() | safe }}
  {% endfor %}
</form>

Example: reset hooks into auth’s slot

The reset feature registers a “Forgot your password?” link into that slot:

# splent_feature_reset/hooks.py
from splent_framework.hooks.template_hooks import register_template_hook
from flask import render_template

def inject_reset_link():
    return render_template("hooks/reset_link.html")

register_template_hook("auth.login.form_footer", inject_reset_link)
register_template_hook("auth.signup.form_footer", inject_reset_link)

If splent_feature_reset is not installed, the hook slot renders nothing — auth works normally without the link. If reset is installed, the link appears automatically.

This pattern works because:

  • Auth does not import or reference reset in any way.
  • Reset depends on auth (declared in the UVL), so it loads after auth and its hooks register into auth’s slots.
  • Removing reset from the product removes the link with no code changes.

Naming convention: feature-defined hook slots use the pattern <feature_name>.<template>.<slot> (e.g., auth.login.form_footer). This avoids collisions with the layout.* namespace used by the product.


How refinement features replace hooks

A refinement feature that depends on a base feature can replace its hook output. For example, a premium_auth feature that refines auth:

from splent_framework.hooks.template_hooks import replace_template_hook

def init_feature(app):
    replace_template_hook("layout.anonymous_sidebar", render_premium_login)

def render_premium_login():
    return '<li><a href="/premium-login">Premium Login</a></li>'

Because features load in topological order, the refinement feature always loads after the base feature, so replace_template_hook is guaranteed to see the existing registration.


Frontend asset loading

Features include their compiled JS bundles via the layout.scripts template hook, not by overriding block-scripts tags in templates.

Old pattern (avoid)

Previously, features loaded scripts by hardcoding asset references in their own templates:

<!-- feature template — creates coupling -->
{% block scripts %}
  <script src="{{ url_for('auth.assets', filename='dist/auth.bundle.js') }}"></script>
  <script src="{{ url_for('profile.assets', filename='dist/profile.bundle.js') }}"></script>
{% endblock %}

This created invisible frontend coupling: one feature’s template referenced another feature’s assets. Removing profile from the product would break auth’s template.

New pattern: layout.scripts hook

Each feature registers its own bundle in hooks.py:

# splent_feature_auth/hooks.py
from splent_framework.hooks.template_hooks import register_template_hook
from flask import render_template

def auth_scripts():
    return render_template("hooks/scripts.html")

register_template_hook("layout.scripts", auth_scripts)

The product’s base_template.html renders all registered scripts automatically:

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

Convention

Features never reference another feature’s assets directly. Each feature registers only its own bundle through layout.scripts. When a feature is added or removed from the product, its scripts appear or disappear automatically — no template edits required.


Summary: variables available in every template

By default, every SPLENT template has access to:

Variable Type Source
SPLENT_APP str Base context in create_splent_app()
get_template_hooks(name) function JinjaManager global
Everything returned by each feature’s inject_context_vars(app) varies Feature context processors
Standard Flask globals varies request, session, g, url_for, config, …

See also


Back to top

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