Protocol
← All sections · part of the machine-readable /all/ index.
Protocol
Reference for wire-level HTTP details — capability discovery via OPTIONS and file-level authoring via WebDAV.
Pages in this group
- OPTIONS method — discover which HTTP methods are allowed on a resource or element
- WebDAV — file-level authoring interface for content management tools
- Accept-Ranges — the range units Pagelove advertises, and a known CDN header-rewrite caveat
See also
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
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:
404 means the file you addressed is not there.
500 means the server failed — including when the server could not load
the site's schema. A schema problem is never reported as 404, because
that would say your file had been deleted when it had not. A syncing client
should treat 404 as "this file is gone" and 500 as "try again later",
and the server will not confuse the two.
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
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: 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 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:
text/css-selector — a CSS selector, returning every matching element as a multipart/mixed response. Served by the WebDAV authoring server, and (for a single document) by the edge proxy.
text/sessel — a Sessel expression, evaluated against a document. Served by the edge proxy.
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:
Content-Location — the document the match came from.
Content-Range: selector <path> — a document-rooted CSS selector that addresses this specific match (not the request selector, which may match several elements) — round-trip it into a follow-up PUT/DELETE to touch just that element.
- The matched fragment's
ETag and Last-Modified.
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
- Sessel — the expression language QUERY's
text/sessel mode evaluates
- Reading and writing —
GET and its Range units, including the general entries= pagination mechanism
- OPTIONS method — discovering
Accept-Query for a resource
- Accept-Ranges — the related header for
GET's Range units