Skip to content
Dee Wan 1.0 is not yet released (pre-MVP). See the 1.0 product boundary.

Page-document contract

A composed page in Dee Wan is an ordinary content item whose model has a json field named slides and a text field named title. The slides field holds a page document: structured data that lists the page’s sections in order. It carries text, links and media references. It never carries HTML, CSS or scripts. Your renderer and your theme decide how each section looks.

This page describes the document a renderer receives from the public read API, and how to parse it.

Read the page item from GET /v1/<model>/<slug>?locale=<locale>. The document is in data.fields.slides, and the page title is in data.fields.title. Each locale has its own document, so read the locale you are rendering.

const res = await fetch("https://example.com/v1/page/about?locale=en");
if (!res.ok) throw new Error(`${res.status}`);
const { data } = await res.json();
const parsed = parsePublicRenderablePage(data.fields.slides);

The item also carries data.page_contract.required, the lowest contract version that can read this page. See Compatibility.

The contract is a small TypeScript module with no dependencies. It imports nothing outside its own directory, so it runs in Cloudflare Workers, in browsers and in Node.

  • A generated site already contains it, copied unchanged, at src/lib/page-contract/.
  • Otherwise it lives in the Dee Wan repository at backend/src/lib/page-contract/. There is no published npm package. Copy the whole directory from the Dee Wan version you run, and replace it when you upgrade. Do not retype the types by hand.
Function Returns
parsePublicRenderablePage(raw) { sections }
parsePublicPageDocument(raw) the parsed document

Use parsePublicRenderablePage. It turns a stored page into one ordered list of sections.

Each parser takes one argument, the raw value of fields.slides (an object, or a JSON string). It needs no database and makes no network call.

Every parser returns a value and never throws:

type Parsed<T> =
| { ok: true; value: T }
| {
ok: false;
error: { code: string; message: string; detail: Record<string, unknown> };
};

When a page is refused, do not render part of it. Treat it as an error for that URL.

PAGE_FORMATS is [2, 3]. A document states format: 2, or format: 3 when at least one of its sections pins a reusable component (see Reusable components). A missing format, or any other value, is refused with unreadable_page. An empty or missing document is refused with unreadable_page too, so an item with no page content never renders as a blank page.

Format 2 is unchanged: its component value must be null. A format-3 document that pins no component is refused with noncanonical_default, so each page has exactly one canonical representation.

The format says how a page is stored. It is not the contract version, which says which vocabulary reads it (see Compatibility).

Each locale stores its own document. The structure (which sections, in which order, with which variant and layout) belongs to this locale, and two locales of one page may differ in every part of it. The content for this locale is in props, keyed by section id.

{
"format": 2,
"sections": [
{
"id": "intro",
"type": "hero",
"variant": "split",
"layout": "default",
"component": null
},
{
"id": "story",
"type": "text_media",
"variant": "balanced",
"layout": "media_side=start",
"component": null
}
],
"props": {
"intro": {
"heading": "Fresh cakes daily",
"subheading": null,
"media": null,
"link": { "label": "See the menu", "href": "/menu" }
},
"story": {
"heading": "Our story",
"paragraphs": ["We started in 2019."],
"media": null
}
}
}

Rules:

  • Every section must have props, and every props entry must belong to a section.
  • component must be present. In a format-2 page it is null. In a format-3 page it is null, or on a custom section a component pin.
  • Props must not repeat id, type or a layout key such as media_side. Those belong to the structure.
  • A section may carry appearance in the structure. It is optional, and belongs to this locale like variant. See Section appearance.

The parser gives one shape:

type SectionType =
| "hero"
| "rich_text"
| "text_media"
| "feature_grid"
| "cta"
| "cards"
| "stats"
| "faq"
| "testimonials"
| "media"
| "logos"
| "steps"
| "pricing"
| "menu"
| "contact"
| "latest_entries"
| "form"
| "custom"
| "search";
type RenderablePage = {
sections: Array<{
id: string;
type: SectionType;
variant: string;
layout: string;
component: {
component_id: string;
version_id: string;
artifact_digest: string;
} | null;
appearance?: {
background?: "muted" | "inverse" | "accent";
spacing?: "compact" | "roomy";
motion?: "reveal" | "parallax";
};
/** `custom` sections only: the registered section version this section pins. */
definition?: { key: string; version: number; digest: string };
props: SectionPropsMap[SectionType];
}>;
};

The real type is a union keyed on type, so switching on section.type narrows props. SectionPropsMap names the props of each type.

