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):

  1. All ShapeConstraint items whose resource matches the request path (or that have no resource) are collected.
  2. For each matching constraint, the request target is checked against the selector (or :root if no selector is declared).
  3. If the target matches, every constraint selector is evaluated against the proposed modified DOM.
  4. 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 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:

<!-- 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:

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=&quot;email&quot;])</span>
        <span itemprop="message">Element matching &#x27;[itemtype*=User]&#x27; does not satisfy constraint &#x27;:has([itemprop=&quot;email&quot;])&#x27;</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 &#x27;nav&#x27; does not satisfy constraint &#x27;:has(ul)&#x27;</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 &#x27;nav&#x27; does not satisfy constraint &#x27;:has(ul li a)&#x27;</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