Render Capture and Manifests

Capture rendered block facts for build diffs, search indexes, and freeze-cache analysis

5 min read 997 words

Render capture is Kida's opt-in observability path for build tools. One render can produce normal HTML while also recording selected block output, semantic metadata, content hashes, and explicitly selected context values. A build coordinator can accumulate those facts into aRenderManifest, compare two builds, derive a search manifest, and inspect site-scopedFreezeCache candidates.

This is separate from the{% cache %} runtime fragment cache. Fragmentin this API means a captured block record; it does not change template rendering.

The complete runnable source is in examples/render_capture_manifest.

Preferred workflow

Compile capture hooks into the environment, create one capture context per render, and add the completed capture to a caller-owned manifest:

from kida import (
    DictLoader,
    Environment,
    FreezeCache,
    FreezeCacheStats,
    RenderManifest,
    SearchManifestBuilder,
    captured_render,
    get_capture,
)

env = Environment(
    loader=DictLoader(
        {
            "page.html": (
                "{% block content %}<main>{{ doc.body }}</main>{% end %}"
                "{% block nav %}<nav>Docs</nav>{% end %}"
            )
        }
    ),
    enable_capture=True,
)

freeze_cache = FreezeCache()
manifest = RenderManifest(freeze_cache=freeze_cache)
template = env.get_template("page.html")

with captured_render(capture_context=frozenset({"doc"})) as capture:
    assert get_capture() is capture
    html = template.render(doc=doc)

assert get_capture() is None
manifest.add("/guide", capture)

content = capture.blocks["content"]
print(content.html, content.content_hash, content.depends_on)

search_manifest = SearchManifestBuilder().build(manifest)
print(search_manifest["entries"])

enable_capture=Trueis a compilation setting. Templates compiled with the defaultFalsehave no capture hooks, so wrapping their render in captured_render() yields an empty blocksmapping. Configure the environment before loading templates.

The defaultpreserve_ast=True lets captured Fragmentrecords include block roles, dependencies, and cache scope from static analysis. With preserve_ast=False, rendered HTML and hashes are still captured, but those analysis-derived fields fall back to"unknown"or an empty dependency set; FreezeCachetherefore has no proven site-scoped candidates to record.

Ownership and lifecycle

The capture context owns one mutableRenderCapture:

  1. captured_render()creates and activates it.
  2. Template.render() sets template_name, snapshots selected context keys, and records captured blocks asFragmentvalues.
  3. Leaving the context deactivates capture but does not invalidate the returned object.
  4. The caller adds the completed capture to aRenderManifestor inspects it directly.

get_capture()exists for framework helpers that run inside the active render. It returnsNoneoutside a capture context. Application code that already has the context manager'scapturevalue should use that value directly.

capture_blocks=None captures all rendered blocks. Pass a frozensetto keep only selected names. Context capture is deliberately stricter: capture_context=Nonecaptures no context, and callers opt in to individual top-level keys. Values are shallow references, not serialized or deep-copied snapshots, so capture only data the build tool needs and serialize it before the application mutates it if historical values matter.

Captured records

RenderCapturecontains:

Field Meaning
template_name Resolved template name for the render
blocks Block name toFragment
context_keys Explicitly selected top-level context values

EachFragment records name, role, rendered html, a deterministic content_hash, depends_on, and inferred cache_scope. Use the hash to compare content; do not treat it as a security or authenticity digest.

Compare build manifests

RenderManifestowns captures from a batch in insertion order. Add each capture only after its context exits:

old_manifest = RenderManifest()
new_manifest = RenderManifest()

# ...render old and new pages, then call manifest.add(url, capture)...

diff = new_manifest.diff(old_manifest)

diff is a frozen ManifestDiff:

  • added and removedcontain URLs;
  • changed[url][block] is (old_hash, new_hash);
  • unchangedcounts URLs whose captured block hashes agree.

The direction matters: callnew.diff(old). all_fragments()iterates every (url, Fragment) pair, while unique_content_hashes()reveals repeated captured content that a build tool may be able to deduplicate.

Optional freeze-cache analysis

Attach aFreezeCache when constructing the manifest. RenderManifest.add() then records captured blocks only when analysis classifies them as site-scoped and they have no context dependencies:

freeze_cache = FreezeCache()
manifest = RenderManifest(freeze_cache=freeze_cache)

# ...capture and add pages...

cached = freeze_cache.get_cached_blocks("page.html")
stats: FreezeCacheStats = freeze_cache.stats
print(cached, stats.blocks_cached, stats.cache_hits)

get_cached_blocks() returns a new dict[str, str] or None. It is an integration output for a build coordinator; obtaining it also updates hit/miss statistics. A changed hash for a supposedly site-scoped block invalidates that entry rather than serving stale HTML. The preferred capture workflow does not change render semantics or promote underscored render plumbing to public API.

FreezeCacheStats exposes cache_hits, cache_misses, invalidations, and blocks_cached. Hits count returned blocks, not page requests.

Optional search manifest

SearchManifestBuilder turns a RenderManifestinto a versioned mapping with entriesand optional category/tag facets. Its default field adapter, default_field_extractor, reads the captured docobject convention:

from kida import (
    RenderCapture,
    SearchEntry,
    SearchManifestBuilder,
    default_field_extractor,
)


def extract(url: str, capture: RenderCapture) -> SearchEntry:
    entry = default_field_extractor(url, capture)
    entry.setdefault("c", "Documentation")
    return entry


search = SearchManifestBuilder(field_extractor=extract).build(manifest)

SearchEntry is the typed shape for optional title (t), description (d), raw body, category (c), tags, and table-of-contents fields. The default extractor prefersdoc.body or doc.content, so search indexes raw source text instead of rendered HTML. When an extractor supplies no body, the builder falls back to captured block HTML whose semantic role has a positive weight.

Thread and task safety

The active capture is stored in aContextVar. Separate threads and async tasks therefore get separate active values when each establishes its own captured_render()context, and nested contexts restore the outer capture on exit.

RenderCapture, RenderManifest, FreezeCache, FreezeCacheStats, and SearchManifestBuilder.role_weightsare mutable caller-owned objects. They do not provide internal synchronization. Create one capture per render; let workers return completed captures; aggregate them into a manifest and cache in one coordinator, or provide external synchronization. Do not concurrently mutate one manifest or freeze cache from multiple workers.

API summary

All names in this workflow are exported fromkida:

API Role
captured_render, get_capture Activate and inspect render-local capture
RenderCapture, Fragment Per-render and per-block facts
RenderManifest, ManifestDiff Batch accumulation and build comparison
FreezeCache, FreezeCacheStats Site-scoped candidate reuse and statistics
SearchManifestBuilder, SearchEntry Search-manifest construction and typed entry shape
default_field_extractor Default captured-docadapter

No capture or manifest object is required for ordinary rendering.