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.
Where the document comes from
Section titled “Where the document comes from”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.
Where the parser lives
Section titled “Where the parser lives”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.
Which parser to use
Section titled “Which parser to use”| 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.
Supported formats
Section titled “Supported formats”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).
The stored page
Section titled “The stored page”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.
componentmust be present. In a format-2 page it isnull. In a format-3 page it isnull, or on acustomsection a component pin.- Props must not repeat
id,typeor a layout key such asmedia_side. Those belong to the structure. - A section may carry
appearancein the structure. It is optional, and belongs to this locale likevariant. See Section appearance.
What the parser gives you
Section titled “What the parser gives you”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.
Section types
Section titled “Section types”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.
Later fields
Section titled “Later fields”| 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.
Items of the list types
Section titled “Items of the list types”media: each item is{ kind: "image", media, caption }or{ kind: "video", provider, video_id, title, poster, caption }. An image item needsmedia. A video item needsprovider,video_idandtitle. See Video.pricing:featuresis a list of text, 0–12 entries. Absent reads as[].highlightedistrueor absent, and at most one plan in a section is highlighted.falseis refused withnoncanonical_default.contact: needs at least one ofaddress,phone,email,map_linkor onehoursentry.hoursabsent reads as[].phoneholds digits, spaces,(,),.and-, with an optional leading+, at least 3 characters.emailis one address, not amailto:link.map_link.hrefmust start withhttps://.logos: every item needs an image.
latest_entries
Section titled “latest_entries”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. nameis 1–32 lowercase letters, digits or_, starting with a letter. Names are unique in a form.kindistext,email,phone,textarea,select,checkboxorfile.optionslists 2–20 choices forselect, and is[]for every other kind.- A
filefield may also setaccept(up to 10 exact supported MIME types or extensions),max_files(1–5, default 1) andmax_bytes(1–5242880, default 5242880). Other kinds refuse these keys. Emptyacceptallows the image/document catalog inFORM_FILE_TYPES; wildcards are refused. All files together are limited to 5 files and 10485760 bytes. File bytes must match a supported format. consentis the statement a visitor must accept, ornullfor 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.
Registered sections (custom)
Section titled “Registered sections (custom)”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>" }}definitionis required on acustomsection and refused on every other type. In a format-2 pagecomponentstaysnull: that format never reinterprets it.digestis 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 }.fieldsholds 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
fieldsagainst 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 isunknown_section_definition. sections.tsreads 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.
Reusable components (format 3)
Section titled “Reusable components (format 3)”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>" }}componentis accepted only in a format-3 page and only on acustomsection that also holds itsdefinition. A bare name or slug is refused (invalid_slide): a name does not say which code was meant.component_idnames the site’s component,version_idthe immutable version, andartifact_digestis the SHA-256 of the compiled build that version was promoted with. Thedefinitionidentifier is the one that version resolves to, so a renderer draws bydigestexactly 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 withunknown_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
storedPagewrites the format its content needs.
search
Section titled “search”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 sendsqto the same page, which readsGET /v1/_searchand draws the results in place. A template maps no component for it. - An exported site draws it with its
SiteSearchcomponent. A page that holds one renders on each request and readsGET /v1/_searchon 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.
Shared sections (shared)
Section titled “Shared sections (shared)”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 receivestype: "shared":parsePublicRenderablePagerefuses one withshared_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 noform,searchorcustomsection (shared_section_type_unsupported). Exceeding this limit returnsshared_section_too_many. Saving rejects each of these cases. - Publishing a page refuses
shared_section_not_livewhile a shared section it uses is not published, andshared_section_page_limitwhen its expanded sections exceedLIMITS.slides. Publishing a shared section refusesshared_section_page_limitwhen 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_routablein 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.
Variants and layouts
Section titled “Variants and layouts”| 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.
Section appearance
Section titled “Section appearance”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
appearanceat all. An empty object is refused withnoncanonical_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.
Limits
Section titled “Limits”| 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 |
| 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.
Content rules
Section titled “Content rules”Text is plain text. Headings, paragraphs, labels, item text and formatting runs may not contain
<, >, <, >, 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.
Inline formatting
Section titled “Inline formatting”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. boldanditalicaretrueor absent.falseis refused withnoncanonical_default.- Two neighbouring runs with the same marks and link are one run. Writing them as separate runs is refused
with
noncanonical_default. inlineholds 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
listform 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.
Refusal codes
Section titled “Refusal codes”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. |
Helpers
Section titled “Helpers”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.
Testing your renderer
Section titled “Testing your renderer”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.
Compatibility
Section titled “Compatibility”Formats and contract versions
Section titled “Formats and contract versions”There are two numbers.
- The format (
formatin the document,2or3) says how a page is stored. Format3carries reusable-component pins. Your copy of the contract refuses formats outsidePAGE_FORMATSrather than guessing. - The contract version (
PAGE_CONTRACT_VERSION, now5) 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.
What the public API tells you
Section titled “What the public API tells you”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
slidesfield never carrypage_contract. - With
fields=,page_contractis kept only whenslidesis 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.
Declaring your renderer’s version
Section titled “Declaring your renderer’s version”Dee Wan verifies a preview renderer through /.well-known/dee-wan-renderer.json.
versionis2. Any other protocol is refused (preview_contract_incompatible).page_contract_versionis the contract version the renderer was built for.renderer_contract_digestmust equal that version’s digest, or Dee Wan refuses the renderer withrenderer_contract_unknown.page_formatsmust equalPAGE_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 and field links
Section titled “Editor bridge and field links”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