Optional values come back as null when absent. The fields listed under Later fields are the exception: they are left out. Text is trimmed, except the text of a formatting run. Every item in a list is a closed object. An unknown key is refused.

Type Props
hero heading (required), subheading, media, link. Later: eyebrow, secondary_link
rich_text heading, paragraphs (required, 1–12)
text_media heading, paragraphs (required, 1–12), media
feature_grid heading, features (required, 1–12 items of { title, body })
cta heading (required), body, link (required). Later: secondary_link
cards heading, items (required, 1–12 items of { title, body, media, link }, where each item requires title)
stats heading, items (required, 1–8 items of { value, label, note }, where each item requires value and label)
faq heading, items (required, 1–20 items of { question, answer }, both required)
testimonials heading, items (required, 1–12 items of { quote, name, role, media }, where each item requires quote and name)
media heading, items (required, 1–24 image or video items)
logos heading, items (required, 1–24 items of { media, name, link }, where each item requires media and name)
steps heading, items (required, 1–12 items of { title, body }, where each item requires title)
pricing heading, plans (required, 1–4 items of { name, price, period, description, features, link, highlighted }, where each plan requires name and price)
menu heading, groups (required, 1–8 items of { title, items }, where items holds 1–30 entries of { name, description, price } and each entry requires name)
contact heading, body, address, hours (0–14 items of { label, value }), phone, email, map_link, link
latest_entries heading, model (required), title_field (required), cover_field, summary_field, date_field, limit (required, whole number 1–12), empty_text (up to 200 characters)
form heading, body, fields (required, 1–12 items of { name, kind, label, required, help, options }), consent, submit_label (required), success_text (required)
custom heading, fields (the values of the registered section the structure pins)
search heading, placeholder (up to 80 characters)
shared none. The structure names the shared section (shared.entry). A renderer never receives one.

A link is { label, href }. A media reference is { media_id, url, alt }, where alt may be null. List items hold media too: media in cards, testimonials and logos items, and media or poster in media items.

A media reference may also carry its framing:

  • crop: { x, y, width, height }, fractions of the stored image from 0 to 1. Width and height are above 0, and the rectangle stays inside the image.
  • focal: { x, y }, fractions from 0 to 1. The point to keep in view when a box cuts the image.

Each key is present only when set; it is never null. Any other structure is refused. Framing belongs to this one use of the image, so the same image can be framed differently in two sections. A list answer’s cover (EntryMedia) carries the entry’s framing the same way.

Field Meaning
hero.eyebrow A short line above the heading.
hero.secondary_link A second link. Allowed only when link is set.
cta.secondary_link A second link.

These fields are left out when unused, never null or empty. After parsing, an unused one is absent (undefined). A public read refuses an explicit null or "" with noncanonical_default.

  • media: each item is { kind: "image", media, caption } or { kind: "video", provider, video_id, title, poster, caption }. An image item needs media. A video item needs provider, video_id and title. See Video.
  • pricing: features is a list of text, 0–12 entries. Absent reads as []. highlighted is true or absent, and at most one plan in a section is highlighted. false is refused with noncanonical_default.
  • contact: needs at least one of address, phone, email, map_link or one hours entry. hours absent reads as []. phone holds digits, spaces, (, ), . and -, with an optional leading +, at least 3 characters. email is one address, not a mailto: link. map_link.href must start with https://.
  • logos: every item needs an image.

The section stores a query mapping, never entries or a cursor. model and every *_field are slugs (^[a-z][a-z0-9_-]{0,63}$). A renderer resolves it at request time through the site’s public list API, GET /v1/<model>, selecting only the mapped fields: newest first by date_field when set (entries without it last), otherwise newest published first, at most limit entries. A missing model or field, a refused query or an unreadable answer is a render error for that section, never an empty list. Only a valid answer with no entries shows empty_text or neutral copy. A summary from a rich-text field is rendered by renderRichDocument (rich-document.ts) before it is inserted. Pass the site’s allowed embed origins (GET /v1/_embeds) as embedProviders; without them an embed from a configured origin draws as a plain link.

collection.ts holds the shared query contract every Dee Wan renderer uses: entriesQuery, readEntriesPage, resolveLatestEntries, walkToPage and the refusal codes in COLLECTION_REFUSALS. See Blog sites for the editor.

