Protocol

← All sections · part of the machine-readable /all/ index.

Protocol

Protocol

Reference for wire-level HTTP details — capability discovery via OPTIONS and file-level authoring via WebDAV.

Pages in this group

See also

OPTIONS

OPTIONS method

The HTTP OPTIONS method discovers which methods are allowed on a resource or a specific element within a resource. It is the capability-discovery mechanism that pagelove/primitives.mjs uses to determine available operations.

When to reach for it

Use OPTIONS before performing writes to confirm that the authenticated user is authorized for the intended method and selector. OPTIONS does not modify the document.

Allowed methods are governed by authorization rules. A rule granting the wildcard method (*) is reported as the concrete methods the server supports on the target — GET, HEAD, PUT, DELETE, POST, MOVE, PATCH for a whole document, or that set without MOVE and PATCH for a selector-scoped grant — so the Allow header never contains a literal *.

Standard response

An OPTIONS request without a selector range returns the allowed methods for the resource as a whole. The response includes Accept-Ranges: selector when the resource is HTML.

Selector-scoped OPTIONS

When an OPTIONS request includes a selector range unit, the server returns the methods allowed for that specific element:

OPTIONS /options-test.html
Range: selector=h1
HTTP/1.1 200
Allow: *, OPTIONS
Accept-Ranges: selector

Multipart OPTIONS

A client can discover capabilities for every addressable element in a single request. Send an OPTIONS request with Accept: multipart/mixed; that header selects the multipart form.

The server responds with 207 Multi-Status when the target path has at least one selector-scoped authorization rule (a rule that names an element, not just the whole document). The body is a multipart message where each part carries an Allow line and a Content-Range line identifying the element:

OPTIONS /options-test.html
Accept: multipart/mixed
HTTP/1.1 207
Content-Type: multipart/mixed; boundary=boundaryb7bbf5b36e83dd18

--boundaryb7bbf5b36e83dd18
Allow: GET, OPTIONS

--boundaryb7bbf5b36e83dd18
Content-Range: selector=h1
Allow: GET, POST, PUT, OPTIONS

--boundaryb7bbf5b36e83dd18--

When there are no selector-scoped rules

Accept: multipart/mixed requests the multipart form, but the server can only produce it when there is per-element authorization to report. If the path has only document-level rules — or no matching rules at all — there is nothing element-specific to enumerate, so the server falls back to a plain 204 No Content with a single aggregated Allow header covering the whole document (the same headers as the standard response — Accept-Ranges: selector, Vary: Authorization, Accept — just with a 204 status and no body).

So a client that always sends Accept: multipart/mixed for capability discovery must handle both responses: a 207 multipart body (per-element) and a bodyless 204 with a flat Allow header (document-level only). Read the status code first — don't assume a multipart body is present.

OPTIONS never checks whether the target exists

OPTIONS answers "what could I do here if I had permission?" purely from authorization rules matched against the request path and selector — it never looks up the document or evaluates the selector against real content. A request for a document that doesn't exist, or a selector that would match no element, still gets a normal response listing whatever methods the rules grant for that path, never a not-found or range error. To find out whether a document or element actually exists, issue the write (or a GET) itself.

See also

WebDAV

WebDAV

The WebDAV authoring interface exposes each Pagelove site as a standard WebDAV mount. Any filesystem client that speaks WebDAV can browse and edit content directly.

When to reach for it

Use WebDAV for file-level authoring — uploading assets, reorganizing directories, or editing documents with desktop tools. Each site has its own mount URL. Credentials are configured in the Pagelove control panel.

Live site

The WebDAV mount represents the live site. Pagelove modifies the DOM in place, so another user or an API call may update a document between the time it is read and the time it is written back. The author is responsible for coordinating concurrent changes.

Directory listings and revalidation

Every directory has a version tag (an ETag). A client that already has a copy of a listing can send that tag back on its next PROPFIND as If-None-Match; if nothing in the directory has changed, the server answers 304 Not Modified with an empty body and the client reuses what it has.

