Live Edit provenance (edit_provenance / edit_annotate / edit_runtime)#
The edit render mode behind the admin Studio's click-to-edit surface.
It is browser-only: the admin's Studio renderer
(admin/src/studio/renderer/page.ts) threads a BodyEditContext into
renderBody and a location seed (at) into resolveReferences, annotates
the markers with annotateEditMarkers and appends EDIT_RUNTIME_JS (both
exported from the package index) to its script set. WebSite / WebServer
never render edit markup — the former server-side mode (allowEdit +
?__drop_edit=1) was removed on 2026-09-11 after the Studio renderer made it
callerless — so a published page can never carry provenance attributes. The
engine pieces below are what the browser host runs; a normal render passes no
edit context and is provably inert.
In edit mode each fragment renders twice (the R1 shadow self-check): once
clean, once with a provenance-boxed context — every markable leaf becomes a
String hybrid whose toHTML() (the SafeString protocol) wraps the value in
private-use-area sentinels around a base64url payload
{d: "{locale}/{collection}/{id}", f: "field.path", k?, t?, e?}. Doc identity
is derived by walking the resolved data in parallel with the STORED doc (the
localizeDates trick), so fields of resolved references provenance to the
referenced doc and render:true fragments edit against their own doc.
Element placements edit in place: the reference resolver threads an
optional location seed (ResolveReferencesOptions.at, set only on edit
renders — normal renders are provably inert) down its walk, so
renderElement renders each live placement provenance-marked against the
OWNING doc with paths crossing the wrapper ({placement}.data.{…}) and the
element id riding every payload as e (the admin resolves field definitions
against the element's fields). The page-flatten walk complements it by
stepping INTO stored placements (placementWalk) — embedded placements and
directly-emitted placement data mark too — while the attached html
fragment key is never claimable (phantom-field guard). Blocks-embedded
elements stay inert (the blocks cut line). Image renditions mark too: a
file value is a FileUrlValue hybrid, so {{cover}} and every accessor a
template actually uses — {{cover.large}}, {{cover.srcset}},
{{cover.original}}, an unknown name falling back to the original — return
boxed strings carrying the field's payload (String coercion, comparison and
JSON stay clean). Without that, the common variant form
(<img src="{{ image.large }}" srcset="{{ image.srcset }}" alt="{{ title }}">)
emitted no provenance for the image at all and a click on the photo resolved
to whatever else the tag carried (its alt binding). The variant properties
are defined configurable for exactly this reason — a non-configurable,
non-writable data property pins a proxy get trap to the stored value. The
runtime orders an element's payloads text → src/srcset → other attributes,
so [0] (what the dialog and the hover tip use) is the image, whatever order
the template author wrote the attributes in. The post-processor
(edit_annotate.ts) turns
sentinel pairs into data-drop-edit / data-drop-edit-rich /
data-drop-edit-{attr} attributes (hoisting onto the parent tag when a pair
spans its whole text), treats <script>/<style>/comments as opaque, and a
final sweep guarantees no sentinel survives (R2). A fragment whose
stripped marked render differs from its clean render is served clean and the
page gains data-drop-edit-degraded on <html>. Data-borne sentinel
structure (spoofed markers) is neutralized at box construction — it can never
reach the scanner as marker structure (this covers every emission channel,
triple-stash String coercion included) — and author-planted
data-drop-edit* ATTRIBUTES inside stored rich HTML are stripped from every
tag (the renderer never emits them pre-annotation, so any present were
planted). Plain-text hoists swallow the template whitespace around the pair
so the element's textContent equals the stored value exactly (the inline
editor captures textContent — baked-in indentation would compound into the
doc on every edit). The marked pass carries its flag on Handlebars' data
channel (options.data.dropEdit), never as a context key, so templates that
enumerate the root context can't diverge. Raw mustaches mark too: a
triple-stash ({{{ field }}} / {{& field}}) string-concatenates its value
— escapeExpression, and with it the toHTML() hook the boxes ride, is never
called — so a boxed plain string rendered raw used to lose its markers
entirely while the same content stored as a {type:'html'} envelope (a
SafeString whose string IS the marked text) stayed editable: editability
depended on the STORED SHAPE of each value, which is exactly the mix
agent-filled content ends up with. The marked pass therefore compiles a
rewritten AST (rewriteRawMustaches — every bare raw mustache becomes
{{{ __dropRaw path }}}; helper calls, @data paths and literals are left
alone) and the __dropRaw helper re-emits the box's markers around the
UNESCAPED value, upgrading the payload kind to rich (an <svg> root: icon)
when the value carries tags or entities so the runtime routes it to the
dialog instead of the textContent inline editor. The clean pass compiles
the untouched template, so the rewrite can only ADD marker structure and R1
still holds; __dropRaw is edit-only and never appears in a stored
template, so the admin's browser port of the helper set does not need it.
Asserted by .dev/live-edit-triple-stash.mjs. Edit pages also carry the inline
live-edit runtime (edit_runtime.ts): the in-iframe state machine the
admin drives over postMessage (enable/mode/saveState/revert/flush in,
ready/editStart/change/editEnd/flushed/extended/hotkey/nav
out). The runtime reports values; every write decision lives in the admin
(useLiveEdit.ts + liveEditQueue.ts). The runtime carries a Studio
seam for the admin's sandboxed (opaque-origin) preview frame: it posts with
'*' when location.origin is null (the parent checks the message source
instead), reports window.DROP_PATH in ready (the page's URL is
about:srcdoc there), and when window.__dropStudio is present — set by the
admin renderer's in-frame runtime — it navigates through the host
(__dropStudio.navigate) and answers refreshSoft through the host
(__dropStudio.refresh → the host refetches the page's sources, re-renders,
patches, and calls back) instead of touching window.location or fetching
itself; it also drops its hover overlays on the host's drop:preview-update
event. On a WebSite-served page refreshSoft is the flawless-live
confirmation path: the runtime re-fetches its own page (live mode re-renders
from fresh data) and morphs the DOM in place — scripts untouched,
DROP_DATA swapped, overlay elements preserved — so derived rendering and
dialog saves surface without a reload or scroll jump. The morph writes the
fresh HTML's attributes onto the surviving nodes, which strips JS-added state
classes page-wide (is-revealed, is-visible …), so it ends with a
visibility sweep: every element's hiding signature — opacity, visibility,
the whole transform family (transform/translate/scale/rotate are
independent properties) and blur — is snapshotted before the morph and
compared after, and anything the morph newly hid is pinned to its final
visible state inline. One-shot entrance animations never re-fire (their
observers hold dead references), so without the sweep morphed content
silently vanishes — a translate: 0 100% line reveal inside an
overflow: hidden box hides text at full opacity.
Normal-mode output is byte-identical to pre-edit-mode builds (the clean pass
is untouched — .dev/live-edit-triple-stash.mjs asserts a normal render
carries no annotation and equals the clean render), and DROP_DATA is
byte-identical between modes.
Renderer building blocks (web_renderer)#
Static-site consumers (see examples/instore-static) don't need a running
WebSite — the renderer's pure functions are exported directly (also
headMetaTags, styleTag, scriptTag, jsonldScripts, escapeHtml,
bcp47 — the head-tag helpers the Studio renderer reuses):
renderWebPage(options)— compose one standalone HTML document from a template + data + design CSS + shell/head options (the full pipelineWebSiteuses).renderBody(html, data, storageBase, extras?)— Handlebars-render just a template body against flattened data.flatten(value, storageBase)— the data flattening (a string carrying an HTML element tag → aSafeString, i.e. markup under{{ }}as under{{{ }}}— the text storage contract: text is a plain string, the legacy{type:'raw'|'html'}envelopes still flatten → strings / SafeStrings;blocksenvelopes → semantic HTML via core-lib'sblocksToHtml(element blocks emit the fragment the reference resolver attached; an image block'svariantpick resolves through the block file node's denormalizedvariants), file nodes →FileUrlValuehybrids, icons → inline SVG) exactly as the admin preview does it.fileUrlValue(node, urlOf)/FileUrlValue(src/web/file_value.ts) — the template hybrid for a stored file node{path, ref, variants?, variant?, metadata?}:{{cover}}is the picked variant's URL (else the original's),{{cover.thumb}}any variant by name (an unknown/missing name falls back to the original — old uploads never break),{{cover.srcset}}a ready width-descriptor srcset from the denormalizedvariantscarrier plus the original when the value carries itswidth(the upload endpoints stamp it since 2026-10-27 — P3; entries without a width are omitted),{{cover.width}}/{{cover.height}}the original's pixel size when known,{{cover.original}}always the original, and{{cover.alt}}/{{cover.caption}}the per-usage metadata the editor typed for this picture (the keys the field'smetadata:[…]schema declares;{{cover.metadata}}is the map). Those names —META_NAMES:metadata,alt,caption,title,description,credit,author,copyright— are EXEMPT from the URL fallback and resolve empty when nothing was typed, so an image with no alt text rendersalt=""rather than its own URL; a size variant may not use one of them. This is why a picture never needs a companionalttext field beside it in a schema. Akind:'file'node (a PDF, a document, an archive — the same node shape plus the denormalizedname/size/typethe editor and the agent upload tool write) adds theINFO_NAMES—{{doc.file.name}},{{doc.file.size}}(bytes),{{doc.file.size_text}}(localized throughfileSizeText(bytes, locale)— "15.3 MB" / "15,3 MB"),{{doc.file.type}}(MIME),{{doc.file.ext}}(from the path) — exempt from the URL fallback the same way, so a download row renders with zero file-doc reads; a declared metadata key of the same name wins. Zero render-time reads — the carrier is written onto the value by the admin editor and the agent image tools at upload/pick/generate time.localizeDates(src/web/localized_date.ts) — stamp-guided datetime localization: the document's_dtstamp (core-libschema_stamps.ts— value path →{date?, time?},{ref: true}on reference fields, written by the core on every save from the doc's template) says which values are dates; a resolved reference is localized by the TARGET's own_dt, so the walk needs no schema read at all. Every declaredkind:'datetime'value becomes aLocalizedDateValue— a String hybrid whose primitive is theIntl.DateTimeFormatdisplay form ({{published}}→11. 6. 2026on acs_CZpage),.isois the stored machine form (<time datetime="{{published.iso}}">), andtoJSON()keepsDROP_DATAon the stored form. Which parts render follows the field'sdate/timeflags ∩ what the value carries; unparseable values pass through raw.WebSiteapplies it to page, shell andrender:truecomponent data before rendering (localizeDocDates); a render server reads NO schema doc. A doc saved seconds ago through a merge path may not carry_dtyet — its dates render raw until the core's stamp repair pushes the stamped doc (data), which evicts the page. The schema-guidedlocalizeDatesstays exported for callers that hold a schema (the admin's Studio stamps unsaved drafts that way). The stamps (_dt,_f) are stripped from template data and theDROP_DATAbootstrap;flattenpasses the hybrids through untouched.rebaseHtml(html, basePath)/rebaseCss(css, basePath)/normalizeBasePath(basePath)— root-absolute link rebasing for path-prefixed deployments (serveCodeappliesrebaseCssto every linked sheet, sourl(/assets/…)fonts and images indesign.cssfollow the prefix too).localizeHtml(html, prefix, {basePath, codes, assetsPrefix})/localeGlobals(...)— the path-mode link safety net and theDROP_LOCALE_BASE/DROP_LOCALE_MODE/DROP_ALTERNATESbootstrap (see "Web routing"); runlocalizeHtmlbeforerebaseHtml.matchRoute/normalizePath(src/web/routes.ts) — the shared three-kind URL→document routing.