A form names fields. It never names an endpoint, a recipient or a secret: where a submission goes is the site’s own setting (Settings → Integrations), and a visitor cannot choose it.

  • Each field is { name, kind, label, required, help, options }. Every key must be present.
  • name is 1–32 lowercase letters, digits or _, starting with a letter. Names are unique in a form.
  • kind is text, email, phone, textarea, select, checkbox or file.
  • options lists 2–20 choices for select, and is [] for every other kind.
  • A file field may also set accept (up to 10 exact supported MIME types or extensions), max_files (1–5, default 1) and max_bytes (1–5242880, default 5242880). Other kinds refuse these keys. Empty accept allows the image/document catalog in FORM_FILE_TYPES; wildcards are refused. All files together are limited to 5 files and 10485760 bytes. File bytes must match a supported format.
  • consent is the statement a visitor must accept, or null for no consent box.

forms.ts is the interaction registry and the submission contract, shared by every renderer and the intake:

Export What it is
INTERACTIONS, FORM_SUBMIT_PATH The registered actions (registry version 3): form.submit, posted to /v1/interactions/form.submit; comment.submit, posted to /v1/interactions/comment.submit; and comment.react, posted to /v1/interactions/comment.react.
readSubmission(raw) The closed submission envelope: { action, page, locale, section, key, values, consent, website }. Anything else is null.
checkSubmission(form, values, consent, files?) The values against the published form, with a FieldError per problem.
FORM_STATES, FORM_STATE_COPY, FIELD_PROBLEM_COPY Every state a form can display, and its text.
readSubmissionAnswer(raw), stateOfAnswer(answer) The intake’s JSON answer, and the state it leads to.
formViews(sections, context), readFormResult(query, section) What a renderer hands its form component, including the outcome read back from the page address.

Forms with file fields send multipart/form-data: scalar inputs use values.<name>, checkboxes use checks.<name> with true when checked, and file parts repeat files.<name>. The ordinary action, page, locale, section, key, consent and website controls remain scalar parts. The JSON envelope stays unchanged for forms without files. Files are not JSON scalar values. The multipart body is bounded to 10 MiB of files plus 64 KiB overhead, with at most 16 KiB of scalar parts. An identical retry includes the same file names, types, sizes and bytes and returns the first receipt; changing a file with the same key is refused.

Retained detail adds optional attachments: Record<fieldName, Array<{ id, name, type, size }>>. GET /api/forms/submissions/:id/attachments/:attachmentId returns private attachment bytes with site-scoped site:infra authorization. Expired, erased, missing and wrong-site attachments cannot be downloaded. No storage keys or public URLs are exposed. See File attachments.

website is a honeypot: a person never sees it, and a submission with a value in it receives an accepted response but is never stored. key (16–64 letters, digits, _ or -) makes a retry the same submission: the same key with the same values replays the first receipt, and with different values is refused.

The page never carries markup for a form. A renderer draws it with a platform component: in the managed renderer src/runtime/Form.astro, in an exported site src/components/FormRuntime.astro. A template may place that component; it may never write <form> or <script> itself.

comments.ts is the comment contract for every model that takes comments: readCommentSubmission and checkComment for a comment, readReactionSubmission and readReactionAnswer for a Helpful reaction, readCommentsPage and walkComments for the approved comments GET /v1/_comments serves in one of COMMENT_ORDERS (oldest, newest, best), and commentsView for what an entry page draws: threads with reaction counts, order links, pages, the form, and the outcome read back from the address. Both renderers draw it with a platform component, src/runtime/Comments.astro in the managed renderer (Blog posts and routed entry pages) and src/components/Comments.astro in an exported site (Blog posts and entry pages of models that take comments). It carries no email, moderation state or reaction actor. See Visitor comments.

A developer can register a typed section through a managed template. Its definition and its Astro component are built in the pinned offline sandbox, and accepting the template under site:deploy promotes them to an immutable version. A page pins that exact version in its section structure:

{
"id": "sl_team",
"type": "custom",
"variant": "grid",
"layout": "default",
"component": null,
"definition": {
"key": "team-grid",
"version": 2,
"digest": "<64 hex characters>"
}
}
  • definition is required on a custom section and refused on every other type. In a format-2 page component stays null: that format never reinterprets it.
  • digest is SHA-256 over the canonical JSON of { definition, source }: the definition the developer declared and the exact component bytes. A renderer never resolves a key to “latest”.
  • The locale props are { heading, fields }. fields holds the definition’s values: text, a flag, a link, a media reference, null, or a list of items holding those.
  • Without the definition, a parser checks fields against that bounded grammar only: markup, unsafe links and malformed media are refused all the same. With it (ParseContext.definitions), every value is checked against the pinned definition, and an unknown pin is unknown_section_definition.
  • sections.ts reads a definition (parseSectionDefinitionSource, parseSectionDefinition), classifies a change between two versions (definitionChange: unchanged, compatible or breaking, with reasons) and moves content onto a newer version (migrateCustomFields), keeping only values the new version still accepts.

