Site compatibility specification, version 1
JSON Schemas
- feed.schema.jsonDiazoma feed page v1
- item.schema.jsonDiazoma item v1
- manifest.schema.jsonDiazoma manifest v1 (.well-known/wall.json)
- recipe.schema.jsonDiazoma recipe v1
- view.schema.jsonDiazoma view v1
Status: version 1. Text licence: CC-BY-4.0. The JSON Schemas and the reference code are Apache-2.0.
This document says how a website makes itself readable as a wall of items (photos, products, listings, videos) in a headset, in a browser extension, or in any other program that wants the items without scraping the page. It is written for the developer of the website.
Design rule: Level 1 takes about ten minutes for a front-end developer, Level 2 about fifteen minutes for a back-end developer, and every field beyond the four core item fields is optional. A specification that asks for more will not be adopted.
The key words MUST, SHOULD and MAY are used as in RFC 2119.
1. The ladder
| Level | The site does | The site gets |
|---|---|---|
| 0 | nothing | whatever a recipe or a heuristic could read |
| 1 | marks up its existing listing with data-wall-* attributes | an exact wall that survives redesigns, with no recipe |
| 2 | serves a JSON feed, and optionally a manifest | cursor paging, search, filters, image variants, actions, brand |
| 3 | embeds the wall itself | its own route, its own analytics, and no injection by extensions |
A site can offer several levels. A program reading the site uses the highest one it finds.
1.1 Detection order
A program MUST check in this order and stop at the first match:
window.__diazomais present: Level 3. A checker that does not run scripts looks instead for a<script>with adata-manifestattribute or asrcending inwall.iife.js.<link rel="wall-manifest">in the page, or a manifest at/.well-known/wall.json: Level 2. The well-known path is checked even when there is no<link>.<link rel="wall-feed">without a manifest: Level 2 without a manifest.[data-wall-item]in the page: Level 1.- A recipe for the origin: Level 0.
- Heuristics: Level 0.
2. Level 1: DOM annotations
<ul data-wall-list data-wall-next="a.pagination__next"> <li data-wall-item data-wall-id="12345" data-wall-link="/listing/12345" data-wall-aspect="1.5" data-wall-badges="new,video"> <img data-wall-image src="/img/12345-512.jpg" data-wall-image-focus="/img/12345-1024.jpg"> <h3 data-wall-title>Title</h3> <span data-wall-subtitle>Author · 2026</span> </li></ul>| Attribute | On | Meaning |
|---|---|---|
data-wall-item | the element of one item | Required. Marks an item. |
data-wall-image | an element inside the item | Required, or the first <img> inside the item is used. The URL is the element’s src, else its data-src. |
data-wall-link | the item or an element inside it | Required, or the first <a href> inside the item is used. On the item, the attribute value is the URL; elsewhere the element’s href. |
data-wall-id | the item | Optional. Stable id. Defaults to the link. |
data-wall-aspect | the item | Optional. Width divided by height. Defaults to the natural size of the image, then to its width and height attributes. |
data-wall-image-focus, data-wall-image-full | the image element | Optional. URLs of larger images. |
data-wall-video | the item | Optional. Video URL. |
data-wall-title, data-wall-subtitle | an element inside the item | Optional. The text of the element is used. |
data-wall-badges | the item | Optional. Comma-separated short labels. |
data-wall-list | the container | Optional. Marks the container of the items. |
data-wall-next | the container | Optional. A CSS selector of the next-page link. |
data-wall-action | any element inside the item | Optional. Names an action (like, save, …). With data-wall-action-url and data-wall-action-method (default POST). |
data-wall-view | a <script type="application/json"> in the document | Optional. The site’s view as JSON (see 3.2.2). The first one counts. A script that is not valid JSON or not an object is ignored. |
Pagination, in this order: data-wall-next on the list (a selector of the next-page link), then
<link rel="next">, then nothing: the program observes infinite scroll.
Nothing here changes how the site renders. A site can keep the annotations invisible to its own CSS.
3. Level 2: feed and manifest
3.1 Discovery
<link rel="wall-manifest" href="/.well-known/wall.json"><link rel="wall-feed" href="/wall/feed.json" title="Latest">3.2 Manifest (/.well-known/wall.json)
{ "version": 1, "policy": "allow", "name": "Example", "brand": { "accent": "#c9334f", "logo": "/logo-512.png", "bg": "#0e0e10" }, "feeds": [ { "id": "latest", "title": "Latest", "url": "/wall/feed?sort=new", "cursor": true }, { "id": "popular", "title": "Popular", "url": "/wall/feed?sort=popular", "cursor": true }, { "id": "search", "type": "search", "url": "/wall/feed?q={query}" } ], "filters": [ { "id": "type", "title": "Type", "values": ["all", "photo", "video"], "param": "type" } ], "media": { "hosts": ["cdn.example.com"], "variants": { "thumb": 512, "focus": 1024, "full": 2560 }, "projections": ["flat", "equirect180"] }, "actions": { "like": { "method": "POST", "url": "/api/items/{id}/like", "auth": "session", "toggle": true }, "cart": { "mode": "deeplink", "url": "/cart/add/{id}" } }, "auth": { "login": "/login", "device_link": "/wall/link" }, "recipe": "/.well-known/wall-recipe.json", "embed": { "route": "/xr" }}| Field | Meaning |
|---|---|
version | Integer, 1. Required. |
policy | allow (the default when absent), deny (programs MUST NOT build a wall for this origin) or recipes-only (only a recipe the site published itself, in recipe, may be used). |
feeds | Required, at least one. A feed with "type": "search" has {query} in its URL and is used for search. Every other feed is a browsable list. |
filters | Each filter has an id, a display title, its values, and the query param it is sent in. The value all means no filter: the parameter is then left out. |
media.hosts | Hosts the site promises serve images and video with Access-Control-Allow-Origin: * (or the requesting origin). A host here lets a program skip a privileged fetch, and lets a validator check CORS up front. |
media.projections | Video projections that appear in the feed. Absent means flat only. |
media.resize | Rules that turn an image URL into smaller copies. See 3.5. |
actions.* | method and url (with {id} for the item id) and auth: none, session (cookies, same origin) or bearer (a token from auth.device_link). With "mode": "deeplink" the program only opens the URL. toggle: true says the same call switches the state on and off. |
auth.device_link | Endpoint of the phone sign-in flow (a code and a QR, polled, single use, at most ten minutes). |
auth.csrf | { "meta": "csrf-token", "header": "X-CSRF-TOKEN" } and/or "cookie": "XSRF-TOKEN". Where a session action finds its CSRF token, and the header that carries it. See 3.2.1. |
recipe | The site’s own recipe for its DOM. |
view | How the wall should look: buttons, panel, layout, colours, texts. See 3.2.2. |
embed.route | Where the site’s own Level 3 wall lives, so a program can offer to open it instead of injecting. |
URLs in the manifest are absolute or relative to the manifest URL.
3.2.1 Actions and CSRF
A program calls an action with the user’s cookies only when auth is session, and then only on the
origin of the manifest. The call carries Accept: application/json and X-Requested-With: XMLHttpRequest.
A redirect is not followed and is an error. A 401, 403 or 419 means the user is not signed in.
The call succeeds only on a 2xx answer whose body is a JSON object ({ "ok": true, "state": { ... } };
"ok": false is a failure). An action that answers with no body says "response": "empty", and then a
2xx answer with an empty body is the success.
If the manifest declares auth.csrf, a program sends every non-GET session action with the token in
csrf.header. It reads the token from a <meta name="<csrf.meta>"> tag or from the cookie
csrf.cookie of the document it runs in, and only when that document is on the manifest’s origin. If
the token cannot be found, the call is not sent and counts as not signed in. A site that uses cookie
sessions SHOULD declare auth.csrf, or accept these actions only with its own origin check.
3.2.2 View
view says how the site wants its wall to look: which action buttons it has and what they say, what
the info panel shows, how many rows the wall has, the colours and the texts. It is optional. A
program that does not know it shows the default wall.
A view can be given in four places. All four have the same shape (schemas/view.schema.json).
| Where | How |
|---|---|
| Manifest | the view field |
| Recipe | the view field |
| Level 1 page | <script type="application/json" data-wall-view>{ ... }</script> |
| Level 3 | new Wall({ view, hooks }) |
If the same setting is given in more than one place, the order is: options of new Wall first, then
the page, manifest or recipe, then the defaults. hooks is code, so it exists only in Level 3: it
can choose which actions an item shows, change a button label, and replace the lines of the panel or
the card label. The wall still draws everything and applies the limits below.
Transport and display are separate. actions.<id> in the manifest says how a request is sent (URL,
method, auth). view.actions.<id> only says how the button looks.
actions, by action id:
| Field | Meaning |
|---|---|
label, labelActive | Button text, and the text while the action is on. At most 24 characters. |
icon, iconActive | One or two characters before the label. At most 4 characters. |
placement | primary (in the column, at most 3), more (behind “More”) or hidden (no button). |
order | Sort key. Lower comes first; equal keys keep the order of the manifest. |
style | primary (always filled) or secondary (filled only while the action is on). |
confirm | true asks for a second press. false does not turn it off where the wall requires it (see below). |
state | Key in item.state that says the action is on. Default liked for like, else the action id. |
count | Item field or item.state key with a number, shown after the label. |
panel:
| Field | Meaning |
|---|---|
fields | Lines of the info panel, in order: title, subtitle, creator, collection, description, likes, tags, badges, meta (every pair as Label: value) or meta.<key> (one pair). At most 8. Absent: title, creator, likes and description. |
side | Which links the column beside the picture shows: creator, collection, tags. Default all three. |
metaLabels | Labels for the keys of item.meta. Each at most 24 characters. |
wall:
| Field | Meaning |
|---|---|
rows | Rows of cards. |
radius, arc | Distance of the wall from the viewer in metres, and how far around it goes in degrees. |
card.width, card.height | Size of a card in metres. |
label | hover (default) shows the label of the focused card, none shows nothing. |
labelFields | Lines of that label: title, subtitle, creator, count or meta.<key>. At most 3. |
badges | false stops item.badges[0] being drawn on cards. Default true. |
indicator | Action id whose state draws the mark on the card. Default like. null: no mark. |
theme:
| Field | Meaning |
|---|---|
colors | bg, surface, surfaceHi, text, textSoft, muted, accent, accentStrong, accentText, panel. Hex (#abc or #aabbcc). panel may also be rgba(r, g, b, a). |
font | A CSS font family. |
environment | preset (studio, night, plain), colours top, horizon, bottom, floor (hex), stars (boolean), panorama (an image URL around the viewer) and dim (how much the panorama is darkened). |
strings: an object of texts by key. They replace the built-in texts of the wall.
Limits:
| Setting | Allowed |
|---|---|
wall.rows | whole number, 1 to 5 |
wall.radius | 1.8 to 4.0 |
wall.arc | 60 to 150 |
wall.card.width | 0.25 to 0.6 |
wall.card.height | 0.25 to 0.7 |
wall.rows × (card.height + 0.06) | at most 2.2 m; extra rows are dropped |
wall.card.width | at least 0.1 × wall.radius (a card must cover about 6° of view); a narrower card is widened |
wall.labelFields | at most 3 |
panel.fields | at most 8 |
action label, labelActive, and metaLabels values | at most 24 characters |
action icon, iconActive | at most 4 characters |
theme.environment.dim | 0 to 0.8 |
theme.environment.panorama | http or https only; the adapters drop any other value |
| colours | hex only (panel also rgba(...)) |
The wall clamps a value outside these limits to the nearest allowed one and writes a warning to the console. It also moves a text colour that is hard to read on its background (contrast below 4.5:1) toward black or white. The validator reports a value outside the limits as a failure.
How the wall uses the actions:
- Buttons are the actions the site declares, narrowed by
item.actionswhen an item has it. A site that declares no action gets no action button. Withoutplacement, the first two actions are in the column and the rest behind “More”. followfollows the item’scollectionfrom the column beside the picture. It becomes a column button only when the view gives it aplacement.- An answer with a
stateupdatesitem.state, the button and the mark on the card. - A
401,403or419answer shows thesignin_on_sitetext (:siteis the site’s name). When the manifest hasauth.login, the button then opens that page (with a second press). - Any other failure shows the
action_failedtext. - The links beside the picture (creator, collection, tags) and the discover button appear only for the scopes the adapter declares, unless the site gives the wall an account adapter.
Some things cannot be changed by a view:
- Leaving VR, opening a page of the site and any action with
mode: "deeplink"always ask for a second press. - Text sizes are not configurable.
Example, in a manifest:
"view": { "actions": { "favourite": { "label": "Favourite", "labelActive": "Favourited", "icon": "♡", "iconActive": "♥", "placement": "primary", "state": "favourited" }, "cart": { "label": "Add to cart", "placement": "primary", "order": 2 }, "compare": { "label": "Compare", "placement": "more" } }, "panel": { "fields": ["title", "meta.price", "description"], "side": ["tags"], "metaLabels": { "price": "Price" } }, "wall": { "rows": 4, "radius": 3, "card": { "width": 0.3, "height": 0.3 }, "labelFields": ["title", "meta.price"], "indicator": "favourite" }, "theme": { "colors": { "accent": "#c9334f", "panel": "rgba(8, 8, 11, 0.85)" }, "environment": { "preset": "night", "stars": true, "dim": 0.4 } }, "strings": { "signin_on_site": "Sign in on :site first" }}3.3 Feed response
{ "items": [ { "id": "12345", "link": "/listing/12345", "title": "A title", "subtitle": "Author", "image": "https://cdn.example.com/12345/512.jpg", "image_focus": "https://cdn.example.com/12345/1024.jpg", "image_full": "https://cdn.example.com/12345/full.jpg", "aspect": 1.5, "badges": ["video"], "video": "https://cdn.example.com/12345/720.mp4", "projection": "flat", "stereo": "mono", "meta": { "price": "1 250 EUR", "location": "Plovdiv" } } ], "next": "eyJzIjpbMTY5"}- Core fields of an item:
id,link,image,aspect. The page hasitems, andnextwhen there is more. nextis an opaque string. The program passes it back untouched ascursor=<next>and never parses it. A missing or emptynextends the feed.- 50 items per page are recommended; at most 100 are allowed.
- A search feed receives the query URL-encoded in
{query}. Filters appendparam=value. titleis strongly recommended (a failed texture shows it).Cache-Controlis the site’s choice; programs do not cache feeds across sessions.
3.4 Optional item fields
All of these are optional. Each is described in item.schema.json.
| Field | One line |
|---|---|
title, subtitle | Text on the card and in the viewer. |
image_focus, image_full | Larger images for the focused card and the viewer. |
video, projection, stereo | Video URL, its projection (flat, equirect180, equirect360) and stereo layout (mono, sbs, tb). |
badges | Short labels drawn on the card. |
blurhash | Placeholder while the texture loads. |
meta | Free key/value strings shown in the viewer, in order. |
kind | item (default), collection or tag: what the card is. |
media_type | photo, gif or video. |
unplayable | { "provider": "..." }: the video is an embed that cannot play in WebGL. |
creator | { "id", "label" }: who made the item. |
collection | { "id", "label", "following" }: the set the item belongs to. |
tags | List of tags. |
count | Number of items inside a collection or tag card. |
width, height | Pixel size of the original image. |
actions | List of action ids that apply to this item, from the manifest’s actions. Absent: all of them. An empty list: none. |
state | { "liked": true }: the viewer’s state for this item, by key (boolean, number or string). liked is the state of the like action. |
likes | Like count. |
description | Longer text for the viewer. |
tag | Tag name on a tag card. |
Unknown fields are ignored, so a site can add its own.
3.5 Image sizes
The wall draws each card at about 512 px and opens the item larger. An item that brings only image
is often the original upload, megabytes per card. A program SHOULD find smaller copies, in this order:
- The item’s own
image_focusorimage_full. When either is present, nothing below applies. - On Level 1, the image’s
srcsetwith width descriptors (two or more): the smallest copy at least 512 px wide becomesimage, the smallest at least 1024 pximage_focus, the largestimage_full. A recipe whoseimagefield reads asrcsetdoes the same, unless it namesimage_focusorimage_fullitself. - The site’s own rules in
media.resize(manifest or recipe). - Image hosts that resize by URL. When the item gives the original’s
width(section 3.4) a program MUST NOT ask for a copy wider than that: Wikimedia serves only fixed widths and scales a smaller original up, so a 300 px original keeps its own file asimage, and an 800 px one gets the 500 px copy and keeps its own file asimage_focus. Without awidththe copies are requested as usual.@diazoma/adaptersknows Wikimedia Commons, Cloudinary, imgix, Unsplash, Contentful, Sanity, Shopify, WordPress.com and Jetpack (i0.wp.com,*.files.wordpress.com), Squarespace, Flickr and Imgur.
In steps 3 and 4 the URL found becomes image_full, so the viewer still opens the original.
"media": { "resize": [ { "match": "/images/screen/", "image": "/images/thumb700x/", "image_focus": "/images/screen/", "image_full": "/images/large/" }, { "match": "https://cdn.example.com/", "image": "{url_raw}?w=512", "image_focus": "{url_raw}?w=1024" }, { "match": "https://img.example.com/", "image": "https://resizer.example/r?w=512&u={url}" } ]}match is plain text that the absolute image URL must contain. Regular expressions are not used,
so a rule cannot hang the program. A size that contains {url} is that template with the whole URL
put in, percent-encoded with encodeURIComponent so it is one query value (the second rule above). {url_raw}
puts it in as it is, for the rare host that wants the URL in the path or unencoded (the first rule). Any other size replaces the first occurrence of match. The first rule that matches is used;
a size it leaves out is not set. At most 20 rules, match at most 200 characters. A result that is
not an http(s) URL is dropped (section 6).
3.6 Recipe API sources
A recipe’s api source (schema: recipe.schema.json) reads a JSON API. These parts are additive: a recipe
that does not use them behaves as before.
Value templates. An api field may be { "template": "..." } instead of a JSONPath. {name} is the value
at that key of the item, dotted for nested keys ({source.id}); it is not JSONPath. Each value is
percent-encoded with encodeURIComponent, unless the template is exactly one placeholder, which is used as
it is. A placeholder whose value is missing, empty or not a string or number drops the field.
"image": { "template": "https://iiif.example/{image_id}/full/512,/0/default.jpg" }Aspect from size. aspect may be { "w": "<JSONPath>", "h": "<JSONPath>" }: the value is w / h. Numeric
strings count. When either is missing or not positive, aspect is dropped (and with it the item, section 3.3).
"aspect": { "w": "$.width", "h": "$.height" }Offset paging. page.step makes the value sent in page.param (and in {page} of url) an offset:
start + (page - 1) * step, where start is the first offset and defaults to 0.
"url": "https://api.example/items?limit=20","page": { "param": "offset", "start": 0, "step": 20 }Continuation tokens. page.next is a JSONPath on the response. Its value is the cursor of the next page:
a string or number is sent in page.param; an absolute http(s) URL is fetched as it is. No value, or a page
without items, ends the paging. A URL must still be inside the recipe’s match. step and next exclude each other.
"page": { "param": "gsroffset", "next": "$.continue.gsroffset" }Regular expressions. A DOM field’s re runs only on the extracted string. A program MUST refuse a pattern
that can backtrack exponentially or polynomially in a way that is not capped: a backreference; a repeated
group that holds a quantifier or an alternation ((a+)+, (a|b)*); a pattern longer than 200 characters; and
any input string longer than 256 characters. The count rule: a variable part is a quantifier whose count can
differ (*, +, ?, {m,}, {m,n} with n > m, bounded or not) or a group with an alternation; {3} and
{2,2} are fixed and do not count. A pattern may hold at most ONE variable part, or TWO when it starts with ^
and has no top-level |. So ^(.*)-3000(\.png)$ and (\d{4}) run, while a*a*b, \d{1,9999}\d{1,9999}x and
^a*a*a*b are refused. Under this rule the work is at most quadratic in 256 characters, below 50 ms on Node 22.
The field is dropped and a bad-recipe warning is reported; the page still loads.
Continuation without a parameter. page.next requires page.param. If a non-URL token arrives and there is
no param to send it in, the paging ends with a bad-recipe warning (it never loops on page 1).
Match patterns (Chrome syntax) are compared without a regular expression: * matches any run of
characters and everything else is literal, so a pattern such as /*a*a*a*b cannot take long.
4. Level 3: embed
<script src="https://cdn.example/wall.iife.js" data-manifest="/.well-known/wall.json" data-route="/xr"></script>or the npm package, or a plugin that also generates the Level 2 feed and manifest. The page sets
window.__diazoma = { version, route } (a version string and the path of the wall’s page, such as /xr) so other programs defer to it and do not inject a second wall.
5. Validation
npx @diazoma/spec validate https://example.comThe validator detects the level by the order in 1.1, validates the manifest and one page of each
non-search feed against the schemas, requests one image from each media.hosts host with an
Origin header and requires Access-Control-Allow-Origin to be * or that origin, checks that
brand.accent has a contrast of at least 4.5:1 against white, prints policy, and prints the
ladder with pass or fail per level and per check. The exit code is 0 when the highest detected level
passes. A checker that does not run scripts cannot check window.__diazoma; it says so.
6. Security of what a program reads
A program reading a site’s feed, manifest or annotations MUST treat them as untrusted input.
link,image,image_focus,image_fullandvideoare http(s) URLs or relative references. An item whoselinkorimagehas another scheme (javascript:,data:,file:) is dropped; another scheme in an optional media URL removes that field.- An action with
auth: "session"MUST point at the origin of the manifest (or, for annotations, of the page). A program does not send the user’s cookies anywhere else. Adeeplinkis http(s) only.
7. Versioning
version is an integer. Additive changes do not bump it. A breaking change is a new integer, and
programs support the previous one for twelve months. Unknown fields are ignored.
8. Files
schemas/manifest.schema.json,schemas/feed.schema.json,schemas/item.schema.json: JSON Schema 2020-12,$idunderhttps://diazoma.app/spec/v1/, where the files are published.schemas/view.schema.json: theviewof 3.2.2, used by the manifest and by recipes; a copy ships in@diazoma/recipe-schema.schemas/recipe.schema.json: the recipe schema for sites that do not publish this spec; the canonical file is in@diazoma/recipe-schema.