Modeling data
← All sections · part of the machine-readable /all/ index.
Modeling data
Reference for Pagelove's schema system — declaring governed types, enforcing shape, validating business rules, transforming values on the way in and out, and expressing required combinations across properties.
Pages in this group
- Schema — declare a governed type and its properties
- Property — declare a single field with cardinality, type, default, and validator
- Methods — declare a named operation on a type (name, parameters, return, Sessel or JavaScript implementation)
- Types — the primitive types a property can use and how each is stored
- Required combinations — enforce "at least one of these" or "exactly one of these" across several properties of the same item
- Resolvers —
@write and @read expressions that transform values at write and read time
- Shape Constraint — enforce document structure via CSS selectors
Schema slots (default, @read, @write, @validate) and methods can be written in Sessel or JavaScript. The Sessel forms live here (Resolvers, Methods, defaults). For the JavaScript forms — and the DOM API they use — see the JavaScript → Server pages JavaScript in schemas and the JavaScript DOM API.
What modeling unlocks in pages
Composing a page needs no schema — templates, resource bindings, includes, and expression bindings work on plain HTML and the site graph (see Composing pages). Modeling a type adds capabilities a page can then use:
- Method Elements — invoke a Method you declared on a schema by name (
<prefix:method-name>), replacing the element with its result.
- Typed instances — a schema turns documents into governed instances, so a Resource Binding or Templating query selects and renders them as modeled data, and Resolvers transform their values on read.
See also
Schema
A Schema declares a governed type — the itemtype URL it applies to and the properties, validators, and constraints that are enforced when items of that type are written or read.
When to reach for it
Declare a schema for any itemtype that needs cardinality enforcement, type validation, defaults, resolvers, uniqueness, referential integrity, or @validate rules. Items whose itemtype has no matching schema are stored unchanged, without any of those checks.
Shape
A schema is an HTML element with itemscope itemtype="https://pagelove.org/Schema" and a type child that names the governed URL. Everything else is optional.
<div itemscope itemtype="https://pagelove.org/Schema">
<meta itemprop="type" content="https://example.com/Person">
<meta itemprop="parent" content="https://example.com/Entity">
<div itemprop="property" itemscope itemtype="https://pagelove.org/Property">
<!-- See the Property reference -->
</div>
<div itemprop="constraint" itemscope itemtype="https://pagelove.org/GroupConstraint">
<!-- See the Required combinations reference -->
</div>
<script type="text/sessel" itemprop="@validate">
<!-- Schema-level validator -->
</script>
</div>
Fields
| Field |
Cardinality |
Accepts |
Purpose |
type |
1..1 |
URL |
The itemtype URL this schema governs. A schema with no type is silently skipped. |
name |
0..1 |
Text |
Human-readable name. Used in error messages and tooling. |
description |
0..1 |
Text |
Human-readable description of what the type represents. |
parent |
0..1 |
URL |
Governed type URL of a parent schema to inherit from. |
property |
0..n |
Nested Property item |
A property declaration. |
constraint |
0..n |
Nested Required combinations item |
A group-level cardinality rule across several properties. |
@validate |
0..1 |
Sessel or JavaScript binding |
Schema-level validator. Must return truthy. See below. |
Inheritance
A schema with a parent field inherits everything the parent declares — properties, group constraints, resolvers, and validators. The chain is walked from root to leaf; a child property with the same name as a parent property overrides the parent's declaration for that property.
When parent is omitted, user-defined schemas implicitly inherit from https://pagelove.org/Instance. Every user schema participates in the Instance type hierarchy unless it declares a different explicit parent.
Cycles are detected and rejected — a cyclic chain produces a 422 on every write to items of any schema in the cycle. An unknown parent URL is not rejected: the inheritance chain silently stops at the last resolvable ancestor, and the schema behaves as if it had no further parent. This is different from a cycle, which is always an error.
How inherited members combine
| Inherited member |
Merge rule |
| Properties |
All parent properties are included. Child declarations override parent declarations by name. |
| Group constraints |
Parent constraints apply unless the child declares a constraint under the same group name. |
@read resolvers |
Run ancestor-first — parents transform before children. |
@write resolvers |
Run child-first — children transform before parents. |
@validate expressions |
Every validator in the chain runs, ancestor-first. All must return true. |
unique |
If any schema in the chain marks a property as unique, it is unique for the leaf type. |
Schema-level @validate
Use schema-level @validate for rules that span more than one property — "start date must be before end date", "total must equal the sum of line items".
Schema-level @validate accepts either a Sessel expression or a JavaScript module. As with the other binding slots, the language is selected by the wrapper's itemtype URL, not by the <script type> attribute.
Sessel
The expression's self binding is the [itemscope] element being written, so self.microdata() gives read access to every property on the item.
<script type="text/sessel" itemprop="@validate">
let md = self.microdata();
md["start-date"].first() < md["end-date"].first()
</script>
JavaScript
Wrap an ES module in a JavaScript/Module item. The module's default export must be a function, and the schema-level slot binds the instance to this as a serialized element string (it is not passed as a positional argument; the positional context argument is null for this slot). A function form is required so this is observable; an arrow function in a strict-mode ES module does not bind its own this. Return truthy to accept, falsy or thrown to reject.
<div itemprop="@validate" itemscope itemtype="https://pagelove.org/JavaScript/Module">
<script itemprop="source" type="module">
export default function () {
// `this` is the serialized instance HTML.
return this.includes('itemprop="start-date"');
}
</script>
</div>
Note: the schema-level slot's this-as-serialized-HTML convention differs from a property-level JavaScript @validate, where the property's value is passed as the first positional argument. See JavaScript bindings for the full contract, supported language features, errors, and resource limits.
Schema-level @validate runs only after cardinality, type, and property-level @validate have all passed, and before group constraints are checked. This guarantees per-property structural correctness before the cross-property rule evaluates against it; group constraints (which span properties, the same category of check) run last.
Examples
Minimal
<div itemscope itemtype="https://pagelove.org/Schema">
<meta itemprop="type" content="https://example.com/Tag">
<div itemprop="property" itemscope itemtype="https://pagelove.org/Property">
<meta itemprop="name" content="label">
<meta itemprop="type" content="https://schema.host/Text">
<meta itemprop="cardinality" content="1..1">
<meta itemprop="unique" content="true">
</div>
</div>
With inheritance and a cross-property validator
<div itemscope itemtype="https://pagelove.org/Schema">
<meta itemprop="type" content="https://example.com/Entity">
<div itemprop="property" itemscope itemtype="https://pagelove.org/Property">
<meta itemprop="name" content="identifier">
<meta itemprop="type" content="https://schema.host/Text">
<meta itemprop="cardinality" content="1..1">
<meta itemprop="unique" content="true">
</div>
</div>
<div itemscope itemtype="https://pagelove.org/Schema">
<meta itemprop="type" content="https://example.com/Person">
<meta itemprop="parent" content="https://example.com/Entity">
<div itemprop="property" itemscope itemtype="https://pagelove.org/Property">
<meta itemprop="name" content="email">
<meta itemprop="type" content="https://schema.host/Text">
<meta itemprop="cardinality" content="1..1">
</div>
<script type="text/sessel" itemprop="@validate">
let md = self.microdata();
md["email"].first().matches("^[^@]+@[^@]+\\.[^@]+$")
</script>
</div>
Error cases
| Condition |
Result |
Schema with no type |
Silently skipped during parsing. No writes are validated against it. |
Unknown parent URL |
Not an error — the inheritance chain silently stops at the last resolvable ancestor. |
| Circular inheritance chain |
422 on every write to items of any schema in the cycle. |
@validate fails to compile |
422 on every write to items of the governed type. |
@validate returns anything other than true |
422 with check: "@validate" in the violation. |
The 422 response body is HTML with Microdata, using the https://pagelove.org/SchemaViolation itemtype.
See also
Property
A Property item inside a Schema declares a single field on a governed type — its name, type, cardinality, default, uniqueness, references, cascade behaviour, group membership, and resolvers.
When to reach for it
Declare a Property for every itemprop on a governed type that should be checked, defaulted, transformed, or constrained. An itemprop with no matching Property declaration is ignored by validation — only declared properties are checked.
Shape
A property is a nested [itemscope] inside a schema, linked via itemprop="property".
<div itemprop="property" itemscope itemtype="https://pagelove.org/Property">
<meta itemprop="name" content="email">
<meta itemprop="type" content="https://schema.host/Text">
<meta itemprop="cardinality" content="1..1">
<meta itemprop="default" content="user@example.com">
<meta itemprop="unique" content="true">
<meta itemprop="references" content="https://example.com/Org#identifier">
<meta itemprop="cascade" content="restrict">
<meta itemprop="group" content="contact-info">
<script type="text/sessel" itemprop="@validate">
self.all((el) => el.text().matches("^[^@]+@[^@]+\\.[^@]+$"))
</script>
</div>
Fields
| Field |
Cardinality |
Accepts |
Purpose |
name |
1..1 |
Text |
Matches the itemprop on data items. A property with no name is silently skipped. |
type |
0..1 |
URL |
A primitive type URL from schema.host or another schema's governed type URL. When omitted, no type validation runs. |
cardinality |
0..1 |
Text |
0..1, 1..1, 0..n, 1..n. Defaults to 0..n. |
description |
0..1 |
Text |
Human-readable description for tooling and error messages. |
default |
0..1 |
Text or Sessel item |
Value injected when the property is absent on a write. See below. |
unique |
0..n |
Text |
"true" for individual uniqueness; any other string is a composite-uniqueness group name. |
references |
0..1 |
Text |
Foreign key in the form {itemtype}#{itemprop}. The target must be declared unique: true. |
cascade |
0..1 |
Text |
"true" (cascade delete) or "restrict" (block delete). Requires references. |
group |
0..n |
Text |
Group name(s) for required combinations. |
@validate |
0..1 |
Sessel or JavaScript binding |
Must return truthy. self/first arg is the property value. Fires at persistence time. See Resolvers. |
@write |
0..1 |
Sessel or JavaScript binding |
Transforms property values before storage. See Resolvers. |
@read |
0..1 |
Sessel or JavaScript binding |
Transforms property values on read. See Resolvers. |
@key |
0..1 |
Boolean ("true" / "false") |
Marks this property as the schema's primary key. The instance's value for this property is emitted as id="…" on the root element when the instance is materialised via Pagelove.PUT. Requires unique: "true" on the same property. See below. |
Cardinality values
| Value |
Meaning |
0..n |
Any number of values allowed. Default when cardinality is omitted. |
0..1 |
At most one value. |
1..1 |
Exactly one value required. |
1..n |
At least one value required. |
Cardinality counts [itemprop="<name>"] elements within the item's scope, excluding anything inside nested [itemscope] boundaries.
Type
When type is a primitive URL (https://schema.host/Text, Number, Integer, Boolean, Date, DateTime), each value is format-validated against the rules for that type — see Types.
When type is another schema's governed URL, each value must be a nested [itemscope] with that itemtype, and the nested item is validated recursively against its own schema.
When type is omitted, no type validation runs and any string is accepted.
Defaults
A static default is a literal value, injected as <meta itemprop="..." content="..."> when the property is absent on a write:
<meta itemprop="default" content="draft">
A dynamic default is an expression evaluated at write time, written in either Sessel or JavaScript. The language is selected by the itemtype URL on the wrapper:
<div itemprop="default" itemscope itemtype="https://pagelove.org/Sessel">
<script itemprop="source" type="text/sessel">String.random(8)</script>
</div>
<div itemprop="default" itemscope itemtype="https://pagelove.org/JavaScript/Module">
<script itemprop="source" type="module">
export default () => String(Math.floor(100 + Math.random() * 500));
</script>
</div>
Dynamic defaults run server-side, before validation. The Sessel form is a bare expression; self is unavailable because the item does not yet contain the property. The JavaScript form is an ES module whose default export is a function; this is bound to the in-progress instance and a context object is passed as the first positional argument. See JavaScript bindings for the full JavaScript contract.
Child defaults override parent defaults for the same property name.
The same JavaScript/Module source is also used by the pagelove.mjs client-side runtime when discovering schemas in the browser — see Schema definitions in HTML.
Uniqueness
unique: "true" declares individual uniqueness — no two items of this governed type on the host may have the same value for this property.
Any other string is treated as a composite-uniqueness group name. Properties sharing the same group name form a composite key: the combination of their values must be unique across items, though individual values may repeat.
<meta itemprop="unique" content="org-role">
A property can participate in both individual and composite uniqueness:
<meta itemprop="unique" content="true">
<meta itemprop="unique" content="email-org">
Uniqueness is enforced atomically with the write. If any schema in the inheritance chain declares a property as unique, it is unique for the leaf type.
References and cascade
references declares a foreign key. The value format is {itemtype}#{itemprop}, pointing at a property that is declared unique: true on the target type. Every value of the referencing property must match an existing value on some item of the target type.
<meta itemprop="references" content="https://example.com/Org#identifier">
<meta itemprop="cascade" content="restrict">
cascade value |
Behaviour on delete of the referenced value |
"true" |
Cascade: delete referencing items (or clear the reference based on cardinality). |
"restrict" |
Block the operation. A restrict-blocked DELETE returns 409 Conflict. |
omitted / "false" |
No cascade behaviour. |
If several referencing types point at the same source property and any one of them uses restrict, every cascade rule for that source is upgraded to restrict. Data integrity is never silently violated.
What triggers a cascade
A cascade fires when the referenced value disappears or changes through a schema-valid operation:
DELETE of the document holding the referenced value.
- A write that removes or changes the referenced value, where the resulting document still satisfies its own schema.
Removing the only value of a 1..1 property is not schema-valid — the document would be left violating its own cardinality — and is rejected with 422 Unprocessable Content before any cascade is considered. The SQL analogue: cascade is a referential action on deleting the row, not on setting a NOT NULL key to NULL. To remove a 1..1 referenced value and cascade, delete the whole document. For 0..1 / 0..n referenced properties, removing the value element (e.g. a selector DELETE) is schema-valid and triggers the cascade.
What the cascade does to referrers
When a cascade fires, each referencing item's disposition is determined by the referencing property's cardinality:
| Referencing cardinality |
Action |
1..1 |
The referencing document is deleted. |
0..1 / 0..n |
The referencing property element is removed; the document survives. |
1..n |
The matching value element is removed; if it was the last one, the document is deleted. |
When the referenced value changes (rather than disappears), cascade: "true" rewrites the referencing property values to the new value.
Primary key
@key: "true" declares that this property's value is the schema's primary key. When an instance is materialised via Pagelove.PUT, the property's value is written as the id attribute on the instance's root element.
<li itemprop="property" itemscope itemtype="https://pagelove.org/Property">
<meta itemprop="name" content="hid">
<meta itemprop="type" content="https://schema.host/Text">
<meta itemprop="cardinality" content="1..1">
<meta itemprop="unique" content="true">
<meta itemprop="@key" content="true">
</li>
A Host instance constructed and PUT through Sessel:
let h = new Host { hid: "abc123", hostname: "abc123.example.com" };
Pagelove.PUT(h, "/hosts/abc123.html")
Persists with the keyed id on the root:
<div id="abc123" itemscope itemtype="https://example.com/Host">
<meta itemprop="hid" content="abc123">
<meta itemprop="hostname" content="abc123.example.com">
</div>
Requirements
| Condition |
Behaviour |
@key: "true" AND unique contains "true" |
Honoured. |
@key: "true" on a property that is not individually unique |
Ignored. |
Composite unique="<group-name>" only (no individual "true") |
Does not qualify — same as above. |
Two or more properties on one schema declare @key: "true" |
Document order wins. |
Schema's own properties have no @key; parent schema declares one |
Parent's @key applies (inheritance). |
No @key anywhere in the inheritance chain |
No id is set on the materialised root; current behaviour preserved. |
Value rules
The keyed value is emitted verbatim as the id attribute. Two value-shape rules apply at PUT time:
| Value shape |
Behaviour |
| Non-empty, no whitespace |
Emitted as id="<value>". Any pre-existing id on the root is overwritten. |
| Empty |
No id is emitted. |
| Contains whitespace |
No id is emitted (whitespace breaks #id selectors). |
| Property is a computed property |
No stored value at materialisation time; no id is emitted. |
Inheritance
@key is inherited from parent schemas. The resolver walks the schema's own properties first (document order, first wins on duplicates), then the parent's properties, and so on up the chain. A child schema can declare its own @key to override the parent's.
Auto-generated default
A property marked @key="true" with no explicit default automatically gets a Sessel default that produces an 8-character value at instance construction. The first character is always a lowercase letter ([a-z]) and the remaining 7 are lowercase-alphanumeric ([a-z0-9]) — this guarantees the value is a valid CSS #id selector (CSS identifiers cannot start with a digit). So:
<li itemprop="property" itemscope itemtype="https://pagelove.org/Property">
<meta itemprop="name" content="hid">
<meta itemprop="type" content="https://schema.host/Text">
<meta itemprop="cardinality" content="1..1">
<meta itemprop="unique" content="true">
<meta itemprop="@key" content="true">
</li>
Behaves as if you had written:
<li itemprop="property" itemscope itemtype="https://pagelove.org/Property">
<meta itemprop="name" content="hid">
<meta itemprop="type" content="https://schema.host/Text">
<meta itemprop="cardinality" content="1..1">
<meta itemprop="unique" content="true">
<meta itemprop="@key" content="true">
<div itemprop="default" itemscope itemtype="https://pagelove.org/Sessel">
<script itemprop="source" type="text/sessel">
String.random(1, { lower: true }) + String.random(7, { lower: true, digits: true })
</script>
</div>
</li>
new Host {} (no hid) materialises with a random hid like "k3p9zx2m" (always letter-first), the cardinality 1..1 is satisfied, and Pagelove.PUT writes the instance with id="k3p9zx2m" on the root.
To override, declare an explicit default on the property, or pass an explicit value at construction (new Host { hid: "abc123" }).
Computed properties skip the auto-default — they have no stored value to default. Declaring both @key: "true" and a computed binding on the same property has no effect: no auto-default is synthesized, and the property remains computed.
Computed properties
A computed property has no stored value: its value is calculated every time the property is read, from the rest of the instance. Attempting to write a value to a computed property fails — the write is rejected with an error rather than silently accepted.
The current mechanism is an @computed slot, in either Sessel or JavaScript:
<div itemprop="property" itemscope itemtype="https://pagelove.org/Property">
<meta itemprop="name" content="display-name">
<div itemprop="@computed" itemscope itemtype="https://pagelove.org/Sessel">
<script itemprop="source" type="text/sessel">
self.first-name + " " + self.last-name
</script>
</div>
</div>
<div itemprop="property" itemscope itemtype="https://pagelove.org/Property">
<meta itemprop="name" content="full-name">
<div itemprop="@computed" itemscope itemtype="https://pagelove.org/JavaScript/Module">
<script itemprop="source" type="module">
export default function() { return this.firstName + " " + this.lastName; }
</script>
</div>
</div>
In Sessel, self refers to the whole instance. In JavaScript, this refers to the whole instance (a function form is required — an arrow function does not bind its own this). Either way the expression sees the instance's other properties, not a list of property elements. If a property has both @computed and @read, @computed takes precedence and @read never fires for it. See Resolvers for how @read/@write transformers differ from a computed property, and JavaScript bindings for the JavaScript contract.
Legacy syntax: @read wrapped in bare Sessel
An older, Sessel-only shorthand is still recognized for backward compatibility: an @read slot whose typed item is the bare https://pagelove.org/Sessel type (not Sessel/Lambda) is parsed as a computed property rather than a read transformer:
<div itemprop="property" itemscope itemtype="https://pagelove.org/Property">
<meta itemprop="name" content="display-name">
<div itemprop="@read" itemscope itemtype="https://pagelove.org/Sessel">
<script itemprop="source" type="text/sessel">
self.first-name + " " + self.last-name
</script>
</div>
</div>
New schemas should prefer @computed — it works in both languages and doesn't rely on the reader recognizing which of the two Sessel item types is in play.
Validators
@validate is a binding that runs at persistence time (PUT), after defaults have been applied but before the required-property check. In Sessel, self is the property value. In JavaScript, the property value is the first positional argument. The binding must return a truthy value for the write to proceed.
Sessel validator
<div itemprop="@validate" itemscope itemtype="https://pagelove.org/Sessel">
<script itemprop="source" type="text/sessel">
self.all((el) => el.text().matches("^[^@]+@[^@]+\\.[^@]+$"))
</script>
</div>
JavaScript validator
<div itemprop="@validate" itemscope itemtype="https://pagelove.org/JavaScript/Module">
<script itemprop="source" type="module">
export default (val) => typeof val === 'string' && val.includes('@');
</script>
</div>
When a schema uses inheritance, the most-derived @validate wins. There is no chaining for validators -- a child schema's validator completely overrides the parent's.
The JavaScript form is the same JavaScript/Module typed item used for defaults and resolvers. See JavaScript bindings for the full contract — export default, supported language features, errors, and resource limits.
Error cases
| Condition |
Result |
Property with no name |
Silently skipped during parsing. |
| Cardinality violation |
422 with check: "cardinality". |
| Type validation failure |
422 with check: "type". |
@validate returns anything other than true |
422 with check: "@validate". |
@validate fails to compile |
422 on every write to the governed type. |
Malformed references format (missing #, empty type or property) |
Schema load error; every write to the type is rejected. |
references target is not declared unique: true |
Schema load error; every write to the type is rejected. |
| Dynamic default expression fails at write time |
Write error on the affected item. |
@key: "true" on a property without unique: "true" |
Schema loads successfully; @key is ignored at resolution. |
Two or more @key: "true" declarations on one schema |
Schema loads successfully; document order wins. |
@key value at PUT time is empty or contains whitespace |
PUT proceeds; no id set on root. |
See also
- Schema — the parent schema item
- Types — primitive types and schema-reference types
- Required combinations — group constraints across several properties
- Resolvers —
@read and @write pipeline details
- JavaScript bindings — the server-side JavaScript binding contract for
default, @read, @write, and @validate
Methods
A Method item inside a Schema declares a named operation on a governed type — its name, parameters, return type, and implementation. A Method is a kind of Property item, declared with itemtype="https://pagelove.org/Method" instead of .../Property.
A declared method is the thing a Method Element invokes during page composition: declaring the method is data modeling; calling <prefix:method-name> in a page is composition. This page documents the declaration; see Method Elements for invocation.
Declaration
A Method is a <li itemprop="property" itemscope itemtype="https://pagelove.org/Method"> in the Schema's property list:
<div hidden itemscope itemtype="https://pagelove.org/Schema">
<meta itemprop="type" content="urn:Test">
<ul>
<li itemprop="property" itemscope itemtype="https://pagelove.org/Method">
<meta itemprop="name" content="foo">
<meta itemprop="returns" content="https://pagelove.org/Element">
<div itemprop="implementation" itemscope itemtype="https://pagelove.org/Sessel">
<script itemprop="source" type="text/sessel">
new ul { new li { "foo" } }
</script>
</div>
</li>
</ul>
</div>
The schema is registered with the host's cache when the document containing it is PUT/POST'd.
Fields
| Field |
itemprop |
Required |
Description |
| Name |
name |
yes |
The method name. Matches the local name in a Method Element's tag (foo in <t:foo>) and the property name in an in-VM call (obj.foo()). |
| Implementation |
implementation |
yes |
An item whose itemtype is https://pagelove.org/Sessel or https://pagelove.org/JavaScript/Module, with the method body in its source. |
| Return type |
returns |
no |
A type URL hint. https://pagelove.org/Element (or a subtype) marks the result as an HTML fragment. |
| Parameters |
parameter |
no |
Zero or more <li itemprop="parameter" itemscope itemtype="https://pagelove.org/Parameter"> items, each with a name and type, in declared order. |
| Static |
static |
no |
<meta itemprop="static" content="true"> declares a class-side method, installed on the constructor rather than the prototype and invoked with the constructor itself bound as self/this, rather than an instance. |
Implementation languages
The implementation item's itemtype selects the language:
https://pagelove.org/Sessel — the source is a Sessel expression. Parameters are bound as named local variables. See the Sessel reference for the expression language.
https://pagelove.org/JavaScript/Module — the source is an ES module whose default export is the method function. this is the owning instance (or the constructor for static methods); parameters are passed positionally in declared order. See JavaScript bindings and Schema definitions in HTML.
Both languages produce the same kinds of result; how a returned value updates a page is documented under Method Elements.
Parameters
Each parameter is a <li itemprop="parameter" itemscope itemtype="https://pagelove.org/Parameter"> with a name and a type:
<li itemprop="parameter" itemscope itemtype="https://pagelove.org/Parameter">
<meta itemprop="name" content="title">
<meta itemprop="type" content="https://schema.host/Text">
</li>
The declared parameter order is the order JavaScript implementations receive their positional arguments; Sessel implementations read each by name.
Overloading
Two methods on the same schema may share a name while declaring different parameters. A caller selects the overload whose declared parameters are all supplied. The most specific match — the overload whose full parameter set is present — wins.
doesNotUnderstand
A method named literally doesNotUnderstand is a catch-all: it handles any invocation whose name matches no declared method on the schema. It always receives messageName (the unrecognised name) and parameters, but the shape of parameters depends on how the invocation was dispatched during page composition — a list of { name, value } maps for an element-tag dispatch, or a list containing a single bare string for an attribute dispatch. A Sessel implementation reads messageName and parameters as named locals; a JavaScript implementation declares them as parameters (messageName, then parameters), positionally. The two dispatch forms and their respective parameters shapes are documented under Method Elements.
See also
- Method Elements — invoking a declared method in a composed page (the composition feature a method unlocks).
- Schema — the governed type a method is declared on.
- Property — the sibling kind of property item (data fields).
- Sessel reference — the language for a Sessel
implementation body.
- JavaScript bindings — server-side JavaScript modules in schema slots.
Required combinations
A GroupConstraint item inside a Schema declares a cardinality constraint across a named group of properties — "exactly one of these must be present", "at least one", "at most one".
When to use
Use a group constraint when you have mutually exclusive or collectively required properties. For example:
- A user must authenticate with exactly one of
password, oauth-token, or saml-assertion.
- A contact must have at least one of
email, phone, or address.
- A payment must have at most one of
credit-card or bank-account.
Syntax
A GroupConstraint is nested within a Schema via itemprop="constraint".
<div itemprop="constraint" itemscope itemtype="https://schema.host/GroupConstraint">
<meta itemprop="group" content="auth-method">
<meta itemprop="cardinality" content="1..1">
</div>
The properties that belong to the group are declared individually using the group property on each Property definition:
<div itemprop="property" itemscope itemtype="https://pagelove.org/Property">
<meta itemprop="name" content="password">
<meta itemprop="type" content="https://schema.host/Text">
<meta itemprop="cardinality" content="0..1">
<meta itemprop="group" content="auth-method">
</div>
<div itemprop="property" itemscope itemtype="https://pagelove.org/Property">
<meta itemprop="name" content="oauth-token">
<meta itemprop="type" content="https://schema.host/Text">
<meta itemprop="cardinality" content="0..1">
<meta itemprop="group" content="auth-method">
</div>
Properties
| Property |
Type |
Cardinality |
Description |
group |
Text |
1..1 |
The group name. Must match group values on Property items. Required. |
cardinality |
Cardinal |
0..1 |
How many properties from the group must be present. Default 0..n. |
group (required)
The name of the group this constraint applies to. This must match the group property values declared on the Property items that belong to the group.
A GroupConstraint without a group property (or with an empty group name) is silently skipped.
cardinality (optional)
The cardinality constraint applied to the count of present properties in the group. "Present" means the property has at least one [itemprop] element in the item.
| Value |
Meaning |
0..n |
Any number of group properties may be present (default, no constraint) |
0..1 |
At most one property from the group may be present |
1..1 |
Exactly one property from the group must be present |
1..n |
At least one property from the group must be present |
If omitted, defaults to 0..n (no constraint).
How groups are assembled
Group membership is declared on individual Property items via the group property. A property can belong to multiple groups. The system collects all properties across the entire inheritance chain that share the same group name and counts how many are present on a given item.
For example, given properties password (group: auth-method), oauth-token (group: auth-method), and saml-assertion (group: auth-method), a group constraint with cardinality: 1..1 requires exactly one of these three to have at least one value.
Inheritance behaviour
Group constraints follow the same inheritance model as other schema features:
- Property group membership is additive across the chain. A child can add new properties to a group defined in the parent.
- Constraint cardinality uses child-overrides-parent. If the parent declares
auth-method as 1..1 and the child declares auth-method as 1..n, the child's 1..n takes effect for items of the child type.
- Group constraints from the parent apply to the child unless explicitly overridden.
Examples
Exactly one authentication method
<div itemscope itemtype="https://pagelove.org/Schema">
<meta itemprop="type" content="https://example.com/UserAuth">
<meta itemprop="name" content="UserAuth">
<div itemprop="property" itemscope itemtype="https://pagelove.org/Property">
<meta itemprop="name" content="password">
<meta itemprop="type" content="https://schema.host/Text">
<meta itemprop="cardinality" content="0..1">
<meta itemprop="group" content="auth-method">
</div>
<div itemprop="property" itemscope itemtype="https://pagelove.org/Property">
<meta itemprop="name" content="oauth-token">
<meta itemprop="type" content="https://schema.host/Text">
<meta itemprop="cardinality" content="0..1">
<meta itemprop="group" content="auth-method">
</div>
<div itemprop="constraint" itemscope itemtype="https://schema.host/GroupConstraint">
<meta itemprop="group" content="auth-method">
<meta itemprop="cardinality" content="1..1">
</div>
</div>
An item with exactly one auth method passes:
PUT /users/alice.html HTTP/2
Content-Type: text/html
<!DOCTYPE html>
<html><body>
<div itemscope itemtype="https://example.com/UserAuth">
<meta itemprop="password" content="hashed-secret">
</div>
</body></html>
An item with both auth methods fails with 422:
PUT /users/bob.html HTTP/2
Content-Type: text/html
<!DOCTYPE html>
<html><body>
<div itemscope itemtype="https://example.com/UserAuth">
<meta itemprop="password" content="hashed-secret">
<meta itemprop="oauth-token" content="token123">
</div>
</body></html>
<div itemprop="constraint" itemscope itemtype="https://schema.host/GroupConstraint">
<meta itemprop="group" content="contact-info">
<meta itemprop="cardinality" content="1..n">
</div>
With properties email, phone, and address all assigned to the contact-info group, at least one must be present.
Error cases
| Condition |
Response |
Item under itemprop="constraint" missing group |
Silently skipped — not recognized as a group constraint |
Item under itemprop="constraint" with empty group |
Silently skipped — not recognized as a group constraint |
| Group cardinality violation |
422 with check: "group" and message containing the group name |
The itemtype on an item nested under itemprop="constraint" is not itself checked — whether an item is treated as a group constraint depends only on whether it carries a non-empty group value. Always use https://schema.host/GroupConstraint as shown above; an item of a different type that happens to carry a group field would still be enforced as one.
Group constraint validation runs only after cardinality, type, property-level @validate, and schema-level @validate have all passed.
The violation message follows the format: group '<name>' constraint violated: <cardinality error>.
See also
- Schema — where
GroupConstraint items are declared
- Property — the
group field on property items
Types
The schema type system defines what values are valid for a property. A type is specified via the type field on a Property item, as a URL that identifies the type.
When to reach for it
Set type on a property when the property's value must conform to a specific format — a valid integer, a parseable date, a URL with a scheme. When type is omitted, no type validation runs and any string value is accepted.
Primitive types
Primitive types are hosted at https://schema.host/ and provide built-in format validation at write time.
| Type URL |
Short name |
Validation rule |
https://schema.host/Text |
Text |
Any string. No validation. |
https://schema.host/String |
String |
Alias for Text. |
https://schema.host/URL |
URL |
Must be a valid URL with a scheme (RFC 3986). |
https://schema.host/Number |
Number |
Must parse as a floating-point number (f64). |
https://schema.host/Integer |
Integer |
Must parse as a 64-bit signed integer (i64). Floats like "3.14" are rejected. |
https://schema.host/FloatingPoint |
FloatingPoint |
Same as Number. |
https://schema.host/Boolean |
Boolean |
Must be exactly "true" or "false" (lowercase). |
https://schema.host/DateTime |
DateTime |
Must be a valid RFC 3339 datetime (e.g. "2024-01-15T10:30:00Z"). Date-only strings are rejected. |
https://schema.host/Date |
Date |
Must be YYYY-MM-DD. Full datetimes are rejected. Calendar correctness is enforced (Feb 29 in non-leap years is rejected). |
https://schema.host/Cardinal |
Cardinal |
A cardinality string: 0..1, 1..1, 0..n, or 1..n. Used internally by schema definitions. |
Syntax
<meta itemprop="type" content="https://schema.host/Integer">
Validation examples
| Type |
Valid |
Invalid |
| Text |
"", "hello", "<b>bold</b>" |
(always passes) |
| URL |
"https://example.com", "mailto:a@b.com" |
"not-a-url", "" |
| Number |
"42", "3.14", "-7.5" |
"hello", "" |
| Integer |
"100", "-42", "0" |
"3.14", "abc", "" |
| Boolean |
"true", "false" |
"True", "1", "" |
| DateTime |
"2024-01-15T10:30:00Z" |
"2024-01-15", "not-a-date" |
| Date |
"2024-01-15", "2024-02-29" (leap year) |
"2023-02-29", "15/01/2024" |
Enum types
An enum is a named type whose valid values are listed explicitly. Declare it as a https://schema.host/Enum item in any schema document, then reference its URL from a property's type:
<div itemscope itemtype="https://schema.host/Enum">
<meta itemprop="name" content="https://example.com/Status">
<meta itemprop="value" content="active">
<meta itemprop="value" content="retired">
</div>
<div itemprop="property" itemscope itemtype="https://pagelove.org/Property">
<meta itemprop="name" content="status">
<meta itemprop="type" content="https://example.com/Status">
</div>
The enum's identifying URL may be declared with itemprop="name" or itemprop="type" — both forms are accepted.
A write whose property value is not in the enum's value list is rejected with a 422 whose message names the offending value and the permitted list:
[https://example.com/Gadget].status: Value "sideways" is not a valid
https://example.com/Status (expected one of: "active", "retired")
Enums declared in your host's own schema documents override a platform enum with the same URL, the same way host-local schemas override system schemas.
Nested schema types
When type is a URL that matches another schema's governed type, the property value must be a nested [itemscope] element with that itemtype. The nested item is validated recursively against its own schema — cardinality, type, @validate, and group constraints all apply.
<div itemprop="property" itemscope itemtype="https://pagelove.org/Property">
<meta itemprop="name" content="address">
<meta itemprop="type" content="https://example.com/Address">
<meta itemprop="cardinality" content="0..1">
</div>
A data item using this property:
<div itemscope itemtype="https://example.com/Person">
<span itemprop="name">Alice</span>
<div itemprop="address" itemscope itemtype="https://example.com/Address">
<span itemprop="street">123 Main St</span>
<span itemprop="city">Springfield</span>
</div>
</div>
The nested Address item is validated against the https://example.com/Address schema. The self binding for a nested schema-level @validate is the nested [itemscope] element, not the parent.
Unknown types
A type URL that matches neither a primitive, a declared enum, nor a known schema is silently ignored — no type validation runs and any value is accepted. This allows forward-compatible use of type URLs before their schemas are defined.
How type validation runs
Type validation runs alongside cardinality checks, before property-level @validate expressions. The check operates on the text value extracted from each [itemprop] element — for elements with a content attribute (e.g. <meta>), the content value is checked; for other elements, the text content is checked.
Type violations are collected together with cardinality violations and reported in a single 422 response, so the reader can fix all shape errors in one pass.
Error cases
| Condition |
Result |
| Value fails primitive type validation |
422 with check: "type", message describes the failure. |
| Value is not in a declared enum's value list |
422 with check: "enum", message lists the permitted values. |
Nested item has wrong itemtype |
Treated as a type mismatch. |
| Unknown type URL |
No validation (passes). |
type omitted |
No validation (passes). |
See also
Resolvers
Resolvers (also called property pipelines) are bindings declared on Property definitions that transform property values on the write and read paths. @write resolvers run before storage. @read resolvers run before the response is sent to the client. Resolvers can be written in Sessel or JavaScript.
When to reach for it
Use a resolver when a stored value should be normalised before it reaches storage (trim whitespace, lowercase an email, compute a slug) or transformed before it reaches the reader (format a timestamp, enrich a value with a lookup, compute a derived field).
Shape
A resolver is a typed microdata item with itemprop="@write" or itemprop="@read", placed inside a Property definition. The itemtype selects the language.
Sessel resolver
<div itemprop="property" itemscope itemtype="https://pagelove.org/Property">
<meta itemprop="name" content="email">
<meta itemprop="type" content="https://schema.host/Text">
<meta itemprop="cardinality" content="1..1">
<div itemprop="@write" itemscope itemtype="https://pagelove.org/Sessel">
<script itemprop="source" type="text/sessel">
self.map((el) => el.set_text(el.text().trim().lowercase()))
</script>
</div>
</div>
JavaScript resolver
<div itemprop="property" itemscope itemtype="https://pagelove.org/Property">
<meta itemprop="name" content="email">
<meta itemprop="type" content="https://schema.host/Text">
<meta itemprop="cardinality" content="1..1">
<div itemprop="@write" itemscope itemtype="https://pagelove.org/JavaScript/Module">
<script itemprop="source" type="module">
export default (val) => typeof val === 'string' ? val.trim().toLowerCase() : val;
</script>
</div>
</div>
The pipeline value
In both @read and @write bindings, the binding receives the property value as its primary input:
- Sessel:
self is the list of all [itemprop] elements with this property's name within the item, excluding anything inside nested [itemscope] boundaries. The expression must return a list of elements (or a single element, auto-wrapped).
- JavaScript: The first positional argument is the property value flowing through the chain. The function must return the transformed value. See JavaScript bindings for the full contract, including marshalling, error variants, and the
pagelove:schema import.
When each hook runs
| Hook |
Runs |
Effect |
@write |
Before validation. The data that validation sees is the post-transform result. |
Normalise values. |
@read |
After the value is fetched from storage, before it is sent to the client. |
Format or enrich values. |
Inheritance ordering
When a schema uses inheritance, resolvers from the entire chain form a sequence. The output of one step becomes self for the next. Schemas without a resolver for a given property are skipped.
| Hook |
Order |
Rationale |
@write |
Child-first (leaf → root) |
The child normalises before the parent applies broader rules. |
@read |
Ancestor-first (root → leaf) |
The parent provides a base transformation; the child refines it. |
Example with chain [Entity, Person, Employee]:
@write: Employee → Person → Entity
@read: Entity → Person → Employee
Cross-document queries
Resolver expressions can read other documents on the host via selector queries. A lookup that reads a related record and attaches its name as an attribute:
<script type="text/sessel" itemprop="@read">
let org_id = self.first().text();
let org_name = ${ [itemtype*="Org"]:has([itemprop="identifier"]) }
.filter((el) => el.microdata()["identifier"].first() == org_id)
.first()
.microdata()["name"]
.first();
self.map((el) => el.set_attr("data-org-name", org_name))
</script>
Mixed-language chaining
Pipeline chains can mix Sessel and JavaScript across the inheritance hierarchy. A parent schema may declare a Sessel @read and a child schema may declare a JavaScript @read; both fire in ancestor-first order, with the Sessel stage's output feeding the JavaScript stage's input.
Examples
Normalise email on write (Sessel)
<div itemprop="@write" itemscope itemtype="https://pagelove.org/Sessel">
<script itemprop="source" type="text/sessel">
self.map((el) => el.set_text(el.text().trim().lowercase()))
</script>
</div>
Stores "alice@example.com" even if the user submits " Alice@Example.COM ".
Trim whitespace on write (JavaScript)
<div itemprop="@write" itemscope itemtype="https://pagelove.org/JavaScript/Module">
<script itemprop="source" type="module">
export default (val) => typeof val === 'string' ? val.trim() : val;
</script>
</div>
Uppercase on read (JavaScript)
<div itemprop="@read" itemscope itemtype="https://pagelove.org/JavaScript/Module">
<script itemprop="source" type="module">
export default (val) => typeof val === 'string' ? val.toUpperCase() : val;
</script>
</div>
Generate a slug on write (Sessel)
<div itemprop="@write" itemscope itemtype="https://pagelove.org/Sessel">
<script itemprop="source" type="text/sessel">
self.map((el) => el.set_text(el.text().slugify()))
</script>
</div>
Identity resolver
A resolver that returns self unchanged. Acts as a placeholder in an inheritance chain:
<div itemprop="@read" itemscope itemtype="https://pagelove.org/Sessel">
<script itemprop="source" type="text/sessel">self</script>
</div>
Error cases
| Condition |
Result |
| Expression fails to compile |
The step is skipped. No transform runs for this property on this schema. |
| Expression returns something other than an element or list of elements |
The write or read request fails. |
| Expression throws at runtime |
The write or read request fails. |
| Expression returns an empty list |
The property is effectively removed from the output. |
Resolver errors on the write path cause the HTTP request to fail before the write reaches storage. Resolver errors on the read path cause the GET request to fail.
See also
Shape Constraint
A ShapeConstraint enforces document structure using CSS selectors — "when this element is modified, these other elements must exist in its subtree for the write to be accepted."
When to reach for it
Use a shape constraint when the rule is about the presence of elements, not the values inside them. Examples: "every blog post must contain a header element", "every form input must carry id and name attributes", "the navigation must contain a <ul> with at least one <a> link".
For rules about property values (email must match a pattern, price must be positive), use schema-level @validate on the Schema or property-level @validate on the Property.
Fields
| Field |
Cardinality |
Accepts |
Purpose |
resource |
0..n |
Path or glob |
Limits the constraint to matching resources. When omitted, the constraint is global. |
selector |
0..n |
CSS selector |
Limits the constraint to elements matching this selector. When omitted, the constraint applies to the document root. |
constraint |
0..n |
CSS selector |
Selectors that must match within the modified subtree. All must succeed. |
permit |
0..n |
CSS selector |
Marks the shape as closed. Each permit declares an element or attribute pattern that is allowed within the constrained scope. Anything not covered by some permit is rejected at write time. See Closed shapes below. |
A ShapeConstraint must declare at least one constraint or at least one permit. A shape with neither is ignored. A permit-only shape is valid: it requires nothing but closes the scope.
How shape constraints run
For each mutating request (POST, PUT, DELETE):
- All
ShapeConstraint items whose resource matches the request path (or that have no resource) are collected.
- For each matching constraint, the request target is checked against the
selector (or :root if no selector is declared).
- If the target matches, every
constraint selector is evaluated against the proposed modified DOM.
- If any
constraint selector fails to match, the request is rejected.
Shape constraints evaluate against the result of the modification, not against the current stored state. For DELETE requests, the constraint is evaluated against the ancestor element with the targeted element removed — if the result violates the constraint, the delete is refused.
Closed shapes
By default a ShapeConstraint is open: it asserts that certain elements or attributes must exist, but places no restriction on what else may exist. A closed shape also enforces the other direction — only what is declared is allowed.
Any ShapeConstraint that declares at least one permit is closed. There is no separate "closed" flag. Writes that introduce elements or attributes the shape does not permit are rejected with 422 Unprocessable Content.
What gets checked
For every descendant of the element matched by the shape's selector:
- The element must match at least one
permit. If no permit matches, the write is rejected.
- Every attribute on the element must be referenced by at least one
permit that matches the element. Attributes on elements that slip through unreferenced are rejected.
- Text nodes, comments, CDATA, and processing instructions are always allowed.
The root element matched by selector is exempt from the element check — it is the scope. Its attributes are still checked, but the coverage pool for the root includes both matching permits and the selector itself (so attributes referenced by the selector are automatically covered on the root).
Worked example
A closed shape for a note, allowing only id, title, and content:
<div itemscope itemtype="https://pagelove.org/ShapeConstraint">
<meta itemprop="resource" content="/notes/*">
<meta itemprop="selector" content="article[itemtype*=Note]">
<code itemprop="permit">[id]</code>
<code itemprop="permit">[itemprop="title"]</code>
<code itemprop="permit">[itemprop="content"]</code>
</div>
A fully-permitted note is accepted:
<article itemtype="https://schema.org/Note" id="n1">
<h2 itemprop="title">My Note</h2>
<p itemprop="content">Some text.</p>
</article>
Adding an <img> inside would be rejected — no permit matches it. Adding an onclick attribute to the <h2> would also be rejected — no permit references onclick. Adding a class attribute to the root <article> would be rejected — neither the selector nor any permit references class.
What each permit pattern covers
| Permit |
Covers |
[itemprop="title"] |
Any element with itemprop="title", plus its itemprop attribute |
.important |
Any element with class important, plus its class attribute |
#main |
Any element with id main, plus its id attribute |
[lang] |
Any element with a lang attribute, plus that attribute |
article |
Any <article> element. Does not cover any attribute. |
:has(> span) |
Any element with a direct <span> child. The inner :has() does not contribute attribute coverage. |
Attributes are covered only when a permit's selector references them by name. A tag-only permit like article allows the element but no attributes — combine it with others (article, [id], [class]) or use a compound permit (article[id][class]) when the element legitimately carries those attributes.
Composed shapes
Multiple closed shapes may apply to the same resource with nested scopes. Ownership is hierarchical:
- When a closed shape's
selector matches an element strictly inside another closed shape's scope, the inner shape owns its subtree. The outer shape's coverage check skips everything below the inner shape's root; the inner shape's permits judge that subtree instead.
- The inner shape's root element itself remains subject to the outer shape — it must be covered by one of the outer shape's permits.
- Ownership is not symmetric: an outer shape never exempts any part of an inner shape's own scope.
<!-- Outer: a closed note allowing only title and content -->
<div itemscope itemtype="https://pagelove.org/ShapeConstraint">
<meta itemprop="resource" content="/notes/*">
<meta itemprop="selector" content="article[itemtype*=Note]">
<code itemprop="permit">[id]</code>
<code itemprop="permit">[itemprop="title"]</code>
<code itemprop="permit">[itemprop="content"]</code>
</div>
<!-- Inner: inside the title, only <em> is allowed -->
<div itemscope itemtype="https://pagelove.org/ShapeConstraint">
<meta itemprop="resource" content="/notes/*">
<meta itemprop="selector" content="article[itemtype*=Note] > [itemprop='title']">
<code itemprop="permit">em</code>
</div>
With both shapes stored, <h2 itemprop="title"><em>Hi</em></h2> is accepted (the inner shape permits em) and <h2 itemprop="title"><span>Hi</span></h2> is rejected by the inner shape — even though the outer shape alone would have rejected both.
Known limitations
Closed shapes are deliberately strict. A few consequences to be aware of:
- No wildcard attribute allow. Every permitted attribute must be named explicitly by some permit. There is no "allow any attribute" pattern.
- Exclusion-only permits do not cover anything. A permit like
:not(script) selects elements but references no attributes, so it cannot contribute attribute coverage.
- Namespaces are distinct.
[lang] covers the unnamespaced lang attribute only; xml:lang must be declared separately.
- Pseudo-elements are ineffective.
::before and ::after describe rendering artefacts, not real DOM elements, and will never match stored content.
For rules about cardinality ("exactly one title"), value validation ("must be a valid hex code"), or conditional structure ("if X then Y"), use Schema — shapes handle structure, schemas handle semantics.
Shape
A ShapeConstraint is an HTML element with itemscope itemtype="https://pagelove.org/ShapeConstraint". The constraint selectors live inside it as itemprop="constraint" values.
<div itemscope itemtype="https://pagelove.org/ShapeConstraint">
<meta itemprop="selector" content="li[itemtype*=User]">
<meta itemprop="constraint" content=":has([itemprop='username'])">
<meta itemprop="constraint" content=":has([itemprop='email'])">
</div>
This constraint says: any User item that is created or modified must contain both a username and an email property element.
Examples
Required microdata properties on a User
First, store a constraint requiring both username and email on User items:
<!DOCTYPE html>
<html><body>
<div itemscope itemtype="https://pagelove.org/ShapeConstraint">
<meta itemprop="resource" content="/people/*">
<meta itemprop="selector" content="[itemtype*=User]">
<code itemprop="constraint">:has([itemprop="username"])</code>
<code itemprop="constraint">:has([itemprop="email"])</code>
</div>
</body></html>
A User with both properties is accepted:
PUT /people/complete-user.html
Content-Type: text/html
<!DOCTYPE html>
<html><body>
<div itemscope itemtype="https://example.org/User">
<span itemprop="username">alice</span>
<span itemprop="email">alice@example.com</span>
</div>
</body></html>
HTTP/1.1 201
<!DOCTYPE html>
<html><body>
<div itemscope itemtype="https://example.org/User">
<span itemprop="username">alice</span>
<span itemprop="email">alice@example.com</span>
</div>
</body></html>
A User missing the email property is rejected:
PUT /people/missing-email.html
Content-Type: text/html
<!DOCTYPE html>
<html><body>
<div itemscope itemtype="https://example.org/User">
<span itemprop="username">bob</span>
</div>
</body></html>
HTTP/1.1 422
<!DOCTYPE html>
<html>
<head>
<title>422 Unprocessable Entity - Shape Constraint Violation</title>
</head>
<body itemscope itemtype="https://pagelove.org/ConstraintViolation">
<h1 itemprop="name">Unprocessable Entity</h1>
<meta itemprop="statusCode" content="422">
<p itemprop="description">Shape constraints violated</p>
<ul itemprop="violations">
<li itemscope itemtype="https://pagelove.org/Violation">
<span itemprop="constraintSelector">[itemtype*=User]</span>
<span itemprop="failedConstraint">:has([itemprop="email"])</span>
<span itemprop="message">Element matching '[itemtype*=User]' does not satisfy constraint ':has([itemprop="email"])'</span>
</li>
</ul>
</body>
</html>
Required list structure in a navigation element
Store a constraint requiring ul with links inside nav elements:
<!DOCTYPE html>
<html><body>
<div itemscope itemtype="https://pagelove.org/ShapeConstraint">
<meta itemprop="resource" content="/site/*">
<meta itemprop="selector" content="nav">
<code itemprop="constraint">:has(ul)</code>
<code itemprop="constraint">:has(ul li a)</code>
</div>
</body></html>
A valid navigation structure is accepted:
PUT /site/valid-nav.html
Content-Type: text/html
<!DOCTYPE html>
<html><body>
<nav>
<ul>
<li><a href="/">Home</a></li>
<li><a href="/about">About</a></li>
</ul>
</nav>
<main><p>Page content.</p></main>
</body></html>
HTTP/1.1 201
<!DOCTYPE html>
<html><body>
<nav>
<ul>
<li><a href="/">Home</a></li>
<li><a href="/about">About</a></li>
</ul>
</nav>
<main><p>Page content.</p></main>
</body></html>
A navigation without the required list structure is rejected:
PUT /site/invalid-nav.html
Content-Type: text/html
<!DOCTYPE html>
<html><body>
<nav>
<p>Just a paragraph, no list.</p>
</nav>
<main><p>Page content.</p></main>
</body></html>
HTTP/1.1 422
<!DOCTYPE html>
<html>
<head>
<title>422 Unprocessable Entity - Shape Constraint Violation</title>
</head>
<body itemscope itemtype="https://pagelove.org/ConstraintViolation">
<h1 itemprop="name">Unprocessable Entity</h1>
<meta itemprop="statusCode" content="422">
<p itemprop="description">Shape constraints violated</p>
<ul itemprop="violations">
<li itemscope itemtype="https://pagelove.org/Violation">
<span itemprop="constraintSelector">nav</span>
<span itemprop="failedConstraint">:has(ul)</span>
<span itemprop="message">Element matching 'nav' does not satisfy constraint ':has(ul)'</span>
</li>
<li itemscope itemtype="https://pagelove.org/Violation">
<span itemprop="constraintSelector">nav</span>
<span itemprop="failedConstraint">:has(ul li a)</span>
<span itemprop="message">Element matching 'nav' does not satisfy constraint ':has(ul li a)'</span>
</li>
</ul>
</body>
</html>
Resource-scoped constraint
Apply a constraint only to the admin section:
<div itemscope itemtype="https://pagelove.org/ShapeConstraint">
<meta itemprop="resource" content="/admin/*">
<meta itemprop="selector" content="[itemtype*=Config]">
<meta itemprop="constraint" content=":has([itemprop='apiKey'])">
<meta itemprop="constraint" content=":has([itemprop='endpoint'])">
</div>
Global constraint
Ensure every [itemscope] element carries an itemtype:
<div itemscope itemtype="https://pagelove.org/ShapeConstraint">
<meta itemprop="constraint" content=":not([itemscope]:not([itemtype]))">
</div>
No resource or selector means the constraint applies to all resources and all elements.
CSS selector patterns
Common patterns for shape constraints:
| Selector |
What it checks |
[itemprop="name"] |
An element with the name property exists. |
:has([itemprop="email"]) |
The subtree contains an email property. |
:has(> [itemprop="title"]) |
A direct child with title exists. |
:has(ul li a) |
The subtree contains a list with links. |
[required] |
The element has a required attribute. |
:not(:empty) |
The element is not empty. |
Error cases
| Condition |
Result |
POST or PUT violates a constraint |
422 Unprocessable Content. |
DELETE would leave the document in a state that violates a constraint |
409 Conflict. |
Constraint with no constraint selectors |
Silently skipped. |
resource does not match the request path |
Constraint does not fire. |
No partial modification is applied on failure.
See also
- Schema — schema-level
@validate for value-based cross-property rules
- Property — property-level
@validate for value checks on individual fields
- Composing pages — the composition mechanisms that shape constraints can guard