A renderer that has no component for the pinned identifier refuses the page. An exported site carries every pinned version’s component under src/registered/<digest>/, and a build whose pages pin a version it cannot carry is refused.

A reusable component is a registered section that was made from a section on a page: generated, built in the pinned offline sandbox, reviewed and promoted under site:deploy (see Build and edit pages). A page that uses one is stored in format 3, and the custom section pins the exact version beside its definition:

{
"id": "sl_team",
"type": "custom",
"variant": "grid",
"layout": "default",
"component": {
"component_id": "rcp_…",
"version_id": "rcv_…",
"artifact_digest": "<64 hex characters>"
},
"definition": {
"key": "team-grid",
"version": 2,
"digest": "<64 hex characters>"
}
}
  • component is accepted only in a format-3 page and only on a custom section that also holds its definition. A bare name or slug is refused (invalid_slide): a name does not say which code was meant.
  • component_id names the site’s component, version_id the immutable version, and artifact_digest is the SHA-256 of the compiled build that version was promoted with. The definition identifier is the one that version resolves to, so a renderer draws by digest exactly as for any registered section.
  • A reader with access to the site’s components (ParseContext.components) checks that the version is promoted and that the pin and the definition belong together; otherwise it refuses with unknown_component_version. A request-side parser without that access refuses every pin, so a pin is never created from input the site cannot check.
  • Nothing resolves a pin to a newer version, when rendering, saving or restoring. A newer version is an explicit change of the pin.
  • A page without any pin stays in format 2, and storedPage writes the format its content needs.

The site’s own search box. The page holds words only: heading and placeholder, both optional. It names no index, endpoint or query. Results come from the site’s search, in the page’s language.

  • The editor offers it only while site search is on (GET /api/pages/renderer-capability → site_search.active), under every renderer.
  • A managed release draws it with the platform’s own search component, whatever the template (PLATFORM_DRAWN_TYPES). The form sends q to the same page, which reads GET /v1/_search and draws the results in place. A template maps no component for it.
  • An exported site draws it with its SiteSearch component. A page that holds one renders on each request and reads GET /v1/_search on the site’s own host. A site built with search off refuses to build a page that holds one.
  • Both renderers add suggestions while the visitor types: the form reads its site’s own /search?suggest= and still works as a plain GET form without JavaScript.
  • While search is off, the editor’s quality list blocks Save on a page that holds one.

Contract version 5. A shared section is content the site keeps once and shows on many pages: an announcement, a promotion, footer content, a call to action. It is an ordinary item of the reserved shared_section model (a title and slides, like a page), edited, versioned and published like a page.

A page stores a reference, never a copy:

{
"id": "notice",
"type": "shared",
"variant": "default",
"layout": "default",
"component": null,
"shared": { "entry": "ci_…" }
}

Its props are {}. shared.entry is the shared section’s owning item id. Each locale holds its own references. variant and layout are default. It takes no appearance.

  • Delivery expands it. Every public read (/v1/:model, /v1/:model/:slug, a preview), a managed working render, a template preview and an exported site replace the reference with the shared section’s published sections in the page’s language (the site’s locale fallback chain applies). Section ids become <reference id>.<n>. A draft never shows anywhere. A shared section with nothing published in that language shows nothing. A renderer therefore never receives type: "shared": parsePublicRenderablePage refuses one with shared_section_unexpanded.
  • Cache. A response that embeds a shared section carries instance:<shared section id>, the tag its publication purges.
  • What a shared section holds. Up to SHARED_SECTION_MAX (6) sections. No other shared section (shared_section_nested), and no form, search or custom section (shared_section_type_unsupported). Exceeding this limit returns shared_section_too_many. Saving rejects each of these cases.
  • Publishing a page refuses shared_section_not_live while a shared section it uses is not published, and shared_section_page_limit when its expanded sections exceed LIMITS.slides. Publishing a shared section refuses shared_section_page_limit when a live page using it would exceed the limit. An import replays a site where both were live, so it skips the first check.
  • Withdrawing one. Unpublishing a shared section lists every page using it in the dependent acknowledgement (field_keys: ["slides"]). Deleting needs it unpublished first. After that, each page shows nothing in its place.
  • Not a page. It has no address, no route (shared_section_not_routable in a route map), no sitemap entry and no search entry.
  • Saved patterns stay copies: a pattern refuses a shared section (pattern_shared_section).
  • Every version records the shared sections it uses in meta.shared_sections, readable by SQL even when the snapshot is compressed.
