inmake DOCS
Docs / Server / Live edit & renderer internals

Live edit & renderer internals

Edit-mode provenance and the renderer building blocks.

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 pipeline WebSite uses).
  • 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 → a SafeString, 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; blocks envelopes → semantic HTML via core-lib's blocksToHtml (element blocks emit the fragment the reference resolver attached; an image block's variant pick resolves through the block file node's denormalized variants), file nodes → FileUrlValue hybrids, 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 denormalized variants carrier plus the original when the value carries its width (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's metadata:[…] 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 renders alt="" rather than its own URL; a size variant may not use one of them. This is why a picture never needs a companion alt text field beside it in a schema. A kind:'file' node (a PDF, a document, an archive — the same node shape plus the denormalized name / size / type the editor and the agent upload tool write) adds the INFO_NAMES — {{doc.file.name}}, {{doc.file.size}} (bytes), {{doc.file.size_text}} (localized through fileSizeText(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 _dt stamp (core-lib schema_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 declared kind:'datetime' value becomes a LocalizedDateValue — a String hybrid whose primitive is the Intl.DateTimeFormat display form ({{published}} → 11. 6. 2026 on a cs_CZ page), .iso is the stored machine form (<time datetime="{{published.iso}}">), and toJSON() keeps DROP_DATA on the stored form. Which parts render follows the field's date/time flags ∩ what the value carries; unparseable values pass through raw. WebSite applies it to page, shell and render:true component data before rendering (localizeDocDates); a render server reads NO schema doc. A doc saved seconds ago through a merge path may not carry _dt yet — its dates render raw until the core's stamp repair pushes the stamped doc (data), which evicts the page. The schema-guided localizeDates stays 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 the DROP_DATA bootstrap; flatten passes the hybrids through untouched.
  • rebaseHtml(html, basePath) / rebaseCss(css, basePath) / normalizeBasePath(basePath) — root-absolute link rebasing for path-prefixed deployments (serveCode applies rebaseCss to every linked sheet, so url(/assets/…) fonts and images in design.css follow the prefix too).
  • localizeHtml(html, prefix, {basePath, codes, assetsPrefix}) / localeGlobals(...) — the path-mode link safety net and the DROP_LOCALE_BASE / DROP_LOCALE_MODE / DROP_ALTERNATES bootstrap (see "Web routing"); run localizeHtml before rebaseHtml.
  • matchRoute / normalizePath (src/web/routes.ts) — the shared three-kind URL→document routing.