Diazoma

Site compatibility specification, version 1

JSON Schemas

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

LevelThe site doesThe site gets
0nothingwhatever a recipe or a heuristic could read
1marks up its existing listing with data-wall-* attributesan exact wall that survives redesigns, with no recipe
2serves a JSON feed, and optionally a manifestcursor paging, search, filters, image variants, actions, brand
3embeds the wall itselfits 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:

  1. window.__diazoma is present: Level 3. A checker that does not run scripts looks instead for a <script> with a data-manifest attribute or a src ending in wall.iife.js.
  2. <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>.
  3. <link rel="wall-feed"> without a manifest: Level 2 without a manifest.
  4. [data-wall-item] in the page: Level 1.
  5. A recipe for the origin: Level 0.
  6. 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>
AttributeOnMeaning
data-wall-itemthe element of one itemRequired. Marks an item.
data-wall-imagean element inside the itemRequired, or the first <img> inside the item is used. The URL is the element’s src, else its data-src.
data-wall-linkthe item or an element inside itRequired, 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-idthe itemOptional. Stable id. Defaults to the link.
data-wall-aspectthe itemOptional. 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-fullthe image elementOptional. URLs of larger images.
data-wall-videothe itemOptional. Video URL.
data-wall-title, data-wall-subtitlean element inside the itemOptional. The text of the element is used.
data-wall-badgesthe itemOptional. Comma-separated short labels.
data-wall-listthe containerOptional. Marks the container of the items.
data-wall-nextthe containerOptional. A CSS selector of the next-page link.
data-wall-actionany element inside the itemOptional. Names an action (like, save, …). With data-wall-action-url and data-wall-action-method (default POST).
data-wall-viewa <script type="application/json"> in the documentOptional. 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" }
}
FieldMeaning
versionInteger, 1. Required.
policyallow (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).
feedsRequired, 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.
filtersEach 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.hostsHosts 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.projectionsVideo projections that appear in the feed. Absent means flat only.
media.resizeRules 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_linkEndpoint 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.
recipeThe site’s own recipe for its DOM.
viewHow the wall should look: buttons, panel, layout, colours, texts. See 3.2.2.
embed.routeWhere 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).

WhereHow
Manifestthe view field
Recipethe view field
Level 1 page<script type="application/json" data-wall-view>{ ... }</script>
Level 3new 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:

FieldMeaning
label, labelActiveButton text, and the text while the action is on. At most 24 characters.
icon, iconActiveOne or two characters before the label. At most 4 characters.
placementprimary (in the column, at most 3), more (behind “More”) or hidden (no button).
orderSort key. Lower comes first; equal keys keep the order of the manifest.
styleprimary (always filled) or secondary (filled only while the action is on).
confirmtrue asks for a second press. false does not turn it off where the wall requires it (see below).
stateKey in item.state that says the action is on. Default liked for like, else the action id.
countItem field or item.state key with a number, shown after the label.

panel:

FieldMeaning
fieldsLines 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.
sideWhich links the column beside the picture shows: creator, collection, tags. Default all three.
metaLabelsLabels for the keys of item.meta. Each at most 24 characters.

wall:

FieldMeaning
rowsRows of cards.
radius, arcDistance of the wall from the viewer in metres, and how far around it goes in degrees.
card.width, card.heightSize of a card in metres.
labelhover (default) shows the label of the focused card, none shows nothing.
labelFieldsLines of that label: title, subtitle, creator, count or meta.<key>. At most 3.
badgesfalse stops item.badges[0] being drawn on cards. Default true.
indicatorAction id whose state draws the mark on the card. Default like. null: no mark.

theme:

FieldMeaning
colorsbg, surface, surfaceHi, text, textSoft, muted, accent, accentStrong, accentText, panel. Hex (#abc or #aabbcc). panel may also be rgba(r, g, b, a).
fontA CSS font family.
environmentpreset (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:

SettingAllowed
wall.rowswhole number, 1 to 5
wall.radius1.8 to 4.0
wall.arc60 to 150
wall.card.width0.25 to 0.6
wall.card.height0.25 to 0.7
wall.rows × (card.height + 0.06)at most 2.2 m; extra rows are dropped
wall.card.widthat least 0.1 × wall.radius (a card must cover about 6° of view); a narrower card is widened
wall.labelFieldsat most 3
panel.fieldsat most 8
action label, labelActive, and metaLabels valuesat most 24 characters
action icon, iconActiveat most 4 characters
theme.environment.dim0 to 0.8
theme.environment.panoramahttp or https only; the adapters drop any other value
colourshex 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.actions when an item has it. A site that declares no action gets no action button. Without placement, the first two actions are in the column and the rest behind “More”.
  • follow follows the item’s collection from the column beside the picture. It becomes a column button only when the view gives it a placement.
  • An answer with a state updates item.state, the button and the mark on the card.
  • A 401, 403 or 419 answer shows the signin_on_site text (:site is the site’s name). When the manifest has auth.login, the button then opens that page (with a second press).
  • Any other failure shows the action_failed text.
  • 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 has items, and next when there is more.
  • next is an opaque string. The program passes it back untouched as cursor=<next> and never parses it. A missing or empty next ends the feed.
  • 50 items per page are recommended; at most 100 are allowed.
  • A search feed receives the query URL-encoded in {query}. Filters append param=value.
  • title is strongly recommended (a failed texture shows it).
  • Cache-Control is 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.

FieldOne line
title, subtitleText on the card and in the viewer.
image_focus, image_fullLarger images for the focused card and the viewer.
video, projection, stereoVideo URL, its projection (flat, equirect180, equirect360) and stereo layout (mono, sbs, tb).
badgesShort labels drawn on the card.
blurhashPlaceholder while the texture loads.
metaFree key/value strings shown in the viewer, in order.
kinditem (default), collection or tag: what the card is.
media_typephoto, 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.
tagsList of tags.
countNumber of items inside a collection or tag card.
width, heightPixel size of the original image.
actionsList 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.
likesLike count.
descriptionLonger text for the viewer.
tagTag 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:

  1. The item’s own image_focus or image_full. When either is present, nothing below applies.
  2. On Level 1, the image’s srcset with width descriptors (two or more): the smallest copy at least 512 px wide becomes image, the smallest at least 1024 px image_focus, the largest image_full. A recipe whose image field reads a srcset does the same, unless it names image_focus or image_full itself.
  3. The site’s own rules in media.resize (manifest or recipe).
  4. 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 as image, and an 800 px one gets the 500 px copy and keeps its own file as image_focus. Without a width the copies are requested as usual. @diazoma/adapters knows 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.com

The 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_full and video are http(s) URLs or relative references. An item whose link or image has 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. A deeplink is 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, $id under https://diazoma.app/spec/v1/, where the files are published.
  • schemas/view.schema.json: the view of 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.