Type Variants Default variant Layouts
hero centred, split, full_bleed centred default
rich_text single_column, wide, narrow, statement single_column default
text_media balanced, media_dominant, text_dominant balanced media_side=start, media_side=end
feature_grid two_column, three_column, list three_column default
cta banner, card, inline banner default
cards grid, bento, scroller grid default
stats row, grid, spotlight row default
faq accordion, list, split accordion default
testimonials spotlight, grid, scroller spotlight default
media full_width, grid, masonry, scroller full_width default
logos grid, row, marquee grid default
steps numbered, timeline, columns numbered default
pricing cards, compact, stacked cards default
menu list, two_column, cards list default
contact split, card, compact split default
latest_entries grid, list grid default
form stacked, split, card stacked default
custom the pinned definition’s own its first default
search default default default

The constants are SECTION_VISUAL_VARIANTS, DEFAULT_VISUAL_VARIANT and SECTION_LAYOUTS.

A section may carry appearance beside its variant:

{
"id": "story",
"type": "rich_text",
"variant": "wide",
"layout": "default",
"component": null,
"appearance": { "background": "muted", "motion": "reveal" }
}
Key Values When the key is absent
background muted, inverse, accent the default background
spacing compact, roomy normal spacing
motion reveal, parallax no motion
  • Each value is a name. Your theme decides what it looks like.
  • A key at its default is left out. A section with no key set has no appearance at all. An empty object is refused with noncanonical_default.
  • Any other key or value is refused with unknown_section_appearance.
  • Turn off all motion when the visitor asks for reduced motion (prefers-reduced-motion: reduce).

The constant is SECTION_APPEARANCE.

What Maximum
Slides or sections per page 24
Paragraphs per section 12
Formatting runs per paragraph 32
Features per grid 12
Cards, testimonials or steps per section 12
Stats per section 8
FAQ items per section 20
Media items or logos per section 24
Pricing plans per section 4
Features per pricing plan 12
Menu groups per section 8
Items per menu group 30
Opening-hours entries 14
Heading 200 characters
Paragraph, subheading, CTA or contact body 2000 characters
Eyebrow 80 characters
Link label 80 characters
Link or media URL 2000 characters
Feature title 120 characters
Feature body 600 characters
Card or step title, menu group title 120 characters
Card or step body 600 characters
Stat value 24 characters
Stat label 80 characters
Stat note 200 characters
FAQ question 200 characters
FAQ answer 2000 characters
Testimonial quote 600 characters
Person name 80 characters
Person role 120 characters
Caption or video title 200 characters
Logo name 80 characters
Plan name 80 characters
Price or period 24 characters
Plan description 300 characters
Plan feature 120 characters
Menu item name 120 characters
Menu item description 300 characters
Address 500 characters
Opening-hours label or value 80 characters
Phone 40 characters
Email 254 characters
Slide or section id 64 characters
Media id 64 characters
Media alt text 1000 characters
Variant or layout name 64 characters
Form fields 12
Form field label 80 characters
Form field help 200 characters
Choices in a form field 20
Form choice 80 characters
Consent statement 300 characters
Form button text 40 characters
Message after sending 300 characters
Values in a registered section 24
Items in a registered list 24
Registered text value 2000 characters
Search box placeholder 80 characters

The constants are LIMITS and SECTION_LIMITS.

Text is plain text. Headings, paragraphs, labels, item text and formatting runs may not contain <, >, &lt;, &gt;, numeric entities, javascript: or data:text/html. Render them as text and let your templating escape them as usual.

Links (link.href, and href in a formatting run) must be one of:

  • a path on the same site, starting with exactly one /.
  • an https:// URL with a named host (containing a dot).
  • a plain mailto: address with no query.

Media URLs are checked with a real URL parser:

URL Result
https://cdn.example.com/a.jpg (query and fragment allowed) accepted
/local-media/abc (exactly one leading slash) accepted
http://localhost…, http://127.0.0.1…, http://[::1]… accepted, for local development
http:// on any other host refused
https:// with no host refused
https://user:pass@host/a.jpg refused
//evil.example/a.jpg refused
any backslash or whitespace refused
javascript:, data: or any other scheme refused

Media alt text is length-checked but not checked for markup. Put it in an escaped attribute.

In rich_text and text_media, an entry of paragraphs is a string or a formatted paragraph:

type RichParagraph = {
inline: Array<{ text: string; bold?: true; italic?: true; href?: string }>;
list?: "bullet" | "number";
};

