Content features
A content feature is a real domain feature that owns its own model and table — events, team, projects, tools — and renders public pages from it.
Table of contents
What is a content feature?
A content feature is a full archetype (see Archetypes) whose job is to own one content type and surface it on the public site. It bundles everything for that type: a model and table, a repository and service, public routes that render themed templates, an admin resource for back-office CRUD, and a seeder.
The canonical example is splent_feature_events. The rest of this page walks through it file by file.
One content type, one feature
The most important rule: each content type is its own feature. Events, team members, projects and tools are four separate features (splent_feature_events, splent_feature_team, …), each with its own table.
SPLENT deliberately does not model content as a single generic “collection” or WordPress-style custom-post-type table where everything shares one polymorphic row. Instead:
- Each type has a typed model (
Event, withspeaker,room,starts_at…) — nometablob, no string keys. - A type is shared across products by installing the feature into each product that needs it, not by copying rows between databases.
- You compose a site by selecting features, the same way you select any other capability in the SPL.
This keeps every content type strongly typed, independently versioned, and independently installable.
The model
models.py defines a normal SQLAlchemy model on splent_framework.db.db. The columns are the real fields of the domain, not a generic key/value bag.
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}>"
A few conventions worth copying:
- A unique, indexed
slugdrives clean public URLs (/events/<slug>). - A
publishedflag lets editors hide drafts from the public site. - An
ordercolumn gives editors manual control over listing order.
The repository
repositories.py extends BaseRepository and adds the queries this content 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()
Always start the file with
from __future__ import annotations.BaseRepositorydefines a method namedlist(). Inside a class that inherits it, the bare namelistresolves to that method, not the built-in — so an annotation like-> list[Event]would try to subscript the method and blow up. Withfrom __future__ import annotations, all annotations become strings (PEP 563) and are never evaluated at definition time, solist[Event]is safe. Add this import to every repository.
The service
services.py extends BaseService and is the only layer routes are allowed to talk to. It delegates to the repository and is what gets registered 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
A content feature is public-facing: its routes render templates that extend the theme’s public_base.html. They are resolved through the service locator with service_proxy, never by importing the service class directly.
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 typical pair of routes is a list (/events) and a detail (/events/<slug>).
Templates extend the theme
Templates live under templates/<type>/ and {% extends "public_base.html" %} — the base layout provided by the theme feature. This is why a content feature requires theme (see the contract below): it relies on the theme’s blocks (title, hero, content), the render_block() helper, and the _() translation helper.
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 a single record, using the theme’s hero block and a prose container for the rich-text description:
{% 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 | safe }}</div>
<p><a href="{{ url_for('events.index') }}">← {{ _('Back to events') }}</a></p>
</article>
{% endblock %}
Because the page styling comes from the theme, the same content feature looks native in any product that installs it — the product’s theme decides the visual identity.
Registering the admin resource
Public routes are read-only. Editors manage content through the admin panel (the wp-admin-style back-office). A content feature surfaces its model there by calling register_admin_resource in init_feature.
__init__.py:
from splent_framework.admin import register_admin_resource
from splent_framework.blueprints.base_blueprint import create_blueprint
from splent_framework.services.service_locator import register_service
from splent_io.splent_feature_events.models import Event
from splent_io.splent_feature_events.services import EventsService
events_bp = create_blueprint(__name__)
def init_feature(app):
register_service(app, "EventsService", EventsService)
# Surface Event in the admin panel (the wp-admin-style back-office).
register_admin_resource(
Event,
name="event",
label="Event",
label_plural="Events",
icon="calendar",
group="Programme",
order=10,
list_columns=["title", "kind", "room", "starts_at"],
field_widgets={
"summary": "textarea",
"description": "richtext",
"image": "image",
"link": "url",
"kind": "select",
"slug": "slug",
"starts_at": "datetime",
"ends_at": "datetime",
"published": "bool",
},
feature="events",
)
What the registration controls:
name/label/label_plural/icon/group/order— how the resource appears in the admin navigation.list_columns— the columns shown in the admin list view.field_widgets— which editor widget renders each field (richtextfor HTML,slug,datetime,bool,image, …) so editors get the right input per column.
init_feature is also where the service is bound into the locator with register_service(app, "EventsService", EventsService), which is what makes service_proxy("EventsService") resolve.
Seeders
A BaseSeeder ships demo content so a freshly derived product has something to show immediately. The seeder constructs real model instances and hands them to self.seed().
seeders.py:
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 feature contract in pyproject.toml is what makes a content feature composable and validated by feature:check and product:derive. A content feature provides its routes, blueprint and model, and requires the theme feature it renders against.
[tool.splent.contract]
description = "Events — talks, workshops, competitions and ceremonies (public list/detail + admin CRUD)"
[tool.splent.contract.provides]
routes = ["/events", "/events/<slug>"]
blueprints = ["events_bp"]
models = ["Event"]
commands = []
[tool.splent.contract.requires]
features = ["theme"]
env_vars = []
[tool.splent.contract.extensible]
services = []
templates = ["events/list.html", "events/detail.html"]
models = []
hooks = []
routes = false
Key points:
provides.routesandprovides.modelsdeclare the public surface and theEventtable the feature introduces.requires.features = ["theme"]is the formal version of “templates extendpublic_base.html.” Deriving a product that selectseventswithoutthemefails validation.extensible.templateslists the templates other features may refine, so a product can override the look ofevents/list.htmlwithout forking the feature.
Checklist
To build a new content feature (say, team), copy the events shape:
- 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. - Templates — under
templates/team/, extendingpublic_base.html. init_feature—register_service(...)andregister_admin_resource(Team, ...).- Seeder — a
BaseSeederwith a few real records. - Contract —
providesthe routes/blueprint/model,requires = ["theme"].
See also
- Archetypes — content features are the
fullarchetype - Feature contract — the
pyproject.toml[tool.splent.contract]block - Directory structure — where each file lives