The full path from YAML source to generated HTML.
This document describes the complete processing pipeline from YAML source to HTML output. Each phase is independent, composable, and has clear inputs and outputs.
┌──────────────┐
index.yml ────────▶│ 1. Parse │──▶ raw YAML dict
└──────┬───────┘
│
┌──────▼───────┐
│ 2. Patch │──▶ all patch directives resolved (files loaded, merged)
└──────┬───────┘
│
┌──────▼───────┐
│ 3. Defaults │──▶ all defaults applied
└──────┬───────┘
│
┌──────▼───────┐
│ 4. Interp. │──▶ all ${} resolved (refs, env, glob, pipes)
└──────┬───────┘
│
┌──────▼───────┐
│ 5. Consume │──▶ extension-owned page keys removed
│ Page Keys │
└──────┬───────┘
│
┌──────▼───────┐
│ 6. Dispatch │──▶ kind → renderer.process() → renderer.render()
│ & Render │ produces HTML fragments, triggers sub-pages
└──────┬───────┘
│
┌──────▼───────┐
│ 7. Output │──▶ .html files, search.json, copied assets
└──────────────┘
Phases 1–4 are pure data transforms — no rendering, no side effects, no file output. Extension configuration happens before rendering, then page-key consumers remove extension-owned controls before normal section rendering.
Input: YAML file path Output: Raw Python dict/list
data = yaml.safe_load(open(path))
Standard YAML parsing. No custom tags, no constructors. The YAML file is
a plain document — all webifier-specific syntax (${}, patch, defaults,
kind) is handled in subsequent phases.
Markdown files may have a YAML page preface delimited by ---:
---
title: My Page
nav: ...
---
# Content here
The parser splits the page preface from the body.
The same page-data contract is used by Markdown pages, notebook first-cell YAML
prefaces, PDF sibling page.yml files, and normal YAML pages:
title: My Page
header:
title: My Page
description: Page header text.
config:
content_pages:
toc: true
markdown:
toc: true
authors:
kind: people
content:
- name: Ada Lovelace
role: Author
Reserved keys such as title, header, nav, footer, meta, style, and
config control the generated page and are not rendered as ordinary content.
Other keys are treated like regular Webifier sections and are rendered after the
content body.
config is the page-local override layer. During rendering, Webifier deep-merges
extension defaults, root site config, and page-local config, then passes that
merged object to renderers and hooks.
Input: Raw dict with potential patch keys
Output: Dict with all patch directives resolved and removed
The patch phase walks the entire data tree depth-first and resolves
patch directives. Patch keys follow the grammar:
patch[@LOCATION][!MODIFIER]
function resolve_patches(data, base_dir):
if data is dict:
patch_keys = [k for k in data if is_patch_key(k)]
for pkey in patch_keys:
location, modifier = parse_patch_key(pkey)
loaded = load_patch(data[pkey], modifier)
if location:
place_at_path(merged, location, loaded)
else:
merge_here(merged, loaded)
# explicit keys always win over patched values
for key in non_patch_keys:
merged[key] = resolve_patches(data[key])
return merged
# Single YAML file — loads and deep-merges
patch: config.yml
# Single non-YAML file — becomes string content
patch@content: bio.md
# Multiple files — merged left-to-right
patch:
- base.yml
- overrides.yml
# @location — nested placement
patch@nav: nav.yml
patch@nav.links: links.yml
# !modifier — interpretation control
patch!yaml: data.txt # force YAML
patch@title!value: My Site # literal value
| Patch type | Explicit keys in parent | Result |
|---|---|---|
| YAML dict | Present | Explicit keys win (shallow merge) |
| YAML dict | Absent | Patch keys used |
| Non-YAML (string) | — | Value becomes the string |
| Glob (list) | — | Value becomes the list |
Children are resolved before parents. This means a patched file can itself
contain patch directives:
# index.yml
education:
patch: education/section.yml
# education/section.yml
patch: defaults.yml # resolved first
label: Education
sharif:
patch: sharif.yml # resolved second
Input: Dict with potential defaults keys
Output: Dict with defaults applied and removed
function apply_defaults(data):
if data is dict and "defaults" in data:
defaults = data.pop("defaults")
for key, value in data:
if value is dict and defaults is dict:
data[key] = {**defaults, **value} # explicit wins
# recurse into children
for key, value in data:
data[key] = apply_defaults(value)
return data
# Before defaults
education:
defaults:
kind: experience
label: false
sharif:
content: "..."
uoft:
label: UofT
content: "..."
# After defaults
education:
sharif:
kind: experience # from defaults
label: false # from defaults
content: "..."
uoft:
kind: experience # from defaults
label: UofT # explicit wins
content: "..."
defaults only applies to sibling values that are dicts.defaults is removed from the dict after application.patch so patched-in children also receive defaults.Input: Dict with potential ${...} values
Output: Fully resolved dict (all ${...} expanded)
function resolve_interpolations(data, root, base_dir):
if data is str:
if data matches /^\$\{.+\}$/:
return resolve_expression(data, root, base_dir) # full value
elif data contains "${":
return data.replace_each("${...}", resolve_and_stringify) # inline
if data is dict:
return {k: resolve_interpolations(v, root, base_dir) for k, v in data}
if data is list:
return [resolve_interpolations(item, root, base_dir) for item in data]
return data
${resolver:arg | transform:arg | transform:arg}
Split on " | " (space-pipe-space):
→ ["resolver:arg", "transform:arg", "transform:arg"]
For each segment, split on first ":":
→ (name, arg)
Execute in sequence, threading output as input to next.
Interpolation happens after patch and defaults, so:
patch are available for ${ref:...}.defaults are available for ${...}.The resolver tracks which paths are currently being resolved. If a path is
encountered again during its own resolution, a CircularReferenceError is
raised:
CircularReferenceError: Circular reference detected:
header.title → nav.brand.text → header.title
Input: Resolved page dict plus enabled extension instances Output: Page dict with extension-owned keys removed
Before top-level page rendering, Webifier lets extensions consume page keys they explicitly registered. This keeps the page syntax extensible without making every renderer know about every possible extension.
for key in page.keys():
if extension_manager.has_page_key_consumer(key):
consumer = extension_manager.consumer_for(key)
value = page.pop(key)
consumer(builder, key=key, value=value, page=page, config=page_config)
For example:
title: Field Notes
weather: cloudy
summary:
content: This still renders as a section.
If an enabled extension registered weather, that key is consumed and removed.
If no extension registered it, weather remains ordinary content and the
standard renderer treats it as a section.
Markdown front matter, notebook first-cell prefaces, and PDF page.yml files
use the same content-page contract. Their preface keys are consumed before
after-content sections such as authors or comments are rendered.
Input: Fully resolved data dict Output: HTML fragments
This is the only phase that produces HTML. The builder dispatches each node
to a renderer based on its kind (explicit or inferred).
class Builder:
def process_node(self, data, ctx: NodeContext) -> str | dict | list:
"""Universal dispatch — the core loop."""
if isinstance(data, str):
kind = self.config_defaults.get("markdown", "markdown")
return self.render_with(kind, {"content": data}, ctx)
if isinstance(data, list):
kind = self.config_defaults.get("links", "links")
return self.render_with(kind, {"items": data}, ctx)
if isinstance(data, dict):
# template (inline file) takes precedence over kind
if "template" in data:
tmpl = data.pop("template")
return self.render_with_template(tmpl, data, ctx)
kind = data.get("kind", self.config_defaults.get("section", "section"))
return self.render_with(kind, data, ctx)
return str(data)
def render_with(self, kind: str, data: dict, ctx: NodeContext) -> str:
"""Resolve kind → renderer, process, render."""
renderer_cls = resolve(kind)
renderer = renderer_cls(self)
processed = renderer.process(data, ctx)
return renderer.render(processed, ctx)
The config_defaults dict comes from config.defaults in the root
config.yml. If not set, the built-in names are used. This means every
inference point is user-configurable — users can swap out the entire
rendering stack without touching content YAML.
process_node(data)
│
├── str → MarkdownRenderer
│ process: no-op
│ render: markdown → HTML
│
├── list → LinksRenderer
│ process: resolve each link (src/href dispatch)
│ render: link list template
│
└── dict → resolve kind
│
├── kind: page → PageRenderer
│ process: handle nav, header, footer, process sections
│ render: full HTML page (page.html)
│
├── kind: section (default) → SectionRenderer
│ process: recurse into children
│ render: section with label + children
│
├── kind: experience → GenericTemplateRenderer
│ process: recurse into children (default)
│ render: renderers/experience.html
│
├── kind: publications → GenericTemplateRenderer
│ process: recurse into children (default)
│ render: renderers/publications.html
│
├── kind: chapters → ChaptersRenderer
│ process: build each chapter as sub-index
│ render: accordion template
│
├── kind: people → PeopleRenderer
│ process: resolve person images, contacts
│ render: people grid template
│
├── kind: freeform → FreeformRenderer
│ process: no-op
│ render: raw content passthrough
│
└── kind: <custom> → user renderer or template
The page renderer is special — it's the top-level orchestrator for a single HTML page:
title, nav, header, footer, config, meta).section-wrapper.html.Links in a list are processed based on their keys:
link item
│
├── src: *.md → build markdown content page, return link to it
├── src: *.ipynb → build notebook content page, return link to it
├── src: */ or *.yml → build sub-page (recursive), return link to it
├── src: *.pdf → copy to output, return link to it
├── src: * → copy to output, return link to it
├── href: * → external link (no processing)
└── kind: person → process as person card
Input: Rendered HTML, collected assets, search index Output: Files on disk
| File | Source |
|---|---|
*.html |
Rendered pages (one per index.yml + one per .md/.ipynb link) |
search.json |
Aggregated search index |
assets/css/*.css |
Core + module CSS files |
assets/images/* |
Copied image assets |
assets/** |
Any files referenced via ${asset:...} or src: |
output/
index.html ← root page
search.json ← search index
assets/
css/
main.css
codehilite.css
jupyter.css
images/
...
education/
index.html ← sub-page
courses.html ← content page (from .md)
ta/
index.html ← sub-sub-page
publications/
...
The Builder is a thin dataclass that orchestrates the pipeline and provides
services to renderers:
@dataclass
class Builder:
base_url: str
repo_full_name: str
output_dir: str = ""
assets_dir: str = "assets"
templates_dir: str | None = None
markdown_extensions: tuple = (...)
# Initialized in __post_init__
env: jinja2.Environment # Jinja2 environment (template search path)
search_index: SearchIndex # Accumulates search entries
checked_pages: set # Prevents duplicate page processing
def build_site(self, index_path: str):
"""Entry point — builds the entire site from a root index.yml."""
data = self.load_and_resolve(index_path)
self.render_with("page", data, NodeContext(base_dir=...))
self.search_index.save(...)
def load_and_resolve(self, path: str) -> dict:
"""Phases 1–4: parse, patch, defaults, interpolate."""
data = parse_yaml(path)
data = resolve_patches(data, base_dir=dirname(path))
data = apply_defaults(data)
data = resolve_interpolations(data, root=data, base_dir=dirname(path))
return data
def process_node(self, data, ctx: NodeContext) -> str:
"""Phase 5: dispatch to renderer."""
...
def render_with(self, kind: str, data: dict, ctx: NodeContext) -> str:
"""Phase 5: resolve kind, process, render."""
...
# ── Services for renderers ─────────────────────────
def render_markdown(self, raw: str, **kwargs) -> str:
"""Render markdown string to HTML."""
...
def resolve_asset(self, path: str, ctx: NodeContext) -> str:
"""Copy asset to output, return resolved URL."""
...
def add_search_content(self, slug, content, **kwargs):
"""Index content for search."""
...
def build_content_page(self, link: dict, ctx: NodeContext) -> dict:
"""Build a content page from a .md or .ipynb link."""
...
def build_subpage(self, link: dict, ctx: NodeContext) -> dict:
"""Build a sub-page from a directory or .yml link."""
...
Renderers access the builder for shared functionality:
| Method | Purpose |
|---|---|
process_node(data, ctx) |
Recursively process a child node |
render_with(kind, data, ctx) |
Process + render with a specific kind |
render_markdown(raw) |
Convert markdown string to HTML |
resolve_asset(path, ctx) |
Copy asset file, return output URL |
add_search_content(...) |
Add content to search index |
add_search_item(...) |
Add a link item to search index |
build_content_page(link, ctx) |
Generate a .md/.ipynb content page |
build_subpage(link, ctx) |
Generate a sub-page from index.yml |
The system is recursive — pages can contain links to sub-pages, which are themselves full pages:
index.yml (root page)
└─ education/ (sub-page)
├─ courses.md (content page)
└─ ta/ (sub-sub-page)
└─ ...
Each sub-page goes through the full pipeline (load → patch → defaults → interpolate → dispatch → output) independently. The builder tracks processed pages to prevent infinite loops.
Sub-pages inherit certain global context from the root:
| Context | Inherited? |
|---|---|
| Base URL | Yes |
| Config (search, analytics) | Yes |
| Global nav | Yes (for consistent navigation) |
| Global footer | Yes |
| Search index | Yes (shared, accumulated) |
| Templates environment | Yes (shared) |
Search indexing is a side effect of rendering, not a separate phase.
Renderers call builder.add_search_content() and builder.add_search_item()
during their process() step.
class LinksRenderer(RendererModule):
def process(self, data, ctx):
for link in data["items"]:
if ctx.search_links and "href" in link:
self.builder.add_search_item(
slug=link["href"],
url=link["href"],
title=link.get("text", "Link"),
)
return data
After all rendering is complete, the builder writes search.json.
Assets (images, PDFs, custom files) are copied to the output directory on demand. When a renderer or resolver encounters a local file reference:
output/assets/... (preserving relative path).This happens via builder.resolve_asset() or the ${asset:...} resolver.
Enabled extension assets are copied at startup:
extension asset mounts → output/assets/...
Each phase produces clear, actionable errors:
| Phase | Error type | Example |
|---|---|---|
| Parse | YAMLParseError |
Malformed YAML syntax |
| Patch | PatchError |
File not found, merge conflict |
| Defaults | DefaultsError |
defaults value is not a dict |
| Interpolate | ResolverError |
Unknown resolver, circular reference |
| Dispatch | KindError |
Unknown kind, no template found |
| Render | RenderError |
Jinja2 template error |
All errors include:
education.sharif.content)${}, patch, or kind value that failedKindError: in index.yml at "work":
kind: "experiance"
No renderer found for "experiance".
Did you mean: experience, inline-experience?
Available renderers: section, freeform, chapters, people, experience, ...