Each meaning has one spelling:

  • Use the object only when it carries bold, italic, a link or a list. A plain paragraph stays a string. An object with no formatting is refused with noncanonical_default.
  • bold and italic are true or absent. false is refused with noncanonical_default.
  • Two neighbouring runs with the same marks and link are one run. Writing them as separate runs is refused with noncanonical_default.
  • inline holds 1–32 runs. Run text is not trimmed, so the spaces between runs survive. A run cannot be empty, and the paragraph cannot be only spaces.
  • Consecutive entries with the same list form one list.

paragraphBlocks(paragraphs) turns a paragraph list into blocks to draw: { kind: 'paragraph', runs } or { kind: 'list', ordered, items }. A plain string becomes one run with no marks. Escape run.text. Never build raw HTML from it. paragraphText(paragraph) returns the words of one paragraph without formatting.

A video item names a provider and a video id. It never carries embed code or a player URL.

provider video_id
youtube exactly 11 letters, digits, _ or -
vimeo 6 to 12 digits

Any other provider, or an id in the wrong form, is refused with unsafe_link. These are the built-in rules of the embed registry (embed.ts), the same ones a rich-text embed uses, and the section editor reads a pasted watch, share, Shorts or player link with the same parser.

By default, draw the poster (or a neutral frame) and a link to the provider’s page. videoPageHref(item) returns that link. Make no third-party request before the visitor clicks, and load no client script. A theme that plays video in the page creates the player frame only after the click, from youtube-nocookie.com or player.vimeo.com, with dnt=1.

DOCUMENT_REFUSALS lists every code a parser can return for a page document:

Code Meaning
unreadable_page Not a page document, empty, or a slide without a usable id.
unknown_slide_type A section type outside the list above.
invalid_slide Wrong shape, a missing required value, an unsupported property, or an invalid phone number or email address.
markup_not_allowed Text that contains markup.
unsafe_link A link that breaks the link rules, or a video provider or id that is not allowed.
unknown_media Media not in the site’s library. The public parsers never return it.
duplicate_slide_target Two slides or sections share an id.
unknown_section_variant A variant the type does not offer.
unknown_section_layout A layout the type does not support.
missing_section_props A section with no props for this locale.
orphan_section_props Props for a section that does not exist.
too_many A limit was exceeded. detail.max names it.
noncanonical_default A value in a spelling the contract does not use. This includes an empty later field, false for a mark or for highlighted, or a formatted paragraph with no formatting. It also includes two runs that should be one, or an empty appearance.
unknown_section_appearance An appearance key or value the contract does not offer.
unknown_section_definition A registered section pins a version this reader does not hold.
unknown_component_version A format-3 section pins a reusable component version this reader does not hold, or one that does not match its definition.
shared_section_unexpanded A renderer was handed a shared section. Only parsePublicRenderablePage returns it.

The contract also exports:

Function or constant Returns
paragraphBlocks(paragraphs) Paragraphs as blocks to draw. See Inline formatting.
paragraphText(paragraph) The words of one paragraph, without formatting.
videoPageHref(item) The provider page of a video item.
slideStrings(slide) Every authored string a slide shows, in reading order. For a section, pass slideFromSection(section).
pageMediaReferences(document) Every media reference in a document, list items included.
mapPageMedia(document, visit) The same document with each media reference passed through visit. It never moves a section or changes its variant, layout or appearance.
slideMediaPaths(type) Where a type may hold media, as key paths with * for a list index.
SLIDE_ITEM_MEDIA_KEYS The media keys inside list items, per type.
currentPageContractVersion() The contract version this copy reads (PAGE_CONTRACT_VERSION).
isPageContractVersion(value) Whether a value is that version. There is one version; any other value is false.
pageContractCapabilities(version) One entry of PAGE_CONTRACT_VERSIONS.
PAGE_CONTRACT_VERSIONS The current contract version and what it reads.
sectionTypeName(type) The type’s name as the admin shows it, such as Text with image.
componentSectionTypes(version) The types a template draws with its own per-type components: every type but custom and PLATFORM_DRAWN_TYPES.
PLATFORM_DRAWN_TYPES The types a managed release draws with platform components: search.
DRAWN_SLIDE_TYPES, DrawnSlideType Every type a renderer may receive: all but shared.
expandSharedSections(document, lookup) The document with each shared section replaced by lookup(entry)’s sections. Always parses.
sharedEntries(document), storedSharedEntries(raw) The shared sections a parsed or stored document uses.
isMediaValue(value) Whether one registered-section value is a media reference.

The functions that take a document take a parsed one. parsePublicPageDocument(raw) returns it without a database.

