How Webifier dispatches YAML nodes to renderers and templates.
The renderer system is the node-level extension mechanism. Every YAML block is
processed and rendered by a renderer selected via the kind key. Renderers
are Python classes, Jinja2 templates, or both.
A renderer does two things:
YAML data ──▶ renderer.process() ──▶ processed data ──▶ renderer.render() ──▶ HTML
Most renderers only need a Jinja2 template for the render step. The process step has a sensible default (recursively process children). Only renderers with custom data-processing logic need Python.
When the framework encounters a node, it resolves the renderer in this order:
template: path/to/file.html ← checked first (inline override)
│
└─ Wrap in GenericTemplateRenderer with that file
kind: <name> ← checked second (named lookup)
│
├─ 1. Registry lookup
│ Is <name> registered via @register("name")?
│ Yes → use that RendererModule class
│
├─ 2. Template lookup
│ Does "renderers/<name>.html" exist in the Jinja2 search path?
│ Yes → wrap in GenericTemplateRenderer
│
└─ 3. Python import
Is <name> a dotted path like "mypackage.MyRenderer"?
Yes → import and instantiate
No → error with suggestions
template is the inline escape hatch — use a specific file without
naming or registering anything. kind is the named lookup. If neither is
present, config defaults or built-in inference applies.
kind Is Omitted)When kind is not specified, the framework infers a default renderer based on
data type. These defaults are configurable via config.defaults:
| Data shape | Config key | Built-in fallback |
|---|---|---|
| Root document (entry point) | config.defaults.page |
page |
str value |
config.defaults.markdown |
markdown |
list value |
config.defaults.links |
links |
dict value |
config.defaults.section |
section |
A user who wants every page to use a custom renderer:
# config.yml
config:
defaults:
page: my-custom-page
Now all pages — root, sub-pages, content pages — use my-custom-page without
any kind: annotation in the content YAML.
Explicit kind: always overrides the configured default:
# Most pages use the global default
# But this one is different:
kind: landing-page
title: Welcome
hero: ...
# webifier/core/base.py
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import ClassVar, Any
import typing as th
if th.TYPE_CHECKING:
from webifier.core.builder import Builder
@dataclass
class NodeContext:
"""Immutable context threaded through the processing tree."""
assets_src_dir: str | None = None
assets_target_dir: str | None = None
search_slug: str | None = None
search_links: bool = False
search_content: bool = False
base_dir: str = ""
class RendererModule(ABC):
"""Base class for all renderers.
Subclasses must set:
- ``name``: registry shorthand (used with ``@register``)
Subclasses may set:
- ``template``: path to a Jinja2 template in the search path
- ``META_KEYS``: keys that are reserved by the renderer, not child content
(skipped by
default ``process()``)
Subclasses may override:
- ``process()``: transform raw data before rendering
- ``render()``: produce HTML from processed data
"""
name: ClassVar[str]
template: ClassVar[str | None] = None
META_KEYS: ClassVar[frozenset[str]] = frozenset()
def __init__(self, builder: "Builder"):
self.builder = builder
def process(self, data: dict, ctx: NodeContext) -> dict:
"""Transform raw YAML data before rendering.
Default implementation: recursively process all non-meta values
via ``builder.process_node()``.
"""
result = {}
for key, value in data.items():
if key == "kind" or key in self.META_KEYS:
result[key] = value
else:
result[key] = self.builder.process_node(value, ctx)
return result
def render(self, data: dict, ctx: NodeContext) -> str:
"""Produce an HTML fragment from processed data.
Default implementation: render ``self.template`` with the data dict.
Subclasses without a template must override this method.
"""
if self.template is None:
raise NotImplementedError(
f"{type(self).__name__} has no template and does not override render()"
)
tmpl = self.builder.env.get_template(self.template)
return tmpl.render(
data=data,
ctx=ctx,
process=self.builder.process_node,
markdown=self.builder.render_markdown,
)
process() is optional. The default recursively processes children.
Most renderers only need render() (or just a template).
render() has a default. If template is set, the default render()
loads and renders it. Most renderers only need to set template.
META_KEYS replaces hardcoded special-key lists. Each renderer
declares which keys it reserves for its own contract. The framework has zero
knowledge of label, title, nav, etc.
Template receives process and markdown callables. Templates can
recursively process child nodes or render inline markdown.
When a kind resolves to a template file (step 2 in resolution), the
framework wraps it in a GenericTemplateRenderer:
class GenericTemplateRenderer(RendererModule):
"""Auto-created for template-only renderers (no Python class)."""
META_KEYS = frozenset({"label", "background", "style"})
def __init__(self, builder: "Builder", template_name: str):
super().__init__(builder)
self.template = template_name
This means creating a new renderer is as simple as creating a template file:
my-site/templates/renderers/gallery.html
# index.yml
photos:
kind: gallery # → finds renderers/gallery.html → GenericTemplateRenderer
label: Photos
items:
- src: img/a.jpg
- src: img/b.jpg
{# templates/renderers/gallery.html #}
<div class="gallery row">
{% for item in data.items %}
<div class="col-4">
<img src="{{ item.src }}" alt="{{ item.alt | default('') }}" class="img-fluid">
</div>
{% endfor %}
</div>
No Python required.
| Kind | Class | Template | Purpose |
|---|---|---|---|
page |
PageRenderer |
page.html |
Full HTML page with head, nav, header, sections, footer |
content-page |
ContentPageRenderer |
content-page.html |
Content page (for rendered md/notebook) |
| Kind | Class | Template | Purpose |
|---|---|---|---|
section |
SectionRenderer |
renderers/section.html |
Default dict container with label + children |
freeform |
FreeformRenderer |
renderers/freeform.html |
Raw content passthrough (no wrapper) |
chapters |
ChaptersRenderer |
renderers/chapters.html |
Bootstrap accordion |
people |
PeopleRenderer |
people.html |
Configurable people card grid |
comments |
— | renderers/comments.html |
Comment system embed (template-only) |
experience |
— | renderers/experience.html |
CV timeline entries (template-only) |
publications |
— | renderers/publications.html |
Publication list (template-only) |
inline-experience |
— | renderers/inline-experience.html |
Compact timeline entries (template-only) |
| Kind | Class | Template | Purpose |
|---|---|---|---|
markdown |
MarkdownRenderer |
— | Render string as markdown (code-only, no template) |
links |
LinksRenderer |
renderers/links.html |
Render list of link items |
| ### Template-only Renderers (No Python Class) |
Renderers marked with — under Class are pure templates. They have no Python
class — the framework wraps them in GenericTemplateRenderer automatically.
To override them, a user just drops a same-named template in their
templates/renderers/ directory.
Section-level renderers produce inner content HTML. The page renderer wraps each section with structural chrome:
┌─────────────────────────────────────────────┐
│ Section Wrapper (handled by page renderer) │
│ ┌──────────┐ ┌────────────────────────┐ │
│ │ Label │ │ Inner Content HTML │ │
│ │ (auto) │ │ ← renderer produces │ │
│ └──────────┘ └────────────────────────┘ │
│ background, style → wrapper HTML │
└─────────────────────────────────────────────┘
The section wrapper is itself a template (section-wrapper.html) that users
can override.
This separation means: - Section renderers never worry about labels, backgrounds, or styling. - The page renderer handles all structural layout consistently. - Users can override the wrapper without touching individual renderers.
Templates are resolved via a Jinja2 FileSystemLoader with this search order:
1. Site template directory ← highest priority (overrides)
2. Enabled extension template dirs ← in config order
To override any template, a user creates a file with the same relative path
in their templates/ directory. The Jinja2 loader finds it first.
First-party extension template at:
webifier_extensions/people/templates/people.html
User creates:
my-site/templates/people.html
The user's version is found first in the search path. The Python processing
(PeopleRenderer.process()) still runs — only the HTML output changes.
Each renderer declares which keys it reserves for its own contract instead of recursively processing them as child content:
class SectionRenderer(RendererModule):
name = "section"
META_KEYS = frozenset({"label", "background", "style"})
class PageRenderer(RendererModule):
name = "page"
META_KEYS = frozenset({"title", "nav", "header", "footer", "config", "meta", "search"})
class ExperienceEntry(RendererModule):
name = "experience"
META_KEYS = frozenset({"label"})
The default process() skips META_KEYS and kind — everything else is
recursively processed. This replaces the old hardcoded
SPECIAL_INDEX_KEYS and SPECIAL_OBJECT_KEYS lists.
from webifier.core.base import NodeContext
from webifier_extensions.standard.section import SectionRenderer
class GalleryRenderer(SectionRenderer):
name = "gallery"
template = "section.html"
def process(self, data: dict, ctx: NodeContext, builder) -> dict:
data = dict(data)
data.setdefault("template", "gallery.html")
for item in data.get("items", []):
if "src" in item:
item["src"] = builder._process_link({"link": item["src"]}, ctx)["href"]
return super().process(data, ctx, builder)
The extension class maps one or more renderer names to that class:
class GalleryExtension(Extension):
id = "my.gallery"
renderers = {
"gallery": "my_gallery.renderer.GalleryRenderer",
"portfolio": "my_gallery.renderer.GalleryRenderer",
}
Template-only renderers are still possible: register a renderer name to a template path when no Python preprocessing is needed.
Reusable renderers live in installed extension packages:
my_people_extension/
__init__.py
extension.py # PeopleExtension class
renderer.py # PeopleRenderer class
assets/
people.css
templates/
people.html
The package exposes an entry point:
[project.entry-points."webifier.extensions"]
"my.people" = "my_people_extension.extension:PeopleExtension"
The extension class declares template directories, asset mounts, and renderer aliases.
For renderers that need no Python, a site can still add
renderers/<name>.html directly in its own template directory.
Every renderer template receives these variables:
| Variable | Type | Description |
|---|---|---|
data |
dict |
The processed data dict (all children already rendered) |
ctx |
NodeContext |
Processing context (assets dirs, search config, etc.) |
process |
callable |
builder.process_node() — for manually processing child data |
markdown |
callable |
builder.render_markdown() — for inline markdown rendering |
Additionally, the page-level template receives page-specific variables (baseurl, global nav, etc.) passed directly by the page renderer.
{# renderers/experience.html #}
<div class="container-fluid">
{% for key, value in data.items() if key not in ("label", "kind") %}
<div class="row my-0">
<div class="col-12 col-md-4">
{% if value.heading.institution %}
<h5 class="mb-0 text-center">{{ value.heading.institution }}</h5>
{% endif %}
{% if value.heading.role %}
<p class="text-center mb-0 fw-light">{{ value.heading.role }}</p>
{% endif %}
{% if value.heading.date %}
<p class="text-muted small mb-0">{{ value.heading.date }}</p>
{% endif %}
</div>
{% if value.content %}
<div class="col-12 col-md-8">{{ value.content }}</div>
{% endif %}
</div>
{% if not loop.last %}<hr class="mx-4">{% endif %}
{% endfor %}
</div>