The tag belongs to the question you asked, not just to the directory. Depth: 0 asks for the directory's own properties; Depth: 1 (the default when no Depth header is sent) asks for the directory and everything directly inside it. Those are two different answers, so they carry two different tags. Sending a Depth: 0 tag with a Depth: 1 request returns the full listing rather than a 304 — a listing is never answered with "nothing changed" on the strength of a stat.

The tag appears both as the ETag response header and as the <D:getetag> property of the directory's own entry in the multistatus body; the two always agree. Responses also carry Vary: Depth, so a caching proxy placed in front of the authoring server keeps the two depths apart.

A directory's tag changes whenever a child is added, removed, or edited.

Single files work the same way. Ask for a file's properties, keep the tag from the ETag header, and send it back on the next request: if nothing about the file has changed, the answer is 304 Not Modified and you reuse what you have. A tool that polls a file to see whether it has changed pays for the answer once, not on every check.

Use the tag from the header, not the one inside the response body. They are two different tags for two different jobs: the one in the body identifies the file's contents, which is what you send when you want to save a change only if nobody else has. The one in the header identifies the answer you just received — the properties, including the file's type and when it was last changed — which is what you send to ask whether that answer is still current.

When something goes wrong

An error response carries an HTML document describing what happened, not just a status code. It is marked up so a program can read it:

<article itemscope itemtype="https://pagelove.org/Error">
  <meta itemprop="status" content="422">
  <meta itemprop="kind" content="UntranslatableWrite">
  <div itemprop="type" itemscope
       itemtype="https://dombase.pagelove.team/ns/error/UntranslatableWrite"></div>
  <p itemprop="message">…</p>
</article>

The kind names the specific failure, and the nested item repeats it as a type you can match on. Those names are the same ones the HTTP interface uses, so a client that already understands errors from one interface understands them from the other — the server never invents a name of its own here.

When a write is refused by the shared pipeline, the pipeline's own error document is included unchanged under a detail property, so you get its full description rather than a summary of it.

The status code tells you what to do about it, and it is worth distinguishing two that look similar:

Other codes you may see: 409 when a write conflicts with a concurrent change, 400 for a path the server will not accept, 422 when the content does not satisfy the site's schema or constraints, 503 when the storage layer is briefly unavailable and the request can be retried, and 507 when the request exhausted its allowance.

See also

Accept-Ranges

Accept-Ranges

The Accept-Ranges response header advertises which Range units a resource supports.

Standard response

Accept-Ranges: selector, bytes

Present on every response for an HTML resource, whether or not the request itself used a Range header. OPTIONS responses advertise just Accept-Ranges: selector (see OPTIONS method).

entries ranging works on Range requests — the platform accepts and answers it correctly — but it is not advertised in Accept-Ranges. If your client hard-codes Range: selector=... or Range: entries=... rather than deriving support from Accept-Ranges, this doesn't affect you. See Reading and writing for what each unit does.

There is no range unit for evaluating an expression. One existed once (sessel=) and was removed; sending it now does nothing at all, because an unrecognised range unit is ignored and you get the whole resource back.

Known issue: some CDNs narrow this header further

Known issue: Pagelove is currently served through Microsoft Azure Front Door, which rewrites Accept-Ranges on the way through — replacing selector, bytes with just bytes, the only unit AFD itself understands. Range: selector=... requests still work end-to-end and the response you get back is correct; only header-based discovery is affected, in the same way and for the same reason as the entries gap above. There is currently no workaround at the CDN layer.

See also

QUERY

QUERY method

The HTTP QUERY method evaluates a query expression against a document, a subtree, or a whole host, without a URL-length limit on the expression — it travels in the request body instead of a header. It is safe, idempotent, and cacheable, following the IETF draft draft-ietf-httpbis-safe-method-w-body.

QUERY accepts two request content types, which select entirely different behavior:

Servers advertise the content types a resource accepts on OPTIONS responses via the Accept-Query header. Sending the wrong body type gets 415 Unsupported Media Type with Accept-Query naming what's accepted.

When to reach for it

Use the CSS-selector form when you need every match across a whole host or a directory, not just one document — something GET with Range: selector= can't do, since a selector range always targets a single resource. Use the Sessel form to compute a value from a document — a count, a filtered list, a single field — rather than fetching HTML and picking it apart client-side.

