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:
captured_render()creates and activates it.Template.render()setstemplate_name, snapshots selected context keys, and records captured blocks asFragmentvalues.- Leaving the context deactivates capture but does not invalidate the returned object.
- The caller adds the completed capture to a
RenderManifestor 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:
addedandremovedcontain 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.