fixtures.ts, in the same directory, holds the documents Dee Wan tests its own parser with: ACCEPTED_PAGES, REFUSED_PAGES, STORED_ACCEPTED_PAGES and STORED_REFUSED_PAGES. They cover every built-in section type, every later field, formatted paragraphs, every appearance value, and values exactly at each limit. V4_REFUSED_FORMS holds refused forms. V4_CUSTOM_PAGES, V4_CUSTOM_REFUSED and V4_CUSTOM_REFUSED_WITH_DEFINITION cover registered sections against SECTION_DEFINITION_FIXTURE. V5_SHARED_PAGE and V5_SHARED_REFUSED cover shared sections; V5_RENDERER_REFUSED is what a renderer must refuse. Run your renderer over the accepted sets to check that it handles every section type. They are not exported from index.ts, so they stay out of your production bundle. Import ./fixtures in tests only.

There are two numbers.

  • The format (format in the document, 2 or 3) says how a page is stored. Format 3 carries reusable-component pins. Your copy of the contract refuses formats outside PAGE_FORMATS rather than guessing.
  • The contract version (PAGE_CONTRACT_VERSION, now 5) names the vocabulary: section types, variants, layouts, fields, structure keys and limits. An additive change (a new type, optional field, variant, layout value or structure key) raises it. Stored pages keep their format and stay valid byte for byte.

PAGE_CONTRACT_VERSIONS holds only the current version and its exact set. It reads that set, never more. Dee Wan computes the version’s digest from its own contract sources. A later version will be a superset of this one.

Every /v1 response carries contracts.page.served, the contract version that produced it. Every page item carries page_contract.required, the contract version that reads its page. The stored document does not change: fields.slides.format is still the storage format.

{
"contracts": { "page": { "served": 5 } },
"data": {
"id": "ci_…",
"slug": "about",
"model": "page",
"locale": "en",
"version": 4,
"published_at": "2026-09-01T08:00:00.000Z",
"fields": {
"title": "About",
"slides": {
"format": 2,
"sections": [
{
"id": "intro",
"type": "hero",
"variant": "centred",
"layout": "default",
"component": null
}
],
"props": {
"intro": {
"heading": "About us",
"subheading": null,
"media": null,
"link": null
}
}
}
},
"relations": {},
"media": {},
"page_contract": { "required": 5 }
}
}
  • Items of models without a slides field never carry page_contract.
  • With fields=, page_contract is kept only when slides is selected.
  • An item whose page cannot be read carries no page_contract. The parser refuses that page anyway.

A renderer built for version N renders a page only when required is N or lower. Otherwise it treats the whole page as an error for that URL and shows its generic error page. It never renders part of the page.

Dee Wan verifies a preview renderer through /.well-known/dee-wan-renderer.json.

  • version is 2. Any other protocol is refused (preview_contract_incompatible).
  • page_contract_version is the contract version the renderer was built for.
  • renderer_contract_digest must equal that version’s digest, or Dee Wan refuses the renderer with renderer_contract_unknown.
  • page_formats must equal PAGE_FORMATS.
{
"version": 2,
"site_id": "site_…",
"routes": { "page": "/preview/page/{slug}" },
"token_param": "preview",
"page_contract_version": 5,
"renderer_contract_digest": "<64 hex characters>",
"page_formats": [2, 3]
}

A generated site serves this file from src/lib/renderer-contract.ts. A stored verification without page_contract_version is not sufficient: verify the renderer again.

Dee Wan refuses to publish a page the active renderer cannot draw (renderer_capability_exceeded): a registered section the active managed template does not include. A site with no verified renderer can still publish.

editor_bridge in the renderer contract is optional. Dee Wan reads { "version": 1 } or { "version": 3 } and drops any other value. Version 1 selects sections. Version 3 also links each rendered value to the field that holds it. A generated site declares version 3. Older generated sites keep version 1, and their preview works as before; the editor says field links are not available.

A field-link path names one value of one entry (field-links.ts). Segments are joined by /. Each segment is a field key, a list index, or ~ plus a percent-encoded instance id: a page section, a component instance (variant_id) or a mixed block (_id).

Path Value
slides/~hero-1/heading heading of section hero-1
slides/~hero-1/features/2/title third feature title of that section
slides/~hero-1/link/label, slides/~hero-1/link button text, button link
slides/~hero-1/media section image
faqs/~v1/question question of component instance v1
body/3 block 3 of a rich field
seo/image the SEO image, where it is drawn