CSS-selector mode

On the WebDAV authoring server

QUERY on the authoring endpoint (dombase-webdav) selects its scope from the target URI:

Target URI Scope
/ Every document on the host
A path ending in / Every document under that prefix
Any other path That single document
QUERY /
Authorization: Basic SmFtZXMgRHVuY2FuOnNlY3JldA==
Content-Type: text/css-selector

h1
HTTP/1.1 201
etag: "85ec253dcbf0f9e47aa29c6bcf5ebb8e017f785e6286f49a736f8a6247f048cb"
content-type: text/html

<!DOCTYPE html>
<html><body><h1>Benji</h1></body></html>

Even a single-document query returns multipart/mixed — the response shape is the same at every scope, so a client doesn't need to special-case it.

Each part carries:

A query that matches nothing still returns 200 OK with an empty multipart body.

Conditional re-query. The top-level ETag is computed from the selector text plus every matched fragment's ETag, so it changes whenever the selector, the match set, or any matched fragment changes. Send it back as If-None-Match to get a cheap 304 Not Modified when nothing has:

QUERY /
Authorization: Basic SmFtZXMgRHVuY2FuOnNlY3JldA==
Content-Type: text/css-selector
If-None-Match: {{query_etag}}

h1
HTTP/1.1 200
etag: "135d9146abf272b94c84122fe86149389ca6c49196f65a6066518e62a6e3d6ca"

On the edge proxy

The public edge (dombase-http) also answers a text/css-selector QUERY, but only for a single, individually addressed document — not a host or subtree — and it queries the composed page (after templates, includes, and expressions have run), not the raw stored markup. It's a read, authorized the same way a GET of that path would be.

QUERY /query-composed-doc.html
Content-Type: text/css-selector

.calc
HTTP/1.1 201

<!DOCTYPE html>
<html xmlns:pagelove="https://pagelove.org/1.0"><body>
  <main class="calc" pagelove:template="text/liquid">{{ 2 | plus: 3 }}</main>
</body></html>

Errors

Condition Status
Missing or wrong Content-Type 415 Unsupported Media Type (Accept-Query: text/css-selector)
Empty body, or a selector that fails to parse 422 Unprocessable Content
Accept header excludes multipart/mixed 406 Not Acceptable
Target path doesn't exist 404 Not Found

Sessel mode

Send a Sessel expression as the body with Content-Type: text/sessel. self binds to the target document (QUERY /doc.html); querying a directory leaves self unbound, so an expression that references it (e.g. from self) fails with 416.

QUERY /team/james.html
Content-Type: text/sessel

${h1}.first()
HTTP/1.1 206
Content-Type: text/html

<main class="calc">5</main>

The response's Content-Type depends on what the expression evaluates to:

Result Content-Type
An element text/html — the element's outer HTML
Anything else (a string, number, boolean, list, map, or null) application/sessel+json
QUERY /team/james.html
Content-Type: text/sessel

${li}.count()
HTTP/1.1 200
Content-Type: text/html

<h1>James</h1>

application/sessel+json is application/json plus one addition: an element embedded inside the result (say, from a list built with a schema search()) is wrapped as a tagged object rather than serialized as plain HTML text —

{
  "$type": "element",
  "$html": "<div id=\"widget\">...</div>",
  "$source": "/team/benji.html"
}

— so a client can tell an embedded element apart from an ordinary string. $source is the document the element came from (omitted for an element built with new rather than selected from storage).

Paginating a list result

Send Range: entries=<start>-<end> alongside the query to paginate a list result the same way Range: entries= paginates any other list — see Reading and writing for the general mechanism. The response's Content-Range: entries <start>-<end>/<total> reports the slice against the full result length.

Errors

Condition Status
Missing/wrong Content-Type, or an expression that fails to parse or evaluate, or an empty body 400 Bad Request
Target document not found 404 Not Found
self unbound on a directory query, or entries= requested on a non-list result 416 Range Not Satisfiable
Expression raised a runtime error 500 Internal Server Error

See also