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
ShapeConstraintitems whoseresourcematches the request path (or that have noresource) are collected. - For each matching constraint, the request target is checked against the
selector(or:rootif no selector is declared). - If the target matches, every
constraintselector is evaluated against the proposed modified DOM. - If any
constraintselector 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
permitthat 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
selectormatches 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 unnamespacedlangattribute only;xml:langmust be declared separately. - Pseudo-elements are ineffective.
::beforeand::afterdescribe 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
@validatefor value-based cross-property rules - Property — property-level
@validatefor value checks on individual fields - Composing pages — the composition mechanisms that shape constraints can guard