Extension & Customization Guide

Named extensions, templates, renderers, resolvers, assets, and site features.

Extension & Customization Guide

Webifier extensions are installable Python packages that contribute rendering behavior to a site. An extension can provide renderers, templates, assets, themes, resolvers, content handlers, and build hooks.

The public config shape is a named mapping:

webifier:
  extensions:
    site:
      uses: webifier.standard

    search:
      uses: webifier.search
      content: true
      links: true

    theme:
      uses: webifier.theme
      default: system
      switcher: true

    resume:
      uses: webifier.resume

Each key is a local instance name. uses points to the installed extension id. The instance name is also the config namespace exported for that instance, so resume: exports its settings to config.resume, publications: exports to config.publications, and so on. This lets one extension be used more than once:

webifier:
  extensions:
    publications:
      uses: my.collection
      source: papers/

    projects:
      uses: my.collection
      source: projects/

YAML order is extension load order. A later extension can replace a registration from an earlier one only when the instance says override: true.

First-Party Extensions

Extension Provides
webifier.standard Default page, section, content page, links, freeform renderers, templates, and base assets
webifier.markdown Markdown renderer and .md / .markdown content pages
webifier.notebook .ipynb content pages
webifier.pdf Embedded .pdf content pages with page chrome and PDF controls
webifier.search Search config and search.json generation
webifier.theme Light/dark/system theme plumbing and theme assets
webifier.analytics.google Google tag head injection
webifier.comments Utterances comments renderer
webifier.people People/profile renderer
webifier.chapters Accordion/chapter renderer
webifier.resume Resume experience renderer, inline detail toggles, and assets

Template Overrides

The template search path starts with your site root, then enabled extension template directories in config order.

To override a template, put a file with the same relative path in your site:

my-site/
  page.html
  templates/
    publications.html

Template-only renderers still work:

my-site/
  renderers/
    gallery.html
photos:
  kind: gallery

Extension Package Contract

Expose an entry point:

[project.entry-points."webifier.extensions"]
"my.resume" = "my_resume.extension:ResumeExtension"

Expose an Extension subclass:

from webifier.core.extensions import AssetMount, Extension


class ResumeExtension(Extension):
    id = "my.resume"
    template_dirs = ["/absolute/path/to/templates"]
    assets = [AssetMount("/absolute/path/to/assets", "assets/webifier/resume")]
    renderers = {
        "resume.experience": "my_resume.renderers.ExperienceRenderer",
    }
    content_renderers = {
        ".bib": "my_resume.content.render_bibliography_page",
    }
    resolvers = {
        "resume_files": "my_resume.resolvers.resume_files",
    }
    hooks = {
        "head": ["my_resume.hooks.render_head"],
        "after_build": ["my_resume.hooks.after_build"],
    }

Extension Fields

Field Purpose
id Stable installed extension id used by uses
dependencies Extension ids that must already be enabled
renderers kind aliases to RendererModule classes
content_renderers File suffixes or aliases to content-page builders
page_keys Page-level keys consumed before ordinary section rendering
resolvers ${resolver:...} functions
formats Patch/load handlers for file extensions
template_dirs Jinja2 template directories
assets Asset directories copied into output
hooks Optional build or HTML injection hooks
default_config Defaults for that extension instance
config_defaults Defaults merged into the site config

Useful Hooks

Hook Purpose
head Return HTML for the page <head>
before_build Run before the root page is rendered
after_build Run after all pages are rendered

Hook functions receive the builder plus lifecycle keyword arguments. The head hook is page-aware: it runs while that page's <head> is being rendered.

Useful head hook arguments:

Argument Purpose
hook_context Stable object with area, builder, page, ctx, config, extension id, instance name, and instance config
page The processed page data currently being rendered
ctx The current NodeContext, including page URL
config Global config merged with page-local config and Markdown page-preface config
instance_config Config for this named extension instance
baseurl Base URL for building asset links

That means an extension can inject assets only on pages that actually use it:

from markupsafe import Markup