readFieldLinkPath refuses every other spelling. pageFieldLinks(sections) lists, per section, the text, link and media values a page may draw. Alternative text, provider names and ids are not listed. entryFieldLinks(id, values, rich) lists an entry page, such as a Blog post: plain values by field key, and each stored rich value block by block (body/3), with a block’s words and library media.

A generated site finds a section by its data-dw-section wrapper. The managed renderer finds it by its positional section marker comment, so the list’s id is that marker’s id. An entry page marks its whole article with data-dw-section, and selecting a point outside all listed values identifies no field. Both built-in renderers route the post model in their renderer contract on a Blog site.

Only a preview render carries field links. The generated preview route writes the list as JSON (fieldLinksJson, safe inside a <script> element) and runs the bridge. The bridge finds the elements that show each value and marks them with data-dw-field (the path) and data-dw-occurrence (0 for the first element showing that value). It makes them focusable. If two fields contain the same text, they map to its elements in order, and only when the counts agree. Published pages never carry the list, the marks or the bridge.

For version 3 messages, the frame posts only to the exact editor origin from editor_origin. It carries the editor_nonce from its address. Both sides check the window, the origin, the nonce and the exact keys, and ignore any other message.

From Message Keys besides type, version: 3, nonce
frame dee-wan:preview:ready current_version_id, locale, model, entry, links
frame dee-wan:preview:pick path, occurrence
frame dee-wan:preview:edit path, occurrence, anchor
frame dee-wan:preview:close none
editor dee-wan:preview:show path
editor dee-wan:preview:focus path
editor dee-wan:preview:clear none

A custom renderer may speak version 3 with its own markup: it marks the elements itself and sends the same messages. To use FIELD_LINK_BRIDGE, provide the identity marker below, the field-link JSON and the section roots described above. Replace the example values with the rendered entry’s actual identity. data-dw-editor-version is its current saved version id, not bridge version 3. data-dw-editor-model is the model slug, data-dw-editor-entry is the entry id, and data-dw-editor-locale is the rendered locale. The host matches readiness against the version and locale.

<div
data-dw-editor-version="cver_example"
data-dw-editor-locale="en"
data-dw-editor-model="post"
data-dw-editor-entry="instance_example"
data-dw-editor-inplace
hidden
></div>

The presence of data-dw-editor-inplace opts into inline editing; remove the attribute to opt out (setting it to "false" still opts in). Without it, the runtime sends pick, which opens the normal field controls. Without the identity marker, the runtime does not start; the normal editor remains available.

An opted-in selection sends edit instead of pick. A custom bridge can send it directly: target is the selected rendered element, editorNonce comes from editor_nonce, and editorOrigin is the exact editor_origin from the frame’s address.

const box = target.getBoundingClientRect();
window.parent.postMessage(
{
type: "dee-wan:preview:edit",
version: 3,
nonce: editorNonce,
path: "body/3",
occurrence: 0,
anchor: { x: box.x, y: box.y, width: box.width, height: box.height },
},
editorOrigin,
);

anchor is the selected element’s rectangle in the iframe viewport’s CSS pixels at selection time. Use getBoundingClientRect() directly: do not add page scroll offsets or the iframe’s position in the parent page. The object has exactly x, y, width, height; every value is finite with absolute value at most 100000, and width and height are nonnegative. Negative x/y are allowed. occurrence is an integer from 0 to 9999. Adding anchor to pick is invalid: send edit.

path must pass readFieldLinkPath: at most 512 characters and 12 segments, beginning with a field key. Keys match [A-Za-z][A-Za-z0-9_]{0,63}; indices are 0–9999 without leading zeroes. An instance segment is ~ plus the canonical encodeURIComponent spelling of a nonempty id of at most 128 decoded UTF-16 code units. Messages and anchors must be plain objects with the exact keys listed; unknown keys or invalid bounds are ignored. The ready message’s links is an integer from 0 to 999999.

The host supports ordinary entries’ top-level text/rich fields and a single rich block index such as body/3. Nested component paths and composed pages use normal field selection. It opens its existing controls only for a visible field the person may edit. An unsupported inline path falls back to normal field selection; a path absent from the form is reported rather than edited. The frame never mutates CMS data. Edits update the host’s unsaved form under its normal permissions and validation. Done closes inline editing and returns focus through dee-wan:preview:focus; it does not save. Save persists the entry. The runtime handles focus by revealing and focusing the first rendered element for path.


Unreleased / pre-MVP; no stable version
Source revision: fe23db481c422b40e317b92fc98116b4c0c26a87 (modified checkout)
Build date (UTC): 2026-10-18T09:22:34.630Z
Upgrade target: unavailable: modified checkout. Release notes