Content features
A content feature owns one content type, such as events, team members, projects or tools, with its own model and table, and renders public pages and admin screens for it.
Table of contents
What is a content feature?
A content feature is a full archetype. It bundles everything for its type, a model and table, a repository and a service, public routes with themed templates, admin screens of its own, and a seeder. The canonical example is splent_feature_events, and this page walks through it file by file.
One content type, one feature
Each content type is its own feature. Events, team members, projects and tools are four features (splent_feature_events, splent_feature_team, …), each with its own table, never one generic collection or custom-post-type table.
- Each type has a typed model (
Event, withspeaker,room,starts_at…), with nometablob and no string keys. - Products share a type by installing the feature, not by copying rows between databases.
- You compose a site by selecting features, like any other capability of the SPL.
Every content type stays typed, versioned on its own and installable on its own.
The model
models.py defines a plain SQLAlchemy model on splent_framework.db.db, with the real fields of the domain as columns.
from datetime import datetime # noqa: F401 — used by seeders/migrations
from splent_framework.db import db
class Event(db.Model):
"""An event/activity: a talk, workshop, competition or ceremony."""
__tablename__ = "event"
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(255), nullable=False)
slug = db.Column(db.String(255), nullable=False, unique=True, index=True)
kind = db.Column(db.String(64), default="talk") # talk|workshop|competition|ceremony
summary = db.Column(db.Text, default="")
description = db.Column(db.Text, default="") # rich text / HTML
speaker = db.Column(db.String(255), default="")
room = db.Column(db.String(128), default="")
starts_at = db.Column(db.DateTime)
ends_at = db.Column(db.DateTime)
image = db.Column(db.String(512), default="")
link = db.Column(db.String(512), default="")
published = db.Column(db.Boolean, default=True)
order = db.Column(db.Integer, default=0)
def __repr__(self):
return f"Event<{self.slug}>"
Three conventions are worth copying.
- A unique, indexed
slugfor clean public URLs (/events/<slug>). - A
publishedflag, so editors hide drafts from the public site. - An
ordercolumn, so editors control the listing order.
The repository
repositories.py extends BaseRepository with the queries the type needs.
from __future__ import annotations
from splent_io.splent_feature_events.models import Event
from splent_framework.repositories.BaseRepository import BaseRepository
class EventsRepository(BaseRepository):
def __init__(self):
super().__init__(Event)
def list_published(self) -> list[Event]:
return (
Event.query.filter_by(published=True)
.order_by(Event.order.asc(), Event.starts_at.asc())
.all()
)
def get_by_slug(self, slug: str) -> Event | None:
return Event.query.filter_by(slug=slug).first()
Start every repository with
from __future__ import annotations.BaseRepositorydefines a method namedlist(). Inside a subclass the bare namelistis that method, not the built-in, so an annotation like-> list[Event]fails when the class is defined. With the import, annotations stay strings (PEP 563) and are never evaluated.
The service
services.py extends BaseService, delegates to the repository, and is the only layer routes talk to. It is what the feature registers in the service locator.
from splent_io.splent_feature_events.repositories import EventsRepository
from splent_framework.services.BaseService import BaseService
class EventsService(BaseService):
def __init__(self):
super().__init__(EventsRepository())
def list_published(self):
return self.repository.list_published()
def get_by_slug(self, slug: str):
return self.repository.get_by_slug(slug)
Public routes
Public routes render templates that extend the theme’s public_base.html, and reach the service through service_proxy, never by importing the class.
from flask import abort, render_template
from splent_io.splent_feature_events import events_bp
from splent_framework.services.service_locator import service_proxy
events_service = service_proxy("EventsService")
@events_bp.route("/events", methods=["GET"])
def index():
events = events_service.list_published()
return render_template("events/list.html", events=events)
@events_bp.route("/events/<slug>", methods=["GET"])
def detail(slug):
event = events_service.get_by_slug(slug)
if event is None:
abort(404)
return render_template("events/detail.html", event=event)
The usual pair is a list (/events) and a detail (/events/<slug>).
Templates extend the theme
Templates live under templates/<type>/ and {% extends "public_base.html" %}, the layout of the theme feature. They use the theme’s blocks (title, hero, content), the render_block() helper and _() for translations. The contract does not record this dependency on the theme, as explained below.
Here is templates/events/list.html.
{% extends "public_base.html" %}
{% block title %}Events — {{ SPLENT_APP }}{% endblock %}
{% block hero %}
{{ render_block('hero', eyebrow=_('Programme'), title=_('Events'),
subtitle=_('Talks, workshops and competitions.')) }}
{% endblock %}
{% block content %}
<div class="container section">
<div class="card-grid">
{% for e in events %}
<a class="card" href="{{ url_for('events.detail', slug=e.slug) }}">
{% if e.image %}<img class="card__img" src="{{ e.image }}" alt="">{% endif %}
<span class="badge">{{ e.kind }}</span>
<h3 class="card__title">{{ e.title }}</h3>
{% if e.summary %}<p class="card__text">{{ e.summary }}</p>{% endif %}
</a>
{% else %}
<p class="card__text">{{ _('No events yet.') }}</p>
{% endfor %}
</div>
</div>
{% endblock %}
templates/events/detail.html does the same for one record, with the theme’s hero block and a prose container for the rich-text description. The description is stored HTML, so it goes through the rich_text filter, which cuts it down to an allowlist, and never through | safe, which would run any script stored in it.
{% extends "public_base.html" %}
{% block hero %}
{{ render_block('hero', eyebrow=event.kind, title=event.title, subtitle=event.summary) }}
{% endblock %}
{% block content %}
<article class="container section">
<div class="prose">{{ event.description | rich_text }}</div>
<p><a href="{{ url_for('events.index') }}">← {{ _('Back to events') }}</a></p>
</article>
{% endblock %}
The styling comes from the theme, so the same feature looks native in any product that installs it.
Admin screens of its own
Public routes are read-only, and editors work in the admin panel. The shipped content features (events, team, projects, tools, about) ship their own admin screens under /admin/<type>, the WordPress-plugin pattern, and link them through the layout.authenticated_sidebar hook. None of them uses the admin resource registry.
Here is __init__.py, trimmed to what every content feature has.
from splent_framework.blueprints.base_blueprint import create_blueprint
from splent_framework.nav.nav_registry import register_nav_item
from splent_framework.services.service_locator import register_service
from splent_io.splent_feature_events.services import EventsService
events_bp = create_blueprint(__name__)
def init_feature(app):
from splent_framework.assets.asset_registry import register_asset
# Events are managed through their OWN custom admin screens (routes.py
# and hooks.py), the WordPress-plugin pattern, instead of the generic
# admin resource, so it does not call register_admin_resource.
register_service(app, "EventsService", EventsService)
label = app.config.get("EVENTS_NAV_LABEL", "") or "Events"
register_nav_item(key="events", label=label, href="/events", order=10)
# Public event-detail stylesheet (token-driven; styled by the active skin).
register_asset(
"css", "events.assets", order=100, subfolder="css", filename="events.css"
)
register_service is what makes service_proxy("EventsService") resolve. init_feature also registers the public nav entry and the feature’s stylesheet, and the real file calls register_settings to put two editorial settings in the admin panel (see Feature settings).
The admin screens are plain routes in routes.py, behind @login_required, rendering templates under templates/events/admin/ that extend base_template.html, the product’s admin shell. Here are the list and create screens.
@events_bp.route("/admin/events", methods=["GET"])
@login_required
def admin_index():
return render_template(
"events/admin/list.html",
groups=_ordered_groups(),
known_groups=_known_groups(),
)
@events_bp.route("/admin/events/new", methods=["GET", "POST"])
@login_required
def admin_new():
if request.method == "POST":
data = _form_to_data(request.form)
if not data["title"]:
flash("Title is required.", "danger")
return redirect(url_for("events.admin_new"))
data["slug"] = _unique_slug(data["title"])
db.session.add(Event(**data))
db.session.commit()
flash(f"Added {data['title']}.", "success")
return redirect(url_for("events.admin_index"))
return render_template(
"events/admin/form.html", event=None, known_groups=_known_groups()
)
/admin/events/<int:event_id>/edit, /move and /delete complete the set. _form_to_data builds the saved data from Event.__table__.columns, so a column a refiner adds to the model is saved by the same route. That is how editions gets its edition selector saved, after putting it in the form through the events.admin.form.fields slot, which admin/form.html renders with hook(event).
The link in the admin sidebar comes from hooks.py.
def events_admin_link():
"""Sidebar entry for the Events management screen (the WP-plugin pattern)."""
active = (
"active"
if request.endpoint and request.endpoint.startswith("events.admin")
else ""
)
return (
f'<li class="sidebar-item {active}">'
f'<a class="sidebar-link" href="{url_for("events.admin_index")}">'
'<i class="align-middle" data-feather="calendar"></i> '
'<span class="align-middle">Events</span>'
"</a>"
"</li>"
)
register_template_hook("layout.authenticated_sidebar", events_admin_link)
Seeders
A BaseSeeder ships demo content, so a freshly derived product has something to show. It builds real model instances and hands them to self.seed().
from datetime import datetime
from splent_framework.seeders.BaseSeeder import BaseSeeder
from splent_io.splent_feature_events.models import Event
class EventsSeeder(BaseSeeder):
def run(self):
self.seed(
[
Event(
slug="opening-ceremony", title="Opening Ceremony", kind="ceremony",
room="Salón de Actos", order=1, starts_at=datetime(2025, 11, 4, 9, 0),
summary="Kick-off of InnoSoft Days XIII at the ETSII.",
description="<p>Welcome to three days of talks, workshops, "
"competitions and fun.</p>",
),
Event(
slug="ai-testing", title="New Ways to Test Software using AI", kind="talk",
room="Aula 0.1", order=2, starts_at=datetime(2025, 11, 4, 10, 0),
speaker="Andreas Zeller", link="https://andreas-zeller.info",
summary="Keynote on AI-assisted software testing.",
),
]
)
The pyproject contract
The contract makes a content feature composable. splent feature:contract --write generates it from the source, so it records what the code does, and check:contracts, product:validate and the product:derive pre-flight check it. This is the contract of events.
[tool.splent.contract]
description = "Events (talks, workshops, competitions and ceremonies) with public list/detail and admin CRUD"
archetype = "full"
[tool.splent.contract.provides]
routes = ["/admin/events", "/admin/events/<int:event_id>/delete", "/admin/events/<int:event_id>/edit", "/admin/events/<int:event_id>/move", "/admin/events/new", "/events", "/events/<slug>"]
blueprints = ["events_bp"]
models = ["Event"]
commands = ["hello"]
hooks = ["home.section", "layout.authenticated_sidebar"]
services = ["EventsService"]
signals = []
translations = ["en", "es"]
docker = []
[tool.splent.contract.requires]
features = ["media"]
features_optional = ["team"]
env_vars = ["EVENTS_NAV_LABEL", "EVENTS_SHOW_PAST"]
signals = []
[tool.splent.contract.extensible]
services = ["EventsService"]
templates = ["events/admin/form.html", "events/admin/list.html", "events/detail.html", "events/hooks/home_section.html", "events/index.html", "events/list.html"]
models = ["Event"]
hooks = ["events.admin.form.fields", "events.admin.list.actions", "events.admin.list.event_meta", "events.detail.before_meta", "events.list.content", "events.list.hero", "events.list.title", "home.section", "layout.authenticated_sidebar"]
routes = true
provides.routesandprovides.modelsare the public and admin surface and theEventtable.requires.features = ["media"]comes from the seeder, which importsMediaServiceto store the seed posters.features_optional = ["team"]comes from aservice_proxy("TeamService")call wrapped intry, which resolves a speaker to a team member whenteamis installed.- Extending
public_base.htmlis invisible to the generator, which reads imports andservice_proxycalls, not template inheritance, so nothing here namestheme. A dependency that must be enforced and that static analysis cannot find goes inrequires.features_manual, which survives regeneration (teamdeclaresmediathere). extensible.templateslists the templates a refiner may override, andextensible.hooksthe slots the feature opens or fills.editionsfills the seven slotseventsopens on its pages, without overriding any template. See Refine, extend or compose.
Checklist
To build a new content feature, team say, copy the shape of events.
- Model. A typed
Teammodel with its own__tablename__, a unique indexedslug,publishedandorder. - Repository. Extends
BaseRepository; start the file withfrom __future__ import annotations; addlist_published()/get_by_slug(). - Service. Extends
BaseService, delegates to the repository. - Routes. Public
index+detail, resolved viaservice_proxy, rendering templates. Adminlist,new,editanddeleteunder/admin/team, behind@login_required. - Templates. Public ones under
templates/team/, extendingpublic_base.html. Admin ones undertemplates/team/admin/, extendingbase_template.html. init_featureandhooks.py. Callregister_service(...)andregister_nav_item(...), and register alayout.authenticated_sidebarhook for the admin link.- Seeder. A
BaseSeederwith a few real records. - Contract. Run
splent feature:contract --writeand read what it inferred. Put inrequires.features_manualanything static analysis cannot see.
See also
- Archetypes. Content features are the
fullarchetype - Feature contract. The
pyproject.toml[tool.splent.contract]block - Directory structure. Where each file lives