def render_head(builder, *, page=None, baseurl="", instance_config=None, **_):
    if not page_uses_widget(page):
        return ""
    return Markup(f'<script defer src="{baseurl}/assets/widget/widget.js"></script>')

Or it can let individual pages opt in through page config:

---
title: Interactive notebook
config:
  widget:
    enabled: true
---
def render_head(builder, *, config=None, baseurl="", **_):
    widget = (config or {}).get("widget", {})
    if not widget.get("enabled"):
        return ""
    return f'<script defer src="{baseurl}/assets/widget/widget.js"></script>'

Head hooks usually return a string; build hooks usually return nothing.

Page Key Consumers

By default, page keys outside reserved Webifier controls are visible content. With webifier.standard, they render as sections in YAML order. Extensions can claim additional page-level keys when a key should control behavior instead of appearing as content.

Register a consumer imperatively:

from webifier.core.extensions import Extension


class WeatherExtension(Extension):
    id = "example.weather"

    def register(self, ctx):
        ctx.consume_page_key("weather", self.consume_weather)

    def consume_weather(self, builder, *, value, page, config, instance_name, **_):
        page.setdefault("_weather", value)

Or declare a mapping:

class WeatherExtension(Extension):
    id = "example.weather"
    page_keys = {
        "weather": "example_weather.consumers.consume_weather",
    }

Then page data can include:

title: Field Notes
weather: cloudy
notes:
  label: Notes
  content: This section still renders normally.

weather is passed to the registered consumer and removed before renderer dispatch sees normal sections. That means downstream renderers do not need to know about keys owned by other extensions.

Consumer callbacks receive the builder plus lifecycle keyword arguments:

Argument Purpose
key Consumed key name
value Original value from the page
page / data Remaining mutable page mapping after the key is removed
ctx Current NodeContext
config Merged page config
instance_name Named extension instance that owns the key
instance_config Config for that extension instance
hook_context Stable context object with the same lifecycle data

If the callback returns a value, Webifier stores it under internal _extension_data.<instance-name>.<key>. A callback can also mutate page directly when it wants to add hidden derived data for a renderer or hook.

Only one extension instance can consume a page key by default. A later instance can replace the consumer with override: true:

config:
  webifier:
    extensions:
      weather:
        uses: example.weather
      better_weather:
        uses: other.weather
        override: true

Content Renderers

Content renderers turn linked files into generated pages. The markdown extension registers .md, .markdown, md, and markdown. The notebook extension registers .ipynb and notebook.

def render_report(builder, src, ctx):
    html = convert_report_to_html(src)
    return wrap_in_content_page(builder, html, ctx)

Then use a YAML link with src:

webifier:
  extensions:
    reports:
      uses: my.reports
- text: Open report
  src: reports/run-001.report

Resume Example

The first-party resume extension registers kind: resume.experience:

webifier:
  extensions:
    site:
      uses: webifier.standard
    markdown:
      uses: webifier.markdown
    resume:
      uses: webifier.resume
experience:
  label: Professional Experience
  kind: resume.experience
  inline: true
  defaults:
    compact: true
  stability:
    heading:
      institution: Stability AI
      role: Senior Machine Learning Scientist
      date: Mar. 2025 - Present
      location: Toronto, ON, Canada
    expanded_heading:
      institution: Stability AI
      role: Senior Machine Learning Scientist
      date: Mar. 2025 - Present
      location: Toronto, ON, Canada
    content: |
      - Led model training work.
  earlier_role:
    compact: false
    heading:
      institution: Earlier Company
      role: ML Engineer
      date: 2021 - 2022
      location: Toronto, ON, Canada
    content: |
      - This entry renders inline instead of using the detail toggle.

If content is missing, the compact toggle is not shown. Set defaults.compact: false to render entries inline by default, or override individual entries with compact: true / compact: false. Compact entries render as short summaries first; the left-side toggle switches that entry into the full non-compact layout and back again. Set inline: true on the section for a dense divider-free stack where desktop rows read as role - institution with location • date aligned to the right.