Sessel

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

Sessel

Sessel

Sessel is a CSS expression language for querying, transforming, and constructing HTML. It extends CSS selectors with methods, variables, and element construction, so you can count elements, aggregate values, filter collections, and build new HTML fragments -- all using the selector syntax you already know.

Sessel expressions appear throughout Pagelove: in constraints that validate documents before they are saved, in expression bindings that compute values at render time, in triggers that fire before request processing, and in processors that shape responses afterward.

Learn Sessel

The four learn guides form a progressive sequence. Each builds on the one before it, starting from CSS selectors and ending with full expression composition.

  1. Querying Elements -- selectors, data extraction, the from clause, and the self and prior context variables.
  2. Working with Collections -- counting, filtering, mapping, aggregation, sorting, and null handling.
  3. Constructing HTML -- building elements with new, CSS shorthand for classes and IDs, attributes, children, and setter methods.
  4. Variables and Composition -- let bindings, if expressions, dictionaries, @namespace declarations, and putting it all together.

Reference

The reference section documents every type, method, and syntactic construct in the language.

Why Sessel?

For the motivation behind a CSS-based expression language -- and how it relates to JavaScript and schema-defined types -- see the Concept pages:

Querying Elements

Querying Elements

Sessel is built on CSS selectors. If you know CSS, you already know most of what you need. This guide covers how to use selectors to find elements, extract data from them, and control which documents are searched.

What is a selector?

The core building block of Sessel is the selector literal: any CSS selector wrapped in ${}.

${div.item}
${h1}
${[itemprop="price"]}
${#main .content p}

Any valid CSS selector works inside ${}. Sessel also adds four content-aware pseudo-classes — :contains(), :equals(), :greater-than(), and :less-than() — but standard CSS selectors are what you'll use most of the time.

A selector always returns a list, even when only one element matches. The list may be empty if nothing matches. You cannot use a selector result directly as a single value — you always need to pull an element out of the list first.

Extracting data from elements

Once you have a list, .first() gives you the first element. From there, three methods let you read its content:

The pattern is always: selector → .first() → data method.

${h1}.first().text()

Get the text of the first h1.

${[itemprop="price"]}.first().value()

Get the microdata value of the first element with itemprop="price". For a <data> or <meter> element this is the value attribute; for a plain <span> it is the text content.

${a.nav}.first().attr("href")

Get the href attribute of the first .nav anchor.

If the list is empty, .first() returns null, and calling .text(), .value(), or .attr() on null also returns null. The ?? null-coalescing operator is useful here:

${h1}.first().text() ?? "Untitled"

Expressions inside selectors

Selector literals can embed Sessel expressions in place of CSS literal values. Inside ${...}, any unquoted attribute value or pseudo-class argument is treated as a Sessel expression:

let status = "active";
${[itemprop="status"]:equals(status)} from self

Here status in the attribute selector and in :equals() both refer to the let-bound variable. Quoted values remain plain CSS literals as usual:

${[itemprop="status"]:equals("active")} from self

This works with arbitrary expressions, including method chains and sub-selects:

let category = ${[itemprop="category"]} from "/config";
${div[data-cat=category.first().text()]} from self

The Selector type

If you need to work with a selector as data — storing it in a variable, passing it to a function, or building it dynamically — use the Selector type:

let sel = new Selector { "h1" };
sel.execute()                    // same as ${h1} from self
sel.execute("/about.html")       // same as ${h1} from "/about.html"
sel.execute(["/a/*", "/b/*"])    // same as ${h1} from "/a/*", "/b/*"

A Selector is inert until you call .execute(). You can also retrieve the underlying CSS string with .toString():

let sel = new Selector { "[itemprop=\"price\"]" };
sel.toString()   // "[itemprop=\"price\"]"

This is useful when the selector is built dynamically or passed in as a variable.

The from clause

By default, a selector queries across the entire site — every document in the store. The from clause narrows that scope.

${selector} from scope

The most common scopes:

from value What it searches
from self The current document only
from "/products/shoes" One specific document
from "/products/*" All documents matching a glob pattern
from expr Documents at the path(s) the expression resolves to
${div.item} from self

Search only the current document. This is the most common form — you'll write from self far more often than a global query.

${[itemprop="price"]} from "/products/shoes"

Search a specific document by path.

${[itemprop="price"]} from "/products/*"

Search all documents whose path matches the glob. This runs across multiple documents and returns a combined list.

${[itemprop="related"]} from ${a.nav}.first().attr("href")

Use another expression to determine the target path dynamically.

Block-level from

When several selectors need the same scope, a block-level from avoids repetition:

from self {
  ${h1}.first().text() == "Welcome" &&
  ${p}.count() > 0
}

Every bare selector inside the braces inherits the scope. A selector with its own from clause overrides the block scope. See Syntax for full details.

self and prior

Two context variables are always available: self and prior.

self resolves to the root element of the current document. You have already seen it used in from self. It is also a valid expression on its own — it evaluates to the document's root element.

prior resolves to the pre-mutation state of the document — what it looked like before the current write operation was applied. If the document is being created for the first time, prior is null. Always check before using it:

prior != null

A common pattern is to read a field from the previous version of the document:

(${[itemprop="status"]} from prior).first().value()

This is particularly useful in mutation handlers and constraints that need to compare old and new values.

Element provenance

Every element returned by a selector knows where it came from. Three methods let you inspect this:

These are especially useful when querying across multiple documents with a glob from clause, so you can tell which document each result came from. See the Element reference for full details.

Typed search with Class objects

When your data is governed by schemas, there is a higher-level alternative to raw CSS selectors: the .search() method on a Class object. Instead of hand-crafting an [itemtype] selector, you ask the schema class to find its instances:

<script type="text/sessel">
  @schema UserConfig url("https://example.com/UserConfig");

  // Find all UserConfig instances across the entire store
  UserConfig.search()

  // Find only those with org-id "abc"
  UserConfig.search({ "org-id": "abc" })
</script>

.search() returns a list of Instance values — the same typed objects you get from new UserConfig { ... }. Each instance supports property access (.theme, .org-id), method calls, and isa checks.

The criteria argument is a dictionary of property-name/value pairs. All criteria must match (logical AND). When no criteria are provided, every instance of the type is returned.

OR criteria

When you need to match instances that satisfy any of several conditions, pass a list of dictionaries instead of a single dictionary:

<script type="text/sessel">
  @schema HostConfig url("https://pagelove.org/HostConfig");

  // Find hosts where hostname OR alias matches "foo.com"
  HostConfig.search([{ hostname: "foo.com" }, { alias: "foo.com" }])
</script>

Each dictionary in the list is an independent AND filter. An instance appears in the results if it matches any of the dictionaries. A single-element list behaves identically to passing the dictionary directly. An empty list matches all instances.

Polymorphic search with isa

When a schema has descendant types (schemas that declare it as their parent), you can search for instances of the base type and all its descendants in a single query by passing { "isa": true } as the second argument:

<script type="text/sessel">
  @schema HostConfig url("https://pagelove.org/HostConfig");

  // Strict: only HostConfig instances
  HostConfig.search({})

  // Polymorphic: HostConfig AND all descendant types
  HostConfig.search({}, { "isa": true })

  // OR criteria + polymorphic
  HostConfig.search([{ hostname: "foo.com" }, { alias: "foo.com" }], { "isa": true })
</script>

Each result carries its actual schema type, so a list returned by a polymorphic search may contain instances of different types. This is useful when you have a base type and want to query across the entire type hierarchy without knowing which subtypes exist.

Note that "isa" must be quoted — isa is a reserved keyword in Sessel.

This is the recommended approach when you are working with schema-typed data. It is more readable than building [itemtype="..."] selectors by hand, and the results come back as typed Instances rather than raw Elements.

If you need polymorphic type matching in a raw CSS selector (e.g. in an HTTP Range header, a resource binding, or a ${} selector literal), use the :isa() pseudo-class:

<script type="text/sessel">
  // CSS-level polymorphic matching — equivalent to the search above
  ${:isa('https://pagelove.org/HostConfig')}
</script>

See Selector Extensions for full :isa() reference.

Next steps

You now know how to find elements, read their content, scope queries to specific documents, and use prior to look at the previous state of a document.

The next guide covers what you can do with a list of elements — counting, filtering, mapping, and aggregating:

For detailed reference material:

Types

Types

Sessel has eleven user-facing types plus a family of Temporal types for date and time. All values belong to exactly one type at any point in time.

Type summary

Type Literal syntax Description
Number 42, 3.14, -7 Integers and decimals
String "hello", "" Text values
Boolean true, false Logical values
Null null Absence of a value
List [1, 2, 3], [] Ordered collections
Dictionary { name: "Widget" }, {} Key-value pairs
Element ${h1}.first(), new p { text: "Hello" } An HTML element
Temporal Temporal.PlainDate.from("2026-03-23") Date and time values
Instance new Project { slug: "launch" } A schema-backed instance with typed properties
Class @schema Project url(...) A schema class object with search and construct
Selector new Selector { "h1.title" } A CSS selector value that can execute scans

Types in detail

Number

Integers (42, -7) and decimals (3.14) are both Numbers. Sessel exposes them as a single type to expressions, though the underlying representation tracks integer vs. float precision. Use .Integer() to force integer parsing and .Float() to force decimal parsing.

See Number for details.

String

Text values written with double quotes: "hello", "Untitled", "". Strings support length checks, substring matching, prefix/suffix tests, and regex matching via methods.

See String for details.

Boolean

One of exactly two values: true or false. Produced by comparison and logical operators, and by methods such as .all(), .any(), .contains(), .startsWith(), .endsWith(), and .matches().

Null

The value null represents the absence of a result. Null arises when an operation cannot produce a value — for example, calling .first() on an empty list, or calling .Number() on text that is not a valid number. Null propagates through method chains until a ?? fallback provides a replacement.

List

An ordered collection of zero or more values, written with square brackets: [1, 2, 3], ["a", "b"], []. Lists can contain any type, including nested lists and dictionaries.

CSS selector expressions (${selector}) always return a List, even when only one element matches. Use .first(), .last(), or .at(n) to extract a single element.

See List for details.

Dictionary

A set of key-value pairs, written with curly braces: { name: "Widget", price: 42 }. Keys are always strings. Values can be any type. Keys can be bare identifiers or quoted strings — use quotes when the key contains hyphens or spaces.

See Dictionary for details.

Element

An HTML element. Elements come from two sources:

Both variants support the same element methods (.text(), .value(), .attr(name)).

See Element for details.

Temporal

Date and time values constructed via the Temporal.* namespace. Sessel provides eight Temporal types that mirror JavaScript's Temporal API:

Type Construction
Temporal.PlainDate Temporal.PlainDate.from("2026-03-23")
Temporal.PlainTime Temporal.PlainTime.from("14:30:00")
Temporal.PlainDateTime Temporal.PlainDateTime.from("2026-03-23T14:30:00")
Temporal.Instant Temporal.Instant.from("2026-03-23T14:30:00Z")
Temporal.ZonedDateTime Temporal.ZonedDateTime.from("2026-03-23T14:30:00[Europe/London]")
Temporal.PlainYearMonth Temporal.PlainYearMonth.from("2026-03")
Temporal.PlainMonthDay Temporal.PlainMonthDay.from("--03-23")
Temporal.Duration Temporal.Duration.from("P1Y2M3DT4H5M6S")

All Temporal values are truthy. String(date) returns the ISO 8601 representation. Use isa Temporal.PlainDate (or isa Temporal for any Temporal type) for type checking.

See Temporal for the full reference.

Instance

A schema-backed object with typed properties and methods. Instances are created by constructing a schema type with new:

@schema Project url("https://example.com/Project");

let p = new Project { slug: "launch", name: "Product Launch" };
p.name        // "Product Launch"
p isa Project // true

Instances are microdata elements under the hood — they carry an itemtype and itemprop children — but property access goes through the schema's type system, including @read resolvers, type validation, and cardinality rules. You can also write properties (p.status = "archived") and call schema-defined methods (p.escalate(2)).

Because an instance is an element, Element methods (.attr(), .children(), .parent(), .text(), and the rest) work on it directly. An instance's body can also include positional element children alongside its properties; those, and the elements generated for scalar properties, all become element children of the instance and can walk back up to it via .parent():

@schema Foo url("https://example.com/Foo");

let f = new Foo { new span { } };
f.children().first().parent().attr("itemtype")   // "https://example.com/Foo" — the child reaches the instance

(new Foo { count: 5 }).children().first().parent().attr("itemtype")   // "https://example.com/Foo" — a scalar property's child reaches it too

Class

A class object represents a schema type as a first-class value. Class values are produced by @schema declarations:

@schema UserConfig url("https://example.com/UserConfig");

UserConfig                // the class object itself
UserConfig isa Class      // true

Class objects expose three capabilities:

.search(criteria?, options?)

Returns a List of Instance values matching the given criteria.

criteria — optional. Controls which instances are returned.

Form Meaning
omitted or null All instances of this type.
Map AND filter — every key/value pair must match.
List<Map> OR filter — instances matching any of the Maps are returned.

Each Map entry becomes a :has([itemprop="key"]:value-equals("value")) CSS predicate. Values may be strings, integers, floats, or booleans.

options — optional Map. Recognized keys:

Key Type Default Meaning
"isa" Bool false When true, includes instances of the target type and every schema with the target in its parent chain (reflexive polymorphic search).

Unknown keys raise a TypeError. "isa" must be quoted because isa is a reserved keyword in Sessel.

Return value: List<Instance>. When "isa" is true, the list is heterogeneous — each instance carries its actual $schema, not the queried type.

CSS equivalent: For polymorphic type matching in raw CSS selectors (HTTP Range headers, resource bindings, ${} literals), use the :isa() pseudo-class instead: :isa('https://example.com/UserConfig').

@schema UserConfig url("https://example.com/UserConfig");

// All instances
UserConfig.search()

// AND filter: org-id must be "abc"
UserConfig.search({ "org-id": "abc" })

// OR filter: match hostname OR alias
UserConfig.search([{ hostname: "foo.com" }, { alias: "foo.com" }])

// Polymorphic: UserConfig + all descendant types
UserConfig.search({}, { "isa": true })

// OR + polymorphic
UserConfig.search([{ hostname: "x" }, { alias: "x" }], { "isa": true })

// Construct an instance from a variable class reference
let cls = UserConfig;
cls.construct({ "org-id": "abc", theme: "dark" })

Selector

A CSS selector wrapped in a value. Unlike the ${...} selector literal (which executes immediately), a Selector is inert until you call .execute().

let sel = new Selector { "h1.title" };
sel.toString()              // "h1.title"
sel.execute()               // same as ${h1.title} from self
sel.execute("/about.html")  // same as ${h1.title} from "/about.html"

The Selector type is useful when you need to build or pass selectors as data — for example, storing a selector in a variable and executing it later against different documents. The .execute() method accepts an optional path argument (string or list of strings) that works like the from clause.

Document

A Document is a special Element returned by Pagelove.GET() for HTML resources. It extends Element with metadata from storage.

Method Description
.metadata() Storage metadata as Dictionary (mimetype, etag, created, modified, size, version)
All Element methods Inherited

Static method: Document.parse(htmlString) parses an HTML string into a mutable ConstructedElement tree. The result can be inspected, modified, and used in composition like any other constructed element.

let el = Document.parse("<div class='card'><p>Hello</p></div>");
el.${p}.first()?.text()    // "Hello"
el.attr("class")            // "card"

Document.parse() is the controlled entry point for HTML strings from external sources.

Blob

Returned by Pagelove.GET() for non-HTML resources (images, scripts, etc.). Not an Element — has no DOM tree.

Method Description
.metadata() Storage metadata as Dictionary
.path() Resource path

Reflection API

Reflection capabilities are accessed through the Sessel schema, explicitly imported:

@schema Sessel url("https://pagelove.org/Sessel");

Sessel.stored(self, "name")      // raw itemprop read, bypasses dispatch
Sessel.properties(self)           // list all itemprop names
Sessel.schemaOf(self)            // schema URL string
Method Description
Sessel.stored(element, propName) Reads the raw itemprop value, bypassing the dispatch chain (no schema methods, no resolvers, no computed properties). Returns null if no itemprop exists.
Sessel.properties(element) Returns a List of all itemprop names on the element.
Sessel.schemaOf(element) Returns the schema URL string (itemtype attribute) or null.

Reflection is deliberately not built into the language surface. The explicit @schema import signals that meta-level operations are being performed.

Truthiness

Conditions in if expressions, the ternary operator (? :), and logical operators (&&, ||) test for truthiness. Only these values are falsy:

Value Type
false Boolean
null Null
0 Number (integer zero)
0.0 Number (float zero)
"" String (empty)
[] List (empty)
{} Dictionary (empty)

Everything else is truthy, including non-zero numbers, non-empty strings, non-empty lists, non-empty dictionaries, all elements, all Temporal values, all Instances, all Class objects, and all Selectors.

Type coercion

Methods that convert between types return null if the conversion fails. They never throw.

Method Input Output Notes
.Number() String Number or null Smart parse: "42" returns integer, "3.14" returns float
.Integer() String, Float Integer or null Truncates floats toward zero; fails if string has a decimal part
.Float() String, Integer Float or null Always returns a float, even for "42"
.String() Any String Always succeeds
.Bool() Any Boolean Converts using truthiness rules
${[itemprop="price"]}.first().value().Number()   // "3.99" → 3.99 (float), "42" → 42 (integer)
${[itemprop="count"]}.first().value().Integer()  // "5" → 5, "5.0" → null
${[itemprop="rating"]}.first().value().Float()   // "42" → 42.0 (always float)
${div.item}.count().String()                   // 4 → "4"
0.Bool()                                       // false
"hello".Bool()                                 // true

Null propagates through coercion — if the input to .Number() is already null, the output is also null. Use ?? to provide a fallback:

${#count}.first().value().Number() ?? 0

Type Namespaces

The capitalized type names Number, Integer, Float, String, Bool, Null, Element, List, and Map are first-class values in Sessel. They provide access to static methods.

Static methods

Type namespaces expose static methods via dot notation:

Integer.random(1, 100)       // random integer in [1, 100]
Float.random(0.0, 1.0)       // random float in [0.0, 1.0)
Number.random(1, 10)          // infers Integer or Float from argument types

See Number for details on random number generation.

Name resolution

Type namespace names are resolved after variables and lambda bindings. A let binding shadows the type name:

let String = "hello";
String                         // "hello" (the variable, not the type namespace)

Working with Collections

Working with Collections

CSS selector expressions always return lists — even when only one element matches, the result is a list. This guide covers everything you need to work with those lists: counting, accessing specific positions, filtering, transforming, checking conditions, aggregating numbers, and validating list properties.

Counting

The simplest thing you can do with a list is count it. .count() returns the number of elements the selector matched.

${div.item}.count()

Counting becomes useful as soon as you compare it to something:

${div.item}.count() > 3

That expression is true when more than three .item elements exist. It is false when there are three or fewer. You can also require an exact number:

${h1}.count() == 1

This is true only when the page has exactly one <h1> — a common requirement for well-structured documents.

Accessing elements

.count() tells you how many elements exist, but sometimes you want a specific one. Sessel provides four access methods:

${h1}.first()
${p}.last()
${li}.at(2)
${li}.slice(0, 5)

.first() is the most common: you use it to extract the single element you actually want to read text or attribute values from.

${h1}.first().text()
${[itemprop="price"]}.first().value()
${a.external}.first().attr("href")

.at(n) gives you arbitrary positional access. The first element is .at(0), the second is .at(1), and so on.

.slice(start, end) is useful when you want to work with a page of results — for example, the first five items in a list, or items 10 through 20.

Filtering

.filter(el => predicate) returns a new list containing only the elements for which the predicate is true. You pick a variable name, then write a condition using it.

${li}.filter(el => el.text().Integer() > 10)

This keeps only the <li> elements whose text content, parsed as an integer, is greater than 10. Every element in the original list is tested; those that pass appear in the result.

${[itemprop="price"]}.filter(el => el.value().Number() > 50)

Here the variable is named el, but you can choose any name — what matters is that you use the same name inside the predicate:

${[itemprop="rating"]}.filter(r => r.value().Number() >= 4)
${div.card}.filter(card => card.attr("data-featured") == "true")

The variable refers to the element being tested. From there, .text(), .value(), and .attr() extract whatever you need to compare.

The callback can also receive the element's index and the source list as additional parameters:

${li}.filter((el, i) => i < 5)

This keeps only the first five elements. The second parameter i is the zero-based index.

Filtering is composable. You can filter a list, then count the result:

${li}.filter(el => el.text().Integer() > 10).count()

Or filter and then access the first match:

${[itemprop="price"]}.filter(el => el.value().Number() > 50).first()

Transforming

.map(el => expression) builds a new list by evaluating the expression once for each element. The result list has the same length as the input, but each position holds the computed value instead of the original element.

Mapping to values:

${[itemprop="item"]}.map(e => e.value())

Mapping to numbers:

${li}.map(el => el.text().Integer())

Like filter(), map() can accept an index parameter:

${li}.map((el, i) => i.String() + ". " + el.text())

Mapping to dictionaries lets you assemble structured data from a collection of elements:

${[itemprop="product"]}.map(p => { name: p.text(), price: p.attr("data-price") })

Each <[itemprop="product"]> element becomes a dictionary with a name key (from its text content) and a price key (from its data-price attribute). The result is a list of dictionaries — one per matching element.

You can combine .filter() and .map() to select a subset and then extract specific values from it:

${[itemprop="price"]}.filter(el => el.value().Number() > 50).map(el => el.value())

Checking conditions

Sometimes you do not want to see the elements themselves — you want a yes/no answer about them as a group. Sessel provides two methods for that:

${div.item}.all(el => el.text().count() > 0)

This is true only when every .item element has non-empty text. Use .all() when you need a guarantee that holds across the entire collection — for example, that every required field has been filled in.

${[itemprop="price"]}.any(el => el.value().Number() > 100)

This is true as soon as at least one price exceeds 100. Use .any() when you want to know whether a condition is possible rather than universal — for example, whether a page contains any featured items.

Aggregation

For numeric data, Sessel can compute aggregates directly:

${[itemprop="price"]}.sum()
${[itemprop="rating"]}.min()
${[itemprop="rating"]}.max()

These methods read the microdata value of each element, so they work naturally with <data> and <meter> elements that carry their value in the value attribute.

Computing an average combines .sum() and .count():

${[itemprop="price"]}.sum() / ${[itemprop="price"]}.count()

You can also aggregate across multiple documents using the from clause. This sums prices from every page under /products/:

(${[itemprop="price"]} from "/products/*").sum()

Collection validation

Beyond counting and aggregating, Sessel can answer structural questions about a list's values:

.unique() removes duplicate entries:

[1, 2, 2, 3].unique()
// [1, 2, 3]
${[itemprop="category"]}.unique()

This returns a new list with duplicates removed — useful for deduplicating tags, identifiers, or any collection where repeated values are unwanted.

.subset() checks that a list's values are all drawn from an allowed set. The from clause lets the allowed set come from a different document:

(${[itemprop="tag"]} from self).subset(${[itemprop="allowed-tag"]} from "/config/tags")

Every tag on the current page must appear in the allowed-tags configuration document. If any tag is absent from that list, the expression is false.

.disjoint() checks for the absence of overlap — useful when two sets of values must remain separate:

(${[itemprop="tag"]} from self).disjoint(${[itemprop="tag"]} from "/published/*")

Null handling

.first(), .last(), and .at(n) all return null when the list is empty or the position is out of range. Null propagates through subsequent method calls, so a chain that starts from a null element does not crash — it simply produces null at each step.

When you need a fallback, the ?? (null-coalescing) operator provides one:

${#subtitle}.first().text() ?? "Untitled"

If the #subtitle element does not exist, .first() returns null, .text() returns null, and ?? substitutes "Untitled". The fallback can be any expression.

For conditional logic that goes beyond a simple fallback, use the ternary operator for simple two-way choices:

${div.item}.count() > 0 ? "has items" : "empty"

Ternary and ?? can be combined. The ternary controls which branch to evaluate; ?? handles null within a branch:

${div.item}.count() > 0
    ? ${div.item}.first().text() ?? "unnamed item"
    : "no items"

When you need multiple branches or multi-statement logic, use if expressions instead:

let count = ${div.item}.count();
if (count > 10) {
  "many items"
} else if (count > 0) {
  ${div.item}.first().text() ?? "unnamed item"
} else {
  "no items"
}

if is an expression — it returns the value of the taken branch, so it composes naturally with let, construction, and method chains.

Sorting, reversing, and flattening

Lists can be sorted by a computed key, reversed, and flattened from nested structures into a single list:

${[itemprop="product"]}.sort(el => el.attr("data-price").Number())
${li}.reverse()
${section}.map(s => ${li} from s).flatten()

For more complex sort orders, pass a two-parameter comparator lambda with the spaceship operator <=>:

${li}.sort((a, b) => a.text() <=> b.text())

And list values can be joined into a single string:

${[itemprop="tag"]}.map(el => el.text()).join(", ")

Reducing

.reduce(initial, callback) collapses a list into a single value by applying a two-parameter callback to an accumulator and each element:

[1, 2, 3, 4].reduce(0, (acc, el) => acc + el)

This produces 10 -- the sum of all elements. .reduceRight() works the same way but processes elements from right to left.

These methods and more are covered in full in the List reference.

Next steps

Number

Number

Numbers in Sessel represent numeric values — integers and decimals. Both types interoperate seamlessly in expressions; you rarely need to distinguish between them.

Literal syntax

Form Example Description
Integer 42, -7, 0 Whole numbers, positive or negative
Decimal 3.14, -0.5 Numbers with a fractional part
42
3.14
-7

Arithmetic operators

Operator Meaning Example Result
+ Addition 10 + 3 13
- Subtraction 10 - 3 7
* Multiplication 10 * 3 30
/ Division 10 / 4 2
${[itemprop="price"]}.sum() / ${[itemprop="price"]}.count()
(${[itemprop="subtotal"]}.sum() + 5) * 1.1

Integer division of two integers returns an integer (truncating toward zero). When either operand is a float, the result is a float: 10 / 4 produces 2, but 10.0 / 4 produces 2.5.

Comparison operators

Numbers support all six comparison operators plus the spaceship operator. Comparison results are booleans; the spaceship result is an integer.

Operator Meaning
== Equal to
!= Not equal to
> Greater than
< Less than
>= Greater than or equal to
<= Less than or equal to
<=> Spaceship (three-way compare): returns -1, 0, or 1
${div.item}.count() > 3
${[itemprop="price"]}.sum() >= 100
${[itemprop="stock"]}.first().value().Integer() == 0

The spaceship operator <=> is primarily useful in sort comparator lambdas:

${li}.sort((a, b) => a.text().Number() <=> b.text().Number())

Methods

.String()

Converts a number to its string representation.

${div.item}.count().String()

Returns: string

Useful when a template or string method requires a string rather than a number.

.abs()

Returns the absolute value of a number.

(-5).abs()      // 5
5.abs()         // 5
(-3.14).abs()   // 3.14

Returns: the same numeric type (integer stays integer, float stays float).

.floor()

Rounds a number down toward negative infinity. Integer values are returned unchanged. Float values are rounded down and returned as integers.

3.7.floor()      // 3
(-3.2).floor()   // -4
42.floor()       // 42

Returns: integer

.ceil()

Rounds a number up toward positive infinity. Integer values are returned unchanged. Float values are rounded up and returned as integers.

3.2.ceil()       // 4
(-3.7).ceil()    // -3
42.ceil()        // 42

Returns: integer

.round()

Rounds a number to the nearest integer, with ties rounding away from zero. Integer values are returned unchanged.

3.5.round()      // 4
3.4.round()      // 3
(-3.5).round()   // -4
42.round()       // 42

Returns: integer

How numbers arise

Numbers appear in Sessel expressions from several sources:

Literals

42 + 8

Coercion from strings — coercion methods that parse numeric content:

Method Parses as Returns
.Number() Smart parse: integer for "42", float for "3.14" number or null
.Integer() Whole number only integer (throws if unparseable)
.Float() Always decimal float (throws if unparseable)
${[itemprop="price"]}.first().value().Number()
${[itemprop="quantity"]}.first().value().Integer()
${[itemprop="rating"]}.first().value().Float()

Only .Number() returns null if the string cannot be parsed — .Integer() and .Float() throw a type error instead. Use ?? to provide a fallback on .Number():

${[itemprop="price"]}.first().value().Number() ?? 0

List aggregation methods — these operate on a list of elements and return a number:

Method Returns
.count() Number of elements in the list (always integer)
.sum() Sum of all element values parsed as numbers
.min() Smallest numeric value in the list
.max() Largest numeric value in the list

.sum(), .min(), and .max() preserve the integer type when all inputs are integers. If any input is a float, the result is a float.

String length.count() on a string returns the number of characters:

${h1}.first().text().count()

Number coercion

Coercion methods convert between number subtypes:

Method On String On Integer On Float
.Number() Smart parse: "42" → integer, "3.14" → float Returns unchanged Returns unchanged
.Integer() Parses whole number; fails on decimals Returns unchanged Truncates toward zero: 3.143
.Float() Parses as float: "42"42.0 Widens: 4242.0 Returns unchanged
"42".Number()       // 42 (integer)
"3.14".Number()     // 3.14 (float)
3.14.Integer()      // 3
42.Float()          // 42.0

Random number generation

Type namespaces provide static random() methods for generating random numbers.

Integer.random(min, max)

Returns a random integer in the inclusive range [min, max]. Both arguments must be integers.

Integer.random(1, 6)          // a random die roll: 1, 2, 3, 4, 5, or 6
Integer.random(0, 100)        // a random integer from 0 to 100 inclusive

Float.random(min, max)

Returns a random float in the half-open range [min, max). Both arguments must be numeric.

Float.random(0.0, 1.0)        // a random float in [0.0, 1.0)
Float.random(1, 10)           // integer args are promoted to float

Number.random(min, max)

Infers the return type from argument types. If both arguments are integers, returns an integer. If either is a float, returns a float.

Number.random(1, 10)          // integer result (both args are integer)
Number.random(1.0, 10.0)      // float result (both args are float)
Number.random(1, 10.0)        // float result (mixed args)

Range validation: if min > max, the bounds are silently swapped. If min == max, min is returned directly.

See also

Constructing HTML

Constructing HTML

The guides so far have focused on reading from the document store — selecting elements, extracting values, filtering and aggregating lists. This guide covers the other direction: building new HTML elements from within an expression.

The new keyword

The new keyword followed by a tag name and a pair of braces creates an element. Inside the braces you describe the element's content.

new p { text: "Hello, World" }
new h1 { text: "My Title" }
new div {}

The first example produces <p>Hello, World</p>. The second produces <h1>My Title</h1>. The third produces <div></div> — an empty element with no children.

The special text: property inside the braces sets the element's text content. The quotes are required for a literal string.

ID and classes

The construction syntax borrows CSS shorthand notation to describe the element's shape. An #id sets the element's ID. A .class adds a class. You can combine them freely:

new p.intro#main { text: "Welcome" }

produces:

<p class="intro" id="main">Welcome</p>

Multiple classes:

new div.card.featured {}

produces <div class="card featured"></div>.

Attributes

Attributes are specified in square brackets, just like CSS attribute selectors. A name-value pair produces a valued attribute. A bare name produces a boolean attribute — one that is either present or absent with no value.

new input[type="text"][name="email"][required] {}

produces <input type="text" name="email" required>.

Namespaced attributes

Namespaced attributes use a prefix|name syntax:

new div[p|transient][p|ttl="3600"] {}

produces <div p:transient p:ttl="3600"></div>.

Dynamic attribute values

When an attribute value is a Sessel expression rather than a quoted literal, the expression is evaluated at runtime:

new a[href=${[itemprop="url"]}.first().value()] { text: "Read more" }

The href is set to the result of the expression ${[itemprop="url"]}.first().value() — whatever URL is in the document's microdata. Quoted values are always treated as literals; unquoted values are always treated as expressions.

Children

For elements with multiple children, list them inside the braces separated by commas:

new article.post {
  new h1 { text: "My Post" },
  new p.body { text: "Content here." },
  new footer { new small { text: "Posted today" } }
}

This produces:

<article class="post">
  <h1>My Post</h1>
  <p class="body">Content here.</p>
  <footer><small>Posted today</small></footer>
</article>

When an element has only text content, use text:. When it has child elements, list them directly. You can mix both — the children are appended in order.

Null skipping and conditional children

Null children are silently skipped — they produce no output. This enables conditional children using the ternary operator or if expressions:

new ul {
  showDrafts ? new li { text: "Drafts" } : null,
  new li { text: "Published" }
}

When showDrafts is false, the first child evaluates to null and is skipped, producing <ul><li>Published</li></ul>.

if without an else returns null when the condition is falsy, so it works the same way:

new ul {
  if (showDrafts) { new li { text: "Drafts" } },
  new li { text: "Published" }
}

For multi-branch conditional children, if is clearer than nested ternaries:

new div {
  if (role == "admin") {
    new span.badge { text: "Admin" }
  } else if (role == "editor") {
    new span.badge { text: "Editor" }
  }
}

List flattening

List children are flattened one level, so map() can produce sibling children naturally:

new ul {
  ${[itemprop="category"]}.map(el => new li { text: el.text() })
}

Each element returned by map() becomes a direct child of the <ul>. For deeper nesting, use the flatten() method explicitly.

Void elements

HTML void elements — br, hr, img, input, meta, link, and others — are serialized without a closing tag:

new br {}
new img[src="/photo.jpg"][alt="Photo"] {}

produces <br> and <img src="/photo.jpg" alt="Photo"> respectively. The braces are still required even though these elements cannot have children.

Mixing queried and constructed elements

Queried elements — those returned from the document store via a selector — can be used as children alongside constructed elements. They are embedded verbatim, preserving their tag, attributes, and all descendant content.

new div.wrapper { ${h1}.first() }

This wraps whatever <h1> is in the document inside a new <div class="wrapper">.

You can mix queried and constructed children freely:

new div {
  ${h1}.first(),
  new p { text: "Added content" }
}

The first child is the queried <h1> as it exists in the store; the second child is a newly constructed <p>.

Method chaining on constructed elements

Setter methods let you set content after construction. They follow an arity convention — the same method name reads with no arguments and writes with arguments. Each setter returns the modified element, so calls chain naturally.

new img {}.value("/images/banner.jpg").attr("alt", "Banner")

The three setter methods:

These are most useful when you need to set a value that depends on a complex expression:

new h1 {}.text(${[itemprop="name"]}.first().value() ?? "Untitled")

A complete example

Building a product card from queried microdata:

new div.card {
  new h2 { text: ${[itemprop="name"]}.first().text() },
  new span.price { text: ${[itemprop="price"]}.first().value() },
  new a[href=${[itemprop="url"]}.first().value()] { text: "View product" }
}

The three children each pull a value from the document's microdata:

The result is a complete card element whose content is drawn entirely from the document store.

Next steps

This guide covered element construction — the new keyword, tag shorthand, attributes, children in braces, text:, null skipping, list flattening, and setter methods.

The next guide introduces named values and how to compose large expressions from smaller, reusable parts:

For detailed reference material:

String

String

A string is a sequence of characters. Strings appear as literal values, as results of element methods like .text(), .value(), and .attr(), and as inputs to string methods.

Literal syntax

String literals are written with double or single quotes:

"hello"
'hello'
"Untitled"
'It\'s fine'

Both quote styles are equivalent. Use single quotes when the string contains double quotes, or escape the delimiter with a backslash.

String interpolation

Strings support interpolation with #{}. Expressions inside #{} are evaluated and coerced to String:

let name = "world";
"hello #{name}"                    // "hello world"
"total: #{items.count()}"          // "total: 3"
"#{price * quantity} GBP"          // "150 GBP"

#{} is used instead of ${} to avoid ambiguity with CSS selectors. If the expression is null, the result is the empty string. To include a literal #{, escape the #: "\#{not interpolated}".

Concatenation

The + operator concatenates strings:

"hello" + " " + "world"
${h1}.first().text() + " — " + ${[itemprop="author"]}.first().text()

For complex string assembly, string interpolation is often more readable than concatenation.

Methods

contains(substring)

Returns true if the string contains the given substring.

${h1}.first().text().contains("Chapter")

Returns: boolean


startsWith(prefix)

Returns true if the string starts with the given prefix.

${a}.first().attr("href").startsWith("/products")

Returns: boolean


endsWith(suffix)

Returns true if the string ends with the given suffix.

${a}.first().attr("href").endsWith(".html")

Returns: boolean


matches(pattern)

Returns true if the string matches the given regular expression pattern.

${[itemprop="email"]}.first().value().matches("^[^@]+@[^@]+$")

Returns: boolean


count()

Returns the length of the string in characters.

${h1}.first().text().count()

Returns: number


isEmpty()

Returns true if the string has zero characters.

"".isEmpty()
// true
${h1}.first().text().isEmpty()

Returns: boolean


trim()

Returns a new string with leading and trailing whitespace removed.

"  hello  ".trim()
// "hello"

Returns: string


lower()

Returns a new string with all characters converted to lowercase.

"Hello World".lower()
// "hello world"

Returns: string


upper()

Returns a new string with all characters converted to uppercase.

"Hello World".upper()
// "HELLO WORLD"

Returns: string


replace(pattern, replacement)

Returns a new string with all occurrences of pattern replaced by replacement.

"hello world".replace("world", "there")
// "hello there"

Returns: string


split(delimiter)

Splits the string by the given delimiter and returns a list of substrings.

"a,b,c".split(",")
// ["a", "b", "c"]

Returns: list


slice(start[, end])

Returns the substring from start (inclusive) to end (exclusive), operating on characters. If end is omitted, slices to the end of the string. Negative indices count from the end.

"hello".slice(1, 3)    // "el"
"hello".slice(1)       // "ello"
"hello".slice(-3)      // "llo"
"hello".slice(-3, -1)  // "ll"

When start >= end after resolving negative indices, returns an empty string. Indices are clamped to the valid range, so out-of-bounds values do not produce errors.

Returns: string


Type coercion methods

These methods parse a string into another type. .Number() returns null if the string cannot be parsed; .Integer() and .Float() throw a type error instead.

.Number()

Smart-parses the string as a number. Returns an integer for whole numbers like "42" and a float for decimal numbers like "3.14". Returns null if the string is not a valid number.

${[itemprop="price"]}.first().value().Number()
// "42" → 42 (integer)
// "3.14" → 3.14 (float)

Returns: number or null


.Float()

Parses the string as a decimal number. Always returns a float, even for whole numbers. Throws a type error if the string is not a valid number — unlike .Number(), it does not return null.

${[itemprop="rating"]}.first().value().Float()
// "42" → 42.0 (float)
// "3.14" → 3.14 (float)

Returns: float


.Integer()

Parses the string as a whole number (no decimal part). Throws a type error if the string is not a valid integer — unlike .Number(), it does not return null.

${[itemprop="quantity"]}.first().value().Integer()

Returns: integer


.String()

Returns the string itself. This is the identity coercion — useful in generic contexts where coercion is applied uniformly across values.

${h1}.first().text().String()

Returns: string


.Bool()

Converts the string to a Boolean using truthiness rules. Empty strings are false, non-empty strings are true.

"hello".Bool()    // true
"".Bool()         // false

Returns: boolean

Cryptographic methods

These methods are available on the server side only. They are not available in the browser-based JavaScript evaluator.

.sha256()

Returns the SHA-256 hash of the string as a lowercase hex string.

"hello".sha256()
// "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"

Returns: string


.hmac_sha256(key)

Returns the HMAC-SHA256 of the string using the given key, as a lowercase hex string.

"message".hmac_sha256("secret-key")

Returns: string


.bcrypt()

Hashes the string using bcrypt. Each call produces a different result due to random salt.

"password".bcrypt()                    // default cost 12
"password".bcrypt({ cost: 10 })        // custom cost (4-31)

.argon2()

Hashes the string using argon2id. Each call produces a different result due to random salt.

"password".argon2()                                         // sensible defaults
"password".argon2({ memory: 19456, time: 2, length: 64 })   // custom params

Options: memory (KiB), time (iterations), length (output bytes).

Random string generation

String.random(length)

Generates a cryptographically random string of the given length using alphanumeric characters (A-Za-z0-9).

String.random(8)              // e.g. "kR7mXp2q"
String.random(32)             // a 32-character random string

String.random(length, options)

Generates a random string with configurable character alphabets and per-alphabet minimum guarantees.

Named alphabets: upper (A-Z), lower (a-z), digits (0-9), symbols, alphanumeric (A-Za-z0-9), url_safe (A-Za-z0-9 plus -_).

Each can be set to true (include), false (exclude), or an integer (include with minimum count guarantee).

String.random(32, { upper: true, digits: true })           // uppercase + digits only
String.random(32, { upper: 3, lower: 3, digits: 2 })       // with minimum counts
String.random(20, { url_safe: true })                       // URL-safe characters

Custom characters: Use chars for a custom character pool, optionally with chars_min for a minimum count.

String.random(20, { chars: "AEIOU" })                       // vowels only
String.random(32, { alphanumeric: true, chars: "-_", chars_min: 2 })  // combined

The algorithm satisfies per-alphabet minimums first, fills the remainder from the combined pool, then shuffles the result.

See also

Variables and Composition

Variables and Composition

You have seen selectors, data extraction, filtering, mapping, aggregation, null handling, and element construction. This guide covers the tools that hold longer expressions together: let bindings, if expressions, dictionary construction, namespace declarations, and the patterns that emerge when all of these are combined.

let bindings

A let binding names a value and makes it available in the rest of the expression.

let name = expr; body

The binding evaluates expr, assigns the result to name, and then evaluates body. The value of the whole expression is the value of body.

let count = ${div.item}.count();
let label = count > 0 ? "items found" : "no items";
label

Bindings are evaluated in order. A later binding can reference an earlier one. The final expression — the one without let — is the return value.

let is an expression, not a statement. It can appear anywhere an expression is expected: inside an if branch, inside a ternary, inside a map callback, inside a construction body. You will see this used extensively in the "Putting it all together" example below.

if expressions

When conditional logic needs more than a simple ternary, use if expressions. They support multiple branches and multi-statement bodies:

let count = ${div.item}.count();
let message = if (count > 10) {
  "Showing first 10 of " + count.String()
} else if (count > 0) {
  count.String() + " items"
} else {
  "No items found"
};
message

if is an expression — the whole construct evaluates to the value of the taken branch. When no branch matches and there is no else, it returns null.

Inside an if body, you can use let bindings for intermediate calculations. The bindings are scoped to that body:

if (${[itemprop="product"]}.count() > 0) {
  let products = ${[itemprop="product"]};
  let total = products.map(p => p.value().Number()).reduce(0, (a, b) => a + b);
  new div.summary { text: total.String() + " total" }
}

Building dictionaries

Dictionaries created with let support property assignment using either dot notation or subscript syntax. This is how you build up structured data step by step.

let product = {};
product.name = "Widget";
product.price = 9.99;
product

Dot notation works when the key is a known identifier. Subscript notation works with a dynamic key from another expression:

product[dynamicKey] = value

Dictionaries also have read methods:

Method Returns
.keys() List of all keys in the dictionary
.values() List of all values
.entries() List of { key, value } dictionaries
.contains(key) true if the key exists, false otherwise
.count() Number of entries in the dictionary

@namespace declarations

At the top of an expression, you can declare XML namespace prefixes using @namespace:

@namespace prefix url("uri");

Once declared, the prefix can be used in selector literals and construction expressions with the prefix|name syntax.

This is how you work with SVG elements:

@namespace svg url("http://www.w3.org/2000/svg");
new svg|svg[width="200"][height="200"] {
  new svg|circle[cx="100"][cy="100"][r="50"][fill="red"] {}
}

And with Pagelove namespace attributes:

@namespace p url("https://pagelove.org/Binding/CSS");
new div[p|transient][p|ttl="3600"] { new h1 { text: "Draft" } }

Multiple @namespace declarations are allowed. They must all appear before the first expression in the file.

Putting it all together

Here is a complete transformation that uses let, map, if, construction, and a namespace declaration together:

@namespace p url("https://pagelove.org/Binding/CSS");
let products = ${[itemprop="product"]};
let count = products.count();
let header = new h2 { text: "Found " + count.String() + " products" };
let items = products.map(el =>
  let name = el.attr("data-name");
  let price = el.value().Number();
  let badge = if (price > 100) { new span.premium { text: "Premium" } } else { new span.value { text: "Great value" } };
  new li.product {
    new span[itemprop="name"] { text: name },
    new span[itemprop="price"] { text: price },
    badge
  }
);
new section.results[p|transient] {
  header,
  new ul { items }
}

Walking through the steps:

  1. The @namespace declaration makes p| available for Pagelove attributes.
  2. let products captures the list of product elements once.
  3. let count derives a number from that list.
  4. let header builds an <h2> element using string concatenation.
  5. let items maps over products. The callback is itself a let chain: it extracts name and price from each element, uses an if expression to choose a badge element, then constructs and returns an <li>.
  6. The final expression assembles a <section> marked p|transient, containing the header and a <ul> built from the mapped items.

Notice that let inside the map callback is not special syntax — it is just a let expression appearing in the position where the callback body is expected. The same composability applies everywhere.

Where to go from here

This is the last guide in the progressive sequence. For complete details on everything covered across all four guides, see the reference section:

List

List

A list is an ordered collection of zero or more values. Lists are the primary result type for CSS selector expressions and the foundation for most data-processing work in Sessel.

Literal syntax

List literals are written with square brackets. Elements are separated by commas. A trailing comma is allowed.

[]
[1, 2, 3]
["a", "b", "c"]
[true, false, null]

Lists can contain any type, including nested lists and dictionaries:

[[1, 2], [3, 4]]
[{ name: "Widget", price: 42 }, { name: "Gadget", price: 15 }]

Selectors return lists

A CSS selector expression always returns a list, even when only one element matches:

${h1}           // list of all <h1> elements
${div.item}     // list of all elements matching div.item
${#title}       // list — always, even though IDs are unique

Use .first(), .last(), or .at(n) to extract a single element from the list.

Access methods

count()

Returns the number of elements in the list.

${div.item}.count()

Returns: number


isEmpty()

Returns true if the list has zero elements.

[].isEmpty()
// true
${div.item}.isEmpty()

Returns: boolean


first()

Returns the first element in the list, or null if the list is empty.

${h1}.first()

Returns: element or null


last()

Returns the last element in the list, or null if the list is empty.

${p}.last()

Returns: element or null


at(n)

Returns the element at position n (0-indexed), or null if n is out of range. Negative indices count from the end: -1 is the last element, -2 is the second-to-last, and so on.

${li}.at(2)
${li}.at(-1)     // last element
${li}.at(-2)     // second-to-last element

Returns: element or null


slice(start[, end])

Returns a sub-list from position start up to (but not including) position end. If end is omitted, slices to the end of the list. Negative indices count from the end: -1 refers to the last position, -2 to the second-to-last, and so on.

${li}.slice(0, 5)
${li}.slice(2)        // from position 2 to end
${li}.slice(-3, -1)   // third-to-last through second-to-last
${li}.slice(-2)       // last two elements

Returns: list

Aggregation methods

sum()

Sums the values of all elements in the list, parsing each element's value as a number. Non-numeric values are skipped.

${[itemprop="price"]}.sum()

Returns: number


min()

Returns the smallest numeric value among the elements. Non-numeric values are skipped. Returns null if no numeric values are present.

${[itemprop="price"]}.min()

Returns: number or null


max()

Returns the largest numeric value among the elements. Non-numeric values are skipped. Returns null if no numeric values are present.

${[itemprop="price"]}.max()

Returns: number or null

Transformation methods

filter(callback)

Returns a new list containing only the elements for which the callback is true. The callback can accept up to three parameters:

${li}.filter(el => el.text().Integer() > 10)
${[itemprop="price"]}.filter(el => el.value().Number() > 50)

With index and list parameters:

${li}.filter((el, i) => i < 5)
${li}.filter((el, i, list) => i < list.count() / 2)

Returns: list


reject(callback)

The inverse of filter(): returns a new list containing only the elements for which the callback is false. The callback can accept up to three parameters (element, index, list).

${li}.reject(el => el.text() == "")
${li}.reject((el, i) => i < 5)

Returns: list


find(callback)

Returns the first element for which the callback is true, or null if no element matches. The callback can accept up to three parameters (element, index, list).

${li}.find(el => el.text() == "Widget")
${[itemprop="price"]}.find(el => el.value().Number() > 100)

Returns: the matching element, or null


map(callback)

Returns a new list formed by evaluating the callback for each element. The callback can accept up to three parameters:

${[itemprop="item"]}.map(e => e.value())
${li}.map(el => el.text().Integer())

With an index parameter:

${li}.map((el, i) => i.String() + ". " + el.text())

The expression can produce any value type, including dictionaries:

${[itemprop="product"]}.map(p => {
    name: p.text(),
    price: p.attr("data-price")
})

Returns: list


each(callback)

Runs the callback once for each element, for side effects, and returns the original list unchanged. The callback can accept up to three parameters (element, index, list), like the other lambda-taking methods.

@schema Pagelove url("https://pagelove.org/1.0");
${li}.each(el => Pagelove.PUT(el, "/log/" + el.attr("id") + ".html"))

Returns: the original list


takeWhile(callback)

Returns a new list of the leading elements for which the callback is true, stopping at the first element where it's false. The callback can accept up to three parameters (element, index, list).

[1, 2, 3, 10, 4].takeWhile(n => n < 5)
// [1, 2, 3]

Returns: list


dropWhile(callback)

Returns a new list with the leading elements for which the callback is true removed, keeping everything from the first false result onward. The callback can accept up to three parameters (element, index, list).

[1, 2, 3, 10, 4].dropWhile(n => n < 5)
// [10, 4]

Returns: list

Validation methods

all(callback)

Returns true if the callback is true for every element in the list. Returns true for an empty list. The callback can accept up to three parameters (element, index, list).

${div.item}.all(el => el.text().count() > 0)
${li}.all((el, i) => i == 0 || el.text() != "")

Returns: boolean


any(callback)

Returns true if the callback is true for at least one element in the list. Returns false for an empty list. The callback can accept up to three parameters (element, index, list).

${[itemprop="price"]}.any(el => el.value().Number() > 100)

Returns: boolean


unique()

Removes duplicate values (compared by text representation) and returns a new list. Preserves first-occurrence order.

[1, 2, 2, 3].unique()
// [1, 2, 3]
${[itemprop="category"]}.unique()

Returns: list


subset(other)

Returns true if every value in this list (compared as text) also appears in the other list. Returns true if this list is empty.

(${[itemprop="tag"]} from self).subset(${[itemprop="allowed-tag"]} from "/config/tags")

Returns: boolean


disjoint(other)

Returns true if this list and the other list share no common values (compared as text). Returns true if either list is empty.

(${[itemprop="tag"]} from self).disjoint(${[itemprop="tag"]} from "/published/*")

Returns: boolean


contains(value)

Returns true if the list contains the given value. Comparison uses text representation, consistent with unique(), subset(), and disjoint().

["admin", "editor", "viewer"].contains("admin")
// true

Returns: boolean

Ordering methods

sort(callback)

Returns a new list sorted according to the callback. The sort is stable. The callback operates in one of two modes:

Key-extraction mode (1 parameter) -- the callback returns a sort key for each element:

${[itemprop="product"]}.sort(el => el.attr("data-price").Number())
${li}.sort(el => el.text())

Comparator mode (2 parameters) -- the callback receives two elements and returns a negative number, zero, or positive number. The spaceship operator <=> is the idiomatic way to produce this:

${li}.sort((a, b) => a.text() <=> b.text())

Comparator mode enables multi-field sorting:

${[itemprop="product"]}.sort((a, b) => {
  let cat = a.attr("data-category") <=> b.attr("data-category");
  cat != 0 ? cat : a.attr("data-price").Number() <=> b.attr("data-price").Number()
})

Returns: list


reverse()

Returns a new list with the elements in reverse order.

${li}.reverse()
${[itemprop="product"]}.sort(el => el.attr("data-price").Number()).reverse()

Returns: list


flatten()

Recursively flattens nested lists into a single list.

[[1, 2], [3, [4, 5]]].flatten()
// [1, 2, 3, 4, 5]

Useful when map() produces nested lists:

${section}.map(s => ${li} from s).flatten()

Sub-select syntax provides a more concise alternative:

${section}.map(s => s.${ li }).flatten()

Returns: list

String conversion

join(delimiter)

Joins the values in the list into a single string, separated by the given delimiter. Each value is converted to its string representation before joining.

["a", "b", "c"].join(", ")
// "a, b, c"
${[itemprop="tag"]}.map(el => el.text()).join("; ")

Returns: string

Reduction methods

reduce(initial, callback)

Reduces the list to a single value by applying the callback to an accumulator and each element, left to right. The callback receives two parameters: the accumulator and the current element.

[1, 2, 3, 4].reduce(0, (acc, el) => acc + el)
// 10
${li}.reduce("", (acc, el) => acc + el.text() + "\n")

If the list is empty, the initial value is returned unchanged.

The JavaScript argument order reduce(callback, initial) is also accepted, so both of these are equivalent:

[1, 2, 3, 4].reduce(0, (acc, el) => acc + el)   // initial first
[1, 2, 3, 4].reduce((acc, el) => acc + el, 0)   // callback first (JS order)

The callback may also be given alone — reduce((acc, el) => acc + el) — in which case the first element seeds the accumulator (and an empty list is an error).

Returns: any type (determined by the callback and initial value)


reduceRight(initial, callback)

Like reduce(), but processes elements from right to left (last to first).

["a", "b", "c"].reduceRight("", (acc, el) => acc + el)
// "cba"

If the list is empty, the initial value is returned unchanged.

Returns: any type (determined by the callback and initial value)

See also

Dictionary

Dictionary

A Dictionary is an ordered collection of key-value pairs. Keys are always strings. Values can be any type, including other dictionaries and lists.

Literal syntax

Dictionaries are written with curly braces. Keys can be bare identifiers or quoted strings.

{}
{ name: "Widget", price: 42 }
{ "Content-Type": "text/html" }

Use quoted keys when a key contains hyphens, spaces, or other characters that are not valid in a bare identifier.

Trailing commas are allowed:

{
  name: "Widget",
  price: 42,
}

Dictionaries can be nested:

{
  product: { name: "Widget", price: 42 },
  meta: { tags: ["sale", "new"] }
}

Access

Dot access

Use dot notation to read a value by a known key:

product.name
product.price

Subscript access

Use square brackets to read a value by a string expression or a dynamic key:

product["name"]
product[dynamicKey]

Dot access and subscript access return null when the key does not exist.

Property assignment

Dictionaries bound to a let variable support property assignment for building up data structures.

Dot assignment

let product = {};
product.name = "Widget";
product.price = 9.99;
product

Subscript assignment

let product = {};
let key = "name";
product[key] = "Widget";
product

Both forms add the key if it is absent or replace its value if it already exists.

For more detail on let bindings, see Variables and Composition.

Methods

.keys()

Returns a list of all keys in insertion order.

{ name: "Widget", price: 42 }.keys()
// ["name", "price"]

Returns: list

.values()

Returns a list of all values in insertion order.

{ name: "Widget", price: 42 }.values()
// ["Widget", 42]

Returns: list

.entries()

Returns a list of { key, value } dictionaries, one per entry, in insertion order.

{ name: "Widget", price: 42 }.entries()
// [{ key: "name", value: "Widget" }, { key: "price", value: 42 }]

Returns: list

.contains(key)

Returns true if the dictionary has an entry for key, false otherwise.

{ name: "Widget" }.contains("name")   // true
{ name: "Widget" }.contains("price")  // false

Returns: boolean

.count()

Returns the number of entries in the dictionary.

{ name: "Widget", price: 42 }.count()
// 2

Returns: number

.isEmpty()

Returns true if the dictionary has no entries.

{}.isEmpty()
// true
{ name: "Widget" }.isEmpty()
// false

Returns: boolean

.get(key, default)

Returns the value for key if it exists, otherwise evaluates and returns default. Unlike dot or subscript access (which return null for missing keys), .get() lets you provide a fallback inline.

{ name: "Widget" }.get("name", "Unknown")    // "Widget"
{ name: "Widget" }.get("price", 0)           // 0

Both arguments are required.

Returns: any

.delete(key)

Returns a new dictionary with the entry for key removed. The original dictionary is unchanged. If the key does not exist, the returned dictionary is identical to the original.

{ name: "Widget", price: 42 }.delete("price")
// { name: "Widget" }

{ name: "Widget" }.delete("missing")
// { name: "Widget" }

Returns: dictionary

.merge(other)

Returns a new dictionary combining entries from both dictionaries. If a key exists in both, the value from other wins.

{ name: "Widget", price: 42 }.merge({ price: 50, stock: 10 })
// { name: "Widget", price: 50, stock: 10 }

Returns: dictionary

See also

Element

Element

An Element represents an HTML element. Elements come from two distinct sources, which determines whether they are immutable or mutable.

Two kinds of element

Queried elements

A queried element comes from the document store via a CSS selector expression. Queried elements are immutable — they are snapshots of what the database holds at the time the expression is evaluated.

${h1}                   // List of all h1 elements
${h1}.first()           // First h1 element
${[itemprop="price"]}.last()
${.card}.at(2)

CSS selector expressions always produce a List. Use .first(), .last(), or .at(n) to extract a single element.

Constructed elements

A constructed element is built in the expression itself using new syntax. Constructed elements are mutable — you can call setter methods on them and append children to them before they are used.

new p {}
new h1 { text: "Welcome" }
new div.card[aria-hidden="true"] {}

See Constructing HTML for a full tutorial and Syntax for the complete grammar.

Element.fromString(html)

A static method that parses an HTML string into a constructed element. An alternative to new syntax when the markup is already available as a string. Throws a type error if the string is empty or cannot be parsed as an element.

Returns: constructed element

Element.fromString("<p>hello</p>").getHTML()
// "hello"

Element.fromString("")
// TypeError

Getting data (both kinds)

These methods work on both queried and constructed elements.

.text()

Returns the text content of the element — the concatenation of all descendant text nodes, with no HTML markup.

Returns: string or null

${h1}.first().text()
${[itemprop="name"]}.first().text()

Returns null when the element has no text content.

.value()

Returns the microdata value of the element, following the WHATWG Microdata specification rules for which attribute holds the meaningful value:

Element Value attribute
<a>, <area>, <link> href
<audio>, <embed>, <iframe>, <img>, <source>, <track>, <video> src
<object> data
<meta> content
<data>, <meter> value
<time> datetime
All others text content

The value is returned verbatim — text content is not trimmed, so leading and trailing whitespace is preserved (use .text() and trim it yourself if you want a cleaned string). An element from the table above that is missing its value attribute — for example an <a> with no href — yields an empty string "", not its text content; only <time> falls back to its text when datetime is absent. A text-content element (the “All others” row) whose text is empty yields null — empty text is treated as absent. This matches the value used by uniqueness and reference constraints, so a script sees exactly what the database enforced.

Returns: string or null

${[itemprop="price"]}.first().value()
${[itemprop="url"]}.first().value()      // href for <a> elements
${[itemprop="image"]}.first().value()    // src for <img> elements
${[itemprop="pubdate"]}.first().value()  // datetime for <time> elements

.attr(name)

Returns the value of the named attribute, or null if the attribute is absent.

Returns: string or null

${a.external}.first().attr("href")
${img.hero}.first().attr("alt")
${div.card}.first().attr("data-id")

.path()

Returns the source document path of the element as a String, or null for constructed elements. Elements retain their source path through sub-selections and list operations.

Returns: string or null

(${h1} from "/team/benji.html").first().path()   // "/team/benji.html"
(new div { }).path()                              // null

.document()

Returns the root element of the element's source document, or null for constructed elements. Enables navigating from a selected element back to its full document context.

Returns: element or null

(${h1} from "/team/benji.html").first().document()   // root <html> element
(new div { }).document()                              // null

.selector()

Generates a CSS selector string that uniquely identifies the element within its document. Uses #id if the element has an id attribute, otherwise builds a tag:nth-child(n) path from the nearest ancestor with an id.

Returns: string or null

${h1}.first().selector()        // "#main > h1:nth-child(1)"
${#hero}.first().selector()     // "#hero"
(new div#panel { }).selector()  // "#panel"
(new span { }).selector()       // "span"

.microdata()

Extracts HTML Microdata from an element with itemscope into a structured Map. Properties come from descendant itemprop attributes. @id is automatically constructed from the element's source path and id attribute. Returns null if the element has no itemscope.

Returns: map or null

${[itemscope][itemtype*="Product"]}.first().microdata()
// → { "@type": "Product", "name": "Widget", "price": "29.99", "@id": "/products/widget.html#widget" }

When called on a List of elements, maps each element to its microdata:

${[itemscope]}.microdata()
// → [{ "@type": "Product", ... }, { "@type": "Product", ... }]

.clone()

Creates an independent constructed copy of the element. The clone is a ConstructedElement (mutable) and loses its source path. Works on both queried and constructed elements.

Returns: constructed element

${div.product}.first().clone()                  // queried element → constructed copy
(new div.card { text: "hi" }).clone()           // constructed → independent copy
${[itemtype*="Product"]}.map(e => e.clone())      // clone each element in a list

.children()

Returns the direct child elements of the element as a List. Text nodes are excluded — only element nodes are returned. This walks the tree directly without using selector machinery, so it works on any element regardless of the template inert boundary.

Returns: list

${ul}.first().children()            // [<li>…</li>, <li>…</li>, …]
${ul}.first().children().count()    // 3
${div}.first().children().first().text()

.parent()

Returns the element's parent element — the inverse of .children(). Returns null when there is no parent element: at the top of the element tree (nothing sits above the topmost element), and on a constructed element that is itself the root of its own tree.

Upward traversal also works inside a constructed tree. A child obtained from a constructed element's .children() walks back up to its parent, and .parent() chains all the way to the constructed root. The link is live — reading a parent's attribute through a child's .parent() reflects edits made to that parent — and a child handle keeps the whole tree alive, so the traversal works even when the constructed root was only a transient value.

Returns: element or null

${li}.first().parent()              // the enclosing <ul> element
${li}.first().parent().parent()    // chains upward
${html}.first().parent()            // null — top of the tree
(new div { }).parent()              // null — constructed root

let d = new div#outer { new p { new span { } } };
d.children().first().parent().attr("id")            // "outer"
d.children().first().children().first().parent().parent().attr("id")   // "outer" — chains up
(new div#outer { new p { } }).children().first().parent().attr("id")   // "outer" — even from a transient root

.content()

For <template> elements, returns the template's inner content as a queryable List of elements. The content is parsed into a fresh document fragment, so selectors and sub-selects work normally on the returned elements — bypassing the inert boundary that prevents selectors from descending into <template>.

Returns null for non-template elements.

Returns: list or null

${template#cart-row}.first().content()
// → [<tr>…</tr>]

${template}.first().content().${ [itemprop="price"] }.first().value()
// → "29.99"

${div}.first().content()
// → null (not a template)

.getHTML()

Returns the element's inner HTML — the serialized markup of its children, without the element's own opening/closing tags.

Returns: string

new div { new p { text: "hello" } }.getHTML()
// "<p>hello</p>"

new span {}.getHTML()
// ""

.innerhtml()

Returns the element's inner HTML. Behaves the same as .getHTML() above.

Returns: string

let el = new div { new p { text: "hello" } };
el.innerhtml().contains("hello")
// true

Sub-select: el.${ selector }

Runs a CSS selector against an element's subtree in-memory. Unlike top-level ${...} which queries stored documents, sub-select operates on already-materialized elements.

${section}.first().${ h2 }                     // all h2 elements within the first section

On a List, the selector runs against each element's subtree and results are concatenated into a flat list:

${[itemtype*="Product"]}.${ [itemprop="name"] }
// Equivalent to:
${[itemtype*="Product"]}.map(el => el.${ [itemprop="name"] }).flatten()

Standard CSS combinators work relative to the element root:

product.${ > [itemprop="name"] }      // direct children only
product.${ h1 + p }                 // adjacent sibling among descendants

Setting data (constructed elements only)

Setter methods are only available on constructed elements. They follow an arity-based get/set convention: the same method name with no arguments reads and with arguments writes.

Each setter returns the modified element, so calls can be chained.

.text(expr)

Replaces the text content of the element with the result of expr.

Returns: modified element

new p.text("Hello, world")
new h1.text(${[itemprop="name"]}.first().value())

.value(expr)

Sets the microdata value of the element. Follows the same WHATWG attribute rules as .value() for reading — the appropriate attribute (href, src, data, content, value, datetime, or text content) is set based on the element tag.

Returns: modified element

new a[itemprop="url"].value("https://example.com")
new meta[itemprop="description"].value("A brief summary")
new time[itemprop="pubdate"].value("2026-03-10")

.attr(name, expr)

Sets the named attribute to the result of expr.

Returns: modified element

new div.attr("id", "main")
new a.attr("href", ${[itemprop="url"]}.first().value())
new img.attr("alt", ${[itemprop="name"]}.first().text())

Construction syntax summary

new tag.class#id[attr="literal"][attr=expression][boolean-attr] { children }
new p { text: "Hello" }
new a.button[href="/about"] { text: "About us" }
new input[type="checkbox"][checked] {}
new div#main.card[data-count=${.item}.count()] {}

See Constructing HTML for a full tutorial and Syntax for the complete grammar.

Mutation methods (constructed elements only)

These methods modify the element tree and return the modified element for chaining.

.append(child)

Appends the given child as the last child of the element.

Returns: modified element

new ul {}.append(new li { text: "Item" })

.prepend(child)

Inserts the given child as the first child of the element.

Returns: modified element

let el = new ul { new li { text: "b" } };
let el2 = el.prepend(new li { text: "a" });
el2.children().first().text()
// "a"

.setHTML(html)

Replaces the element's children with the parsed content of the given HTML string.

Returns: modified element

let el = new div { text: "old" };
el.setHTML("<b>new</b>");
el.getHTML()
// "<b>new</b>"

.empty()

Removes all children from the element.

Returns: modified element

let el = new div { text: "hello", new p { text: "world" } };
let el2 = el.empty();
el2.children().count()
// 0

.remove()

Removes the element from its parent. Returns null.

Returns: null

let el = new div { new p { text: "Hello" } };
el.${ p }.first().remove()

.replaceWith(replacement)

Replaces the element with the given replacement in its parent's child list. Returns the replacement element.

Returns: element

let el = new div { new p { text: "old" } };
el.${ p }.first().replaceWith(new span { text: "new" })

.insertBefore(referenceChild)

Called on the new element being inserted, not on the parent: newChild.insertBefore(referenceChild) inserts the receiver into referenceChild's parent, immediately before it. Returns the receiver (the inserted element).

Returns: element (the receiver)

let el = new ul { new li { text: "Second" } };
let newLi = new li { text: "First" };
newLi.insertBefore(el.${ li }.first())

See also

Syntax

Syntax

Complete grammar and syntax rules for the Sessel expression language.

Grammar

program              := declaration* frame_body
declaration          := namespace_decl | schema_decl
namespace_decl       := '@namespace' IDENT 'url' '(' STRING ')' ';'
schema_decl          := '@schema' IDENT 'url' '(' STRING ')' ';'

frame_body           := (statement ';'?)* expr?
statement            := let_binding | property_assign | expr
let_binding          := 'let' destructure '=' expr
destructure          := IDENT | dict_destruct | list_destruct
dict_destruct        := '{' dict_field (',' dict_field)* '}'
dict_field           := IDENT (':' IDENT)?
list_destruct        := '[' list_field (',' list_field)* ']'
list_field           := IDENT | '...' IDENT
property_assign      := IDENT ':' expr

expr                 := return_expr | throw_expr | ternary
return_expr          := 'return' expr
throw_expr           := 'throw' expr

ternary              := null_coalesce ('?' ternary ':' ternary)?
null_coalesce        := or_expr ('??' or_expr)*
or_expr              := and_expr ('||' and_expr)*
and_expr             := comparison ('&&' comparison)*
comparison           := addition (comp_op | isa_op)*
comp_op              := ('==' | '!=' | '<' | '>' | '<=' | '>=' | '<=>') addition
isa_op               := 'isa' type_name
addition             := multiplication (('+' | '-') multiplication)*
multiplication       := unary (('*' | '/') unary)*
unary                := ('!' | '-') unary | postfix
postfix              := primary (postfix_op)*
postfix_op           := method_call | opt_method_call | subscript | opt_subscript
                      | sub_select | dot_access | opt_dot_access
method_call          := '.' IDENT '(' arg_list? ')'
opt_method_call      := '?.' IDENT '(' arg_list? ')'
dot_access           := '.' IDENT
opt_dot_access       := '?.' IDENT
subscript            := '[' expr ']'
opt_subscript        := '?.' '[' expr ']'
sub_select           := '.' '${' css_content '}'

primary              := literal | selector | IDENT | construct | func_call
                      | grouped | map_lit | list_lit | if_expr | try_expr | from_block

literal              := INTEGER | FLOAT | string | 'true' | 'false' | 'null'
string               := '"' (STRING_CHAR | '#{' expr '}')* '"'
selector             := '${' css_content '}' ('from' from_source (',' from_source)*)?
from_source          := 'self' | 'document' | expr
construct            := 'new' (ns_prefix '|')? TAG construct_mods? construct_body?
func_call            := IDENT '(' arg_list? ')'
grouped              := '(' expr ')'
map_lit              := '{' (map_entry (',' map_entry)* ','?)? '}'
list_lit             := '[' (expr (',' expr)* ','?)? ']'
if_expr              := 'if' '(' expr ')' block ('else' 'if' '(' expr ')' block)* ('else' block)?
try_expr             := 'try' block 'catch' '(' IDENT ')' block
block                := '{' frame_body '}'
from_block           := 'from' from_source (',' from_source)* '{' frame_body '}'

arg_list             := arg (',' arg)*
arg                  := lambda | ternary
lambda               := lambda_params '=>' lambda_body
lambda_params        := IDENT | '(' lambda_param (',' lambda_param)* ')'
lambda_param         := IDENT | '...' IDENT
lambda_body          := ternary | block

Operator precedence

Operators are listed from lowest to highest precedence. Higher-precedence operators bind more tightly.

Precedence Operator Associativity
1 (lowest) Ternary ? : Right
2 Null-coalesce ?? Left
3 Logical OR || Left
4 Logical AND && Left
5 Comparison == != < > <= >= <=> isa Left
6 Addition/Subtraction + - Left
7 Multiplication/Division * / Left
8 Unary ! - Right
9 (highest) Postfix .method() ?.method() [index] ?.[index] .prop ?.prop Left

String interpolation

Strings support interpolation with #{}. Expressions inside #{} are evaluated and coerced to String via .String():

let name = "Sessel";
let version = 1;
"#{name} version #{version}"          // "Sessel version 1"
"total: #{items.count()}"             // "total: 3"
"#{price * quantity} GBP"             // "150 GBP"

#{} is used instead of ${} to avoid ambiguity with CSS selectors. If the expression is null, the result is the empty string. To include a literal #{ in a string, escape the #: "\#{not interpolated}".

Optional chaining

?. short-circuits the entire postfix chain to null if the left-hand side is null:

a?.b().c()      // if a is null → null (b() and c() are not called)
a?.b?.c         // if a is null → null; if a.b is null → null
items.first()?.text()?.upper()    // null if list is empty

?. works with method calls (?.method()), property access (?.prop), and subscript (?.[key]). It does not work with sub-select — use ?? with a fallback for that case.

return keyword

The value of a block is its last expression. The return keyword provides early exit from a block or lambda body:

(el, i) => {
  if (i > 10) { return null }
  let name = el.text();
  if (name == "") { return "unnamed" }
  name.upper()
}

A trailing semicolon on the last expression is always valid — it does not change the block's value.

Destructuring

let supports destructuring for dictionaries and lists:

// Dictionary destructuring — sends messages through dispatch
let { name, role } = user;              // binds name = user.name, role = user.role
let { name: n, role: r } = user;       // rename: binds n = user.name, r = user.role

// List destructuring — uses positional access
let [first, second] = items;            // binds first = items.at(0), second = items.at(1)
let [head, ...rest] = items;            // head = items.at(0), rest = items.slice(1)

Dictionary destructuring works with any receiver that supports message dispatch — Elements, Instances, Dictionaries. Accessing a missing key produces null.

List destructuring binds positionally. Out-of-bounds positions are null. The ...rest pattern collects remaining elements as a List via .slice(). Only one ...rest is allowed and it must be last.

Selector literals

A CSS selector wrapped in ${} produces a list of matching elements:

${div.item}
${h1}
${[itemprop="price"]}
${#main .content p}

A selector always returns a list, even if only one element matches. Use .first(), .last(), or .at(n) to extract a single element.

Pagelove extends CSS with four pseudo-classes:

Pseudo-class Matches
:contains(text) Elements whose text content includes text
:equals(text) Elements whose text content exactly equals text
:greater-than(n) Elements whose numeric value is greater than n
:less-than(n) Elements whose numeric value is less than n
${h1:contains("Chapter")}
${[itemprop="price"]:greater-than(100)}
${[itemprop="status"]:equals("active")}

Expression embedding

Inside a selector literal, unquoted attribute values and pseudo-class arguments are Sessel expressions rather than CSS literals. Quoted values remain plain CSS strings.

let threshold = 50;
${[itemprop="price"]:greater-than(threshold)}

Here threshold resolves to the variable bound by let. Compare with the quoted form, which is a CSS literal:

${[itemprop="price"]:greater-than("50")}

Expressions can include method chains and sub-selects:

${div[data-id=host.${ [itemprop="id"] }.first().value()]}

This applies to all attribute selector operators (=, ~=, |=, ^=, $=, *=) and to the extended pseudo-classes.

from clause

The from clause restricts a selector query to specific documents. Without it, the query runs across the entire site.

${selector} from expr
Source Meaning
self The current document
prior The pre-mutation document (null if the document is new)
String literal A specific document path
Glob pattern All documents matching the pattern
Any expression producing a string or list of strings The resolved document(s)
${div.item} from self
${div.item} from "/products/shoes"
${div.item} from "/products/*"
${div.item} from ${a.nav}.first().attr("href")
${[itemprop="status"]} from prior

Multi-source from

Multiple sources may be specified as a comma-separated list:

${[itemprop="status"]} from "/orders/completed/*", "/orders/processing/*"

The result is the union of all matches. Sources may include self, paths, and globs:

${h1} from self, "/templates/header.html"

Block-level from

When multiple selectors need to query the same document, the block-level from scopes all bare selectors within its body to that document:

from "/products/shoes" {
  ${h1}.first().text() == "Nike" &&
  ${[itemprop="price"]}.first().value().Number() > 0
}

This is equivalent to writing from "/products/shoes" on each selector individually, but avoids repetition. Selectors with an explicit from clause inside the block override the block scope.

Block-level from also accepts multiple sources:

from "/orders/completed/*", "/orders/processing/*" {
  ${[itemprop="status"]}.filter(el => el.text() == "urgent").count()
}

The prior keyword is contextual — it is only treated as the pre-mutation document reference when it appears after from (either inline or block-level). In all other positions, prior is an ordinary identifier.

Construction syntax

The new keyword constructs an HTML element:

new tag.class#id[attr="val"] { children }

After new:

new p.note {}
new a[href="/products"] {}
new li { ${span.label}.first(), ${span.value}.first() }
new input[type=checkbox][checked] {}

Construction grammar

construct       = "new" [ ns_prefix "|" ] TAG_NAME { construct_mod } [ construct_body ] ;
construct_mod   = "#" IDENT | "." IDENT | "[" construct_attr "]" ;
construct_attr  = [ ns_prefix "|" ] IDENT [ "=" expression ] ;
construct_body  = "{" [ construct_children ] "}" ;
construct_children = construct_child { "," construct_child } | "text" ":" expression ;
construct_child = expression ;
ns_prefix       = IDENT ;

if expressions

if provides multi-branch conditionals. Each branch has a parenthesized condition and a braced body. The whole construct is an expression — it evaluates to the value of the taken branch.

if (condition) { body } else if (condition) { body } else { body }

Conditions are evaluated top-to-bottom. The first truthy condition causes its body to execute. No subsequent conditions or bodies are evaluated.

if (status == "active") {
  new span.badge-green { text: "Active" }
} else if (status == "pending") {
  new span.badge-yellow { text: "Pending" }
} else {
  new span.badge-red { text: "Inactive" }
}

The else branch is optional. When omitted, the expression evaluates to null if no condition is truthy:

if (items.count() > 0) { "has items" }

Bodies can contain multiple statements separated by semicolons. let bindings inside a body are scoped to that body. The last expression is the return value:

if (items.count() > 0) {
  let total = items.map(i => i.value().Number()).reduce(0, (a, b) => a + b);
  new div.summary { text: "Total: " + total.String() }
}

Because if is an expression, it works anywhere a value is expected — in let bindings, as arguments, inside construction bodies:

let greeting = if (hour < 12) { "Good morning" } else { "Hello" };
new h1 { text: greeting }

For simple two-way choices, the ternary operator condition ? then : else remains idiomatic. Use if when you need multiple branches or multi-statement blocks.

Try/Catch expressions

try/catch provides error handling. The try body is evaluated; if it raises an error, the catch body runs with the error bound to the named variable.

try { expr } catch (name) { handler }

Like if, try/catch is an expression — it returns the try body's value on success, or the catch body's value on error:

try { "hello" + 42 } catch (e) { "type mismatch" }
// "type mismatch"

The catch variable is a dictionary with two keys:

Key Type Description
message String Human-readable error description
type String Error category (e.g. "TypeError", "DivisionByZero")
try { 1 / 0 } catch (e) { e.type }
// "DivisionByZero"

try { "a" + 1 } catch (e) { "Error: " + e.message }
// "Error: Cannot add String and Integer"

You can branch on the error type:

try { someVar } catch (e) {
  if (e.type == "UndefinedVariable") { "not found" } else { "unexpected" }
}

Try/catch does not intercept null propagation. Method calls on null return null without error, so ?? remains the right tool for null handling:

try { null.text() } catch (e) { "caught" }
// null — null propagation, not an error

let bindings

let binds a name to an expression for the duration of the body:

let name = expr; body

Bindings are expression-based. Each binding is evaluated in order; later bindings may reference earlier ones. The final expression is the return value.

let price = ${[itemprop="price"]} from self;
let count = ${[itemprop="quantity"]} from self;
price.first().value().Number() * count.first().value().Number()

Dictionary property assignment

Assignment sets a property on a dictionary bound to a let variable. This is how you build up dictionaries step by step.

ident.prop = expr
ident[expr] = expr
let headers = {};
headers["Content-Type"] = "text/html";
headers

Subscript access

Square bracket notation accesses a dictionary by string key or a list by integer index:

expr[expr]
headers["Content-Type"]
items[0]

Declarations

Declarations must appear before any expression in a program.

@namespace

Namespace declarations bind a prefix to a URI for use in CSS selectors:

@namespace prefix url("uri");
@namespace svg url("http://www.w3.org/2000/svg");
${svg|circle}

@schema

Schema declarations import a schema type by URL, making it available as a class name:

@schema Project url("https://example.com/Project");

let p = new Project { slug: "launch" };
p isa Project    // true

Schema declarations are required for schema-typed construction, instance methods, the Reflection API (Sessel.stored, Sessel.properties, Sessel.schemaOf), and the platform interface (Pagelove.GET, etc.).

Lambda expressions

Some methods accept a lambda: one or more parameter names, a fat arrow (=>), and a body. The parameters represent the values provided by the calling method.

Single-parameter lambdas

${li}.filter(el => el.text().Integer() > 10)
${div.item}.all(el => el.text().count() > 0)
${[itemprop="price"]}.any(el => el.value().Number() > 100)
${li}.map(item => item.text())

Multi-parameter lambdas

When a method provides additional arguments (such as an index or the source list), declare multiple parameters in parentheses:

${li}.map((el, i) => i.String() + ". " + el.text())
${li}.filter((el, i) => i < 5)
${li}.filter((el, i, list) => i < list.count() / 2)
${li}.sort((a, b) => a.text() <=> b.text())
[1, 2, 3].reduce(0, (acc, el) => acc + el)

Rest parameters

A trailing ...rest parameter collects remaining arguments as a List:

(first, ...rest) => rest.count()         // collects remaining args
(el, ...extras) => extras                // extras is [index, list] for .map()/.filter()

The primary use case is lambdas stored in variables and called from schema methods with variadic dispatch. Only one rest parameter is allowed and it must be last.

Block bodies

When a lambda needs intermediate bindings, use a block body with { }. Expressions are separated by semicolons; the last expression is the return value:

${[itemprop="product"]}.sort((a, b) => {
  let cat = a.attr("data-category") <=> b.attr("data-category");
  cat != 0 ? cat : a.attr("data-price").Number() <=> b.attr("data-price").Number()
})

Closures

Lambdas capture variables from their enclosing scope by reference. Captured variables remain accessible when the lambda is called, even if the enclosing block has completed:

let threshold = 10;
let aboveThreshold = el => el.value().Number() > threshold;
(${[itemprop="score"]} from self).filter(aboveThreshold)

Lambdas capture let bindings and self from the enclosing frame at the point of definition. Lambda parameters shadow captured variables of the same name.

Nesting

The parameter names are arbitrary. Use any name that reads clearly. Lambdas can be nested:

${div}.filter(d => ${span}.filter(s => s.attr("data-parent") == d.attr("id")).count() > 0)

Context variables

Names not defined by let are resolved from the runtime context. Available names depend on where the expression is used:

Name Type Available in
self Element Constraints, expression bindings, mutation handlers
document Element The root element of the current document (alias for self in most contexts)
prior Element or null Contextual keyword: the pre-mutation document when used after from
request.* Object Expression bindings & Liquid templates (page composition) — request.path, request.method, request.query.*, request.headers.*, and request.auth.claims.* / request.auth.username / request.auth.roles
auth.claims.* String Authorization rules only
method String Authorization rules only
path String Authorization rules only
query.* String Authorization rules only

In page composition (expression bindings and Liquid templates), request data is reached through the request object — e.g. request.auth.claims.email. The bare top-level names (auth.claims.*, method, path, query.*) are the authorization-rule spelling and are not bound in composition. For a selector-addressable view of the request — so <p:include> and r: resource bindings can pull fragments of it into a page — see the Request Document. Reading per-user request state (e.g. request.auth) marks the composed page Cache-Control: private.

auth.claims.email
method == "POST"
path.startsWith("/admin")
query.search

Authenticated identity in composition

The auth.claims.*, method, path, and query.* names above are authorization-rule context only. Referencing the bare form (e.g. auth.claims.email) in an expression binding or template raises undefined variable — it is not in scope there.

To read the authenticated user's identity during page composition (expression bindings and Liquid templates), use the request object instead:

Accessor Returns
request.auth.claims.email the authenticated user's email
request.auth.claims.name the authenticated user's display name
request.auth.claims.* any other OIDC claim
request.auth.username the authenticated user's OIDC sub

For an anonymous request these are empty (falsy) — there is no error — so a page can branch on identity. For example, an expression binding that exposes the logged-in email, and a condition that is true only when authenticated:

request.auth.claims.email
request.auth.claims.email != null

Because the result depends on who is logged in, a composed page that reads any request.auth.* member is treated as user-varying and served with Cache-Control: private (only the URL/body-derived request members are shared-cacheable).

Temporal

Temporal

Temporal types model JavaScript's Temporal API for date and time handling in Sessel. All types live under the Temporal.* namespace. They parse ISO 8601 strings, expose typed accessors, support arithmetic, convert between representations, and format output.

Type summary

Type Construction example Description
Temporal.Instant Temporal.Instant.from("2026-03-23T14:30:00Z") An exact moment in time (no timezone or calendar)
Temporal.ZonedDateTime Temporal.ZonedDateTime.from("2026-03-23T14:30:00[Europe/London]") An exact moment in a named timezone
Temporal.PlainDateTime Temporal.PlainDateTime.from("2026-03-23T14:30:00") A wall-clock date and time (no timezone)
Temporal.PlainDate Temporal.PlainDate.from("2026-03-23") A calendar date (no time, no timezone)
Temporal.PlainTime Temporal.PlainTime.from("14:30:00") A wall-clock time (no date, no timezone)
Temporal.PlainYearMonth Temporal.PlainYearMonth.from("2026-03") A year-month pair
Temporal.PlainMonthDay Temporal.PlainMonthDay.from("--03-23") A month-day pair (recurring date)
Temporal.Duration Temporal.Duration.from("P1Y2M3DT4H5M6S") A span of time

Temporal.PlainDate

A calendar date: year, month, and day, with no time component and no timezone.

Construction

Temporal.PlainDate.from("2026-03-23")
Temporal.PlainDate.from({ year: 2026, month: 3, day: 23 })

Accessors

let date = Temporal.PlainDate.from("2026-03-23");
date.year          // 2026
date.month         // 3
date.day           // 23
date.dayOfWeek     // 1 (Monday = 1, Sunday = 7, ISO 8601)
date.dayOfYear     // 82
date.weekOfYear    // 13
date.daysInMonth   // 31
date.daysInYear    // 365
date.inLeapYear    // false

Arithmetic

let date = Temporal.PlainDate.from("2026-03-23");

date.add(Temporal.Duration.from("P1M"))        // 2026-04-23
date.subtract(Temporal.Duration.from("P7D"))   // 2026-03-16
date.until(Temporal.PlainDate.from("2026-12-31"))   // Duration to end of year
date.since(Temporal.PlainDate.from("2026-01-01"))   // Duration since start of year

Field replacement

Temporal.PlainDate.from("2026-03-23").with({ day: 1 })    // 2026-03-01
Temporal.PlainDate.from("2026-03-23").with({ month: 12 }) // 2026-12-23

Conversions

let date = Temporal.PlainDate.from("2026-03-23");

date.toPlainDateTime()                                   // 2026-03-23T00:00:00
date.toPlainDateTime(Temporal.PlainTime.from("09:00"))   // 2026-03-23T09:00:00
date.toZonedDateTime("Europe/London")                    // ZonedDateTime at midnight

Comparison

Temporal.PlainDate.compare(
  Temporal.PlainDate.from("2026-01-01"),
  Temporal.PlainDate.from("2026-12-31")
)
// -1 (first is earlier), 0 (equal), or 1 (first is later)

Equality

Temporal.PlainDate.from("2026-03-23").equals(Temporal.PlainDate.from("2026-03-23"))  // true

Formatting

let date = Temporal.PlainDate.from("2026-03-23");

date.format("yyyy-MM-dd")           // "2026-03-23"
date.format("dd/MM/yyyy")           // "23/03/2026"
date.format("MMMM d, yyyy")         // "March 23, 2026"
date.toLocaleString("en-US")        // "3/23/2026"
date.toLocaleString("de-DE")        // "23.3.2026"
String(date)                        // "2026-03-23" (ISO 8601)

Testable examples

PlainDate accessors evaluated through an expression binding:

GET /temporal-ref/date-accessors.html
HTTP/1.1 200

<!DOCTYPE html>
<html>
<body>
  <div>
    <p>Date: 2026-03-23</p>
    <p>Year: 2026, Month: 3, Day: 23</p>
    <p>Day of week: 1</p>
    <p>Leap year: false</p>
  </div>
</body>
</html>

Date arithmetic and field replacement in a rendered page:

GET /temporal-ref/date-arithmetic.html
HTTP/1.1 200

<!DOCTYPE html>
<html>
<body>
  <div>
    <p>Plus one month: 2026-04-23</p>
    <p>Minus seven days: 2026-03-16</p>
    <p>First of month: 2026-03-01</p>
  </div>
</body>
</html>

Temporal.PlainTime

A wall-clock time: hour, minute, second, and sub-second precision. No date, no timezone.

Construction

Temporal.PlainTime.from("14:30:00")
Temporal.PlainTime.from("14:30:00.500")

Accessors

let t = Temporal.PlainTime.from("14:30:45.123456789");
t.hour          // 14
t.minute        // 30
t.second        // 45
t.millisecond   // 123
t.microsecond   // 456
t.nanosecond    // 789

Arithmetic

let t = Temporal.PlainTime.from("14:30:00");

t.add(Temporal.Duration.from("PT2H30M"))     // 17:00:00
t.subtract(Temporal.Duration.from("PT1H"))   // 13:30:00
t.until(Temporal.PlainTime.from("18:00:00")) // PT3H30M
t.since(Temporal.PlainTime.from("09:00:00")) // PT5H30M

Field replacement

Temporal.PlainTime.from("14:30:00").with({ hour: 9 })      // 09:30:00
Temporal.PlainTime.from("14:30:00").with({ minute: 0, second: 0 }) // 14:00:00

Conversions

let t = Temporal.PlainTime.from("14:30:00");
t.toPlainDateTime(Temporal.PlainDate.from("2026-03-23"))  // 2026-03-23T14:30:00

Comparison

Temporal.PlainTime.compare(
  Temporal.PlainTime.from("09:00:00"),
  Temporal.PlainTime.from("17:00:00")
)
// -1

Equality

Temporal.PlainTime.from("14:30:00").equals(Temporal.PlainTime.from("14:30:00"))  // true

Formatting

let t = Temporal.PlainTime.from("14:30:00");

t.format("HH:mm")               // "14:30"
t.format("h:mm a")              // "2:30 PM"
t.toLocaleString("en-US")       // "2:30:00 PM"
String(t)                       // "14:30:00"

Temporal.PlainDateTime

A wall-clock date and time. No timezone.

Construction

Temporal.PlainDateTime.from("2026-03-23T14:30:00")
Temporal.PlainDateTime.from("2026-03-23T14:30:00.500")

Accessors

let dt = Temporal.PlainDateTime.from("2026-03-23T14:30:45");
dt.year        // 2026
dt.month       // 3
dt.day         // 23
dt.hour        // 14
dt.minute      // 30
dt.second      // 45
dt.millisecond // 0
dt.microsecond // 0
dt.nanosecond  // 0

Arithmetic

let dt = Temporal.PlainDateTime.from("2026-03-23T14:30:00");

dt.add(Temporal.Duration.from("P1DT2H"))       // 2026-03-24T16:30:00
dt.subtract(Temporal.Duration.from("PT30M"))   // 2026-03-23T14:00:00
dt.until(Temporal.PlainDateTime.from("2026-03-30T09:00:00"))
dt.since(Temporal.PlainDateTime.from("2026-01-01T00:00:00"))

Field replacement

Temporal.PlainDateTime.from("2026-03-23T14:30:00").with({ hour: 9, minute: 0 })
// 2026-03-23T09:00:00

Conversions

let dt = Temporal.PlainDateTime.from("2026-03-23T14:30:00");

dt.toPlainDate()                        // 2026-03-23
dt.toPlainTime()                        // 14:30:00
dt.toZonedDateTime("America/New_York")  // ZonedDateTime with timezone applied

Comparison

Temporal.PlainDateTime.compare(
  Temporal.PlainDateTime.from("2026-03-23T09:00:00"),
  Temporal.PlainDateTime.from("2026-03-23T17:00:00")
)
// -1

Equality

Temporal.PlainDateTime.from("2026-03-23T14:30:00")
  .equals(Temporal.PlainDateTime.from("2026-03-23T14:30:00"))
// true

Formatting

let dt = Temporal.PlainDateTime.from("2026-03-23T14:30:00");

dt.format("yyyy-MM-dd HH:mm")          // "2026-03-23 14:30"
dt.format("MMMM d, yyyy 'at' h:mm a")  // "March 23, 2026 at 2:30 PM"
dt.toLocaleString("en-GB")             // "23/03/2026, 14:30:00"
String(dt)                             // "2026-03-23T14:30:00"

Temporal.Instant

An exact moment in time — a point on the UTC timeline with no calendar or timezone.

Construction

Temporal.Instant.from("2026-03-23T14:30:00Z")
Temporal.Instant.from("2026-03-23T14:30:00.500Z")
Temporal.Instant.fromEpochSeconds(1)              // 1970-01-01T00:00:01Z

Accessors

let instant = Temporal.Instant.from("1970-01-01T00:00:01Z");
instant.epochSeconds       // 1
instant.epochMilliseconds  // 1000
instant.epochMicroseconds  // 1000000
instant.epochNanoseconds   // 1000000000

Arithmetic

let instant = Temporal.Instant.from("2026-03-23T14:30:00Z");

instant.add(Temporal.Duration.from("PT1H"))
instant.subtract(Temporal.Duration.from("PT30M"))
instant.until(Temporal.Instant.from("2026-03-23T18:00:00Z"))   // PT3H30M
instant.since(Temporal.Instant.from("2026-03-23T12:00:00Z"))   // PT2H30M

Conversions

let instant = Temporal.Instant.from("2026-03-23T14:30:00Z");
instant.toZonedDateTimeISO("America/New_York")  // ZonedDateTime in Eastern time

Comparison

Temporal.Instant.compare(
  Temporal.Instant.from("2026-03-23T14:00:00Z"),
  Temporal.Instant.from("2026-03-23T15:00:00Z")
)
// -1

Equality

Temporal.Instant.from("2026-03-23T14:30:00Z")
  .equals(Temporal.Instant.from("2026-03-23T14:30:00Z"))
// true

Formatting

let instant = Temporal.Instant.from("2026-03-23T14:30:00Z");

instant.format("yyyy-MM-dd HH:mm 'UTC'")  // formats in UTC
instant.toLocaleString("en-US")
String(instant)                            // "2026-03-23T14:30:00Z"

Testable examples

Instant epoch accessors and arithmetic in a rendered page:

GET /temporal-ref/instant.html
HTTP/1.1 200

<!DOCTYPE html>
<html>
<body>
  <div>
    <p>Epoch seconds: 1</p>
    <p>Plus one hour: 2026-03-23T15:30:00Z</p>
    <p>Compare: -1</p>
  </div>
</body>
</html>

Temporal.ZonedDateTime

A moment in time in a named IANA timezone — combines an exact instant with a calendar representation.

Construction

Temporal.ZonedDateTime.from("2026-03-23T14:30:00[Europe/London]")
Temporal.ZonedDateTime.from("2026-03-23T14:30:00+05:30[Asia/Kolkata]")

Accessors

let zdt = Temporal.ZonedDateTime.from("2026-03-23T14:30:00[America/New_York]");
zdt.year         // 2026
zdt.month        // 3
zdt.day          // 23
zdt.hour         // 14
zdt.minute       // 30
zdt.second       // 0
zdt.millisecond  // 0
zdt.microsecond  // 0
zdt.nanosecond   // 0
zdt.timeZoneId   // "America/New_York"
zdt.offset       // "-04:00" (or "-05:00" depending on DST)

Arithmetic

let zdt = Temporal.ZonedDateTime.from("2026-03-23T14:30:00[America/New_York]");

zdt.add(Temporal.Duration.from("P1DT2H"))
zdt.subtract(Temporal.Duration.from("PT1H"))
zdt.until(Temporal.ZonedDateTime.from("2026-04-01T00:00:00[America/New_York]"))
zdt.since(Temporal.ZonedDateTime.from("2026-01-01T00:00:00[America/New_York]"))

Field replacement

Temporal.ZonedDateTime.from("2026-03-23T14:30:00[America/New_York]")
  .with({ hour: 9, minute: 0 })
// 2026-03-23T09:00:00[America/New_York]

Temporal.ZonedDateTime.from("2026-03-23T14:30:00[America/New_York]")
  .with({ timeZone: "Europe/London" })
// re-interprets the wall clock time in the new timezone

Conversions

let zdt = Temporal.ZonedDateTime.from("2026-03-23T14:30:00[America/New_York]");

zdt.toInstant()                          // the underlying Instant
zdt.toPlainDate()                        // 2026-03-23
zdt.toPlainTime()                        // 14:30:00
zdt.toPlainDateTime()                    // 2026-03-23T14:30:00
zdt.withTimeZone("Europe/London")        // same instant, different timezone

Comparison

Temporal.ZonedDateTime.compare(
  Temporal.ZonedDateTime.from("2026-03-23T09:00:00[America/New_York]"),
  Temporal.ZonedDateTime.from("2026-03-23T14:00:00[Europe/London]")
)
// compares the underlying instants

Equality

ZonedDateTime.equals() requires both the same instant AND the same timezone:

let a = Temporal.ZonedDateTime.from("2026-03-23T14:30:00[America/New_York]");
let b = Temporal.ZonedDateTime.from("2026-03-23T19:30:00[Europe/London]");
a.equals(b)  // false — different timezones, even though same instant

Formatting

let zdt = Temporal.ZonedDateTime.from("2026-03-23T14:30:00[America/New_York]");

zdt.format("yyyy-MM-dd HH:mm z")   // "2026-03-23 14:30 EDT"
zdt.toLocaleString("en-US")
String(zdt)                         // "2026-03-23T14:30:00-04:00[America/New_York]"

Testable examples

ZonedDateTime timezone conversion in a rendered page:

GET /temporal-ref/zoned.html
HTTP/1.1 200

<!DOCTYPE html>
<html>
<body>
  <div>
    <p>Timezone: UTC</p>
    <p>New York hour: 6</p>
    <p>Date: 2026-03-23</p>
  </div>
</body>
</html>

Temporal.Duration

A span of time with both calendar components (years, months, weeks, days) and time components (hours, minutes, seconds, sub-seconds).

Construction

ISO 8601 duration format: P<date>T<time>. The P prefix is required. The T separator is required if any time component is present.

Temporal.Duration.from("P1Y2M3DT4H5M6S")   // 1 year, 2 months, 3 days, 4 hours, 5 minutes, 6 seconds
Temporal.Duration.from("P1M")               // 1 month
Temporal.Duration.from("PT30M")             // 30 minutes
Temporal.Duration.from("P7D")               // 7 days
Temporal.Duration.from("-PT1H")             // negative 1 hour

Map construction:

Temporal.Duration.from({ years: 1, months: 6 })
Temporal.Duration.from({ hours: 2, minutes: 30 })
Temporal.Duration.from({ days: 7 })

Accessors

let dur = Temporal.Duration.from("P1Y2M3DT4H5M6S");
dur.years        // 1
dur.months       // 2
dur.weeks        // 0
dur.days         // 3
dur.hours        // 4
dur.minutes      // 5
dur.seconds      // 6
dur.milliseconds // 0
dur.microseconds // 0
dur.nanoseconds  // 0
dur.sign         // 1 (positive), -1 (negative), or 0 (zero)
dur.blank        // false (true only when all components are zero)

Field replacement

Temporal.Duration.from("P1Y2M3DT4H5M6S").with({ years: 2, hours: 0 })
// P2Y2M3DT5M6S

.with() takes a map of the same keys as map construction above (years, months, weeks, days, hours, minutes, seconds, milliseconds, microseconds, nanoseconds) and replaces only the given components, leaving the rest unchanged.

Calendar arithmetic and relativeTo

Operations involving years, months, or weeks are calendar-relative — the number of days in a month varies. These operations require a relativeTo argument providing a reference date:

let oneMonth = Temporal.Duration.from("P1M");

// Add two durations with calendar components — need relativeTo
oneMonth.add(
  Temporal.Duration.from("P1M"),
  Temporal.PlainDate.from("2026-01-31")   // relativeTo
)
// P2M (relative to Jan 31, gives Mar 31)

// total() converts to a specific unit — always needs relativeTo for calendar units
Temporal.Duration.from("P1Y").total("days", Temporal.PlainDate.from("2026-01-01"))
// 365

Pure time durations (hours, minutes, seconds) do not need relativeTo:

Temporal.Duration.from("PT2H").add(Temporal.Duration.from("PT30M"))
// PT2H30M

Negation and absolute value

Temporal.Duration.from("P1M").negated()    // -P1M
Temporal.Duration.from("-PT2H").abs()      // PT2H
Temporal.Duration.from("-P1M").sign        // -1

Equality

Temporal.Duration.from("P1M").equals(Temporal.Duration.from("P1M"))   // true
Temporal.Duration.from("P30D").equals(Temporal.Duration.from("P1M"))  // false (different components)

Testable examples

Duration accessors and negation in a rendered page:

GET /temporal-ref/duration.html
HTTP/1.1 200

<!DOCTYPE html>
<html>
<body>
  <div>
    <p>Duration: P1Y2M3DT4H5M6S</p>
    <p>Years: 1</p>
    <p>Sign: 1</p>
    <p>Negated: -PT1H</p>
    <p>Blank: true</p>
  </div>
</body>
</html>

Temporal.PlainYearMonth

A year and month without a day. Useful for billing periods, monthly reports, or month-level comparisons.

Construction

Temporal.PlainYearMonth.from("2026-03")
Temporal.PlainYearMonth.from({ year: 2026, month: 3 })

Accessors

let ym = Temporal.PlainYearMonth.from("2026-03");
ym.year         // 2026
ym.month        // 3
ym.daysInMonth  // 31
ym.daysInYear   // 365
ym.inLeapYear   // false

Arithmetic

let ym = Temporal.PlainYearMonth.from("2026-03");

ym.add(Temporal.Duration.from("P3M"))              // 2026-06
ym.subtract(Temporal.Duration.from("P1Y"))         // 2025-03
ym.until(Temporal.PlainYearMonth.from("2027-01"))  // P10M
ym.since(Temporal.PlainYearMonth.from("2026-01"))  // P2M

Field replacement

Temporal.PlainYearMonth.from("2026-03").with({ month: 12 })  // 2026-12

Conversions

Temporal.PlainYearMonth.from("2026-03").toPlainDate(15)  // 2026-03-15

Comparison

Temporal.PlainYearMonth.compare(
  Temporal.PlainYearMonth.from("2026-01"),
  Temporal.PlainYearMonth.from("2026-06")
)
// -1

Equality

Temporal.PlainYearMonth.from("2026-03").equals(Temporal.PlainYearMonth.from("2026-03"))  // true

Formatting

Temporal.PlainYearMonth.from("2026-03").format("MMMM yyyy")   // "March 2026"
Temporal.PlainYearMonth.from("2026-03").toLocaleString("en-US")
String(Temporal.PlainYearMonth.from("2026-03"))                // "2026-03"

Temporal.PlainMonthDay

A month and day without a year. Useful for recurring dates such as birthdays or anniversaries.

Construction

ISO 8601 month-day format: --MM-DD.

Temporal.PlainMonthDay.from("--03-23")
Temporal.PlainMonthDay.from({ month: 3, day: 23 })

Accessors

let md = Temporal.PlainMonthDay.from("--03-23");
md.month  // 3
md.day    // 23

Field replacement

Temporal.PlainMonthDay.from("--03-23").with({ day: 1 })   // --03-01

Conversions

Temporal.PlainMonthDay.from("--03-23").toPlainDate(2026)  // 2026-03-23

Equality

Temporal.PlainMonthDay.from("--03-23").equals(Temporal.PlainMonthDay.from("--03-23"))  // true

Formatting

Temporal.PlainMonthDay.from("--03-23").format("MMMM d")       // "March 23"
Temporal.PlainMonthDay.from("--03-23").toLocaleString("en-US")
String(Temporal.PlainMonthDay.from("--03-23"))                  // "--03-23"

Temporal.Now

Temporal.Now provides current date/time values. These are non-deterministic — calls return different values on each evaluation.

Temporal.Now.instant()            // current Instant (UTC timestamp)
Temporal.Now.zonedDateTimeISO()   // ZonedDateTime in the system timezone
Temporal.Now.zonedDateTimeISO("America/New_York")  // ZonedDateTime in a specific timezone
Temporal.Now.plainDateISO()       // PlainDate in the system timezone
Temporal.Now.plainDateISO("Europe/London")
Temporal.Now.plainTimeISO()       // PlainTime in the system timezone
Temporal.Now.plainTimeISO("Asia/Tokyo")
Temporal.Now.plainDateTimeISO()   // PlainDateTime in the system timezone
Temporal.Now.plainDateTimeISO("Australia/Sydney")

Because Temporal.Now is non-deterministic, avoid it in constraint expressions where repeatable evaluation is required. Use it in mutation handlers and response expressions where the current time at execution is meaningful.


String coercion

String(value) on any Temporal value returns its ISO 8601 canonical form:

String(Temporal.PlainDate.from("2026-03-23"))       // "2026-03-23"
String(Temporal.PlainTime.from("14:30:00"))         // "14:30:00"
String(Temporal.PlainDateTime.from("2026-03-23T14:30:00"))  // "2026-03-23T14:30:00"
String(Temporal.Instant.from("2026-03-23T14:30:00Z"))       // "2026-03-23T14:30:00Z"
String(Temporal.Duration.from("P1Y2M3D"))           // "P1Y2M3D"
String(Temporal.PlainYearMonth.from("2026-03"))     // "2026-03"
String(Temporal.PlainMonthDay.from("--03-23"))      // "--03-23"

.toString() is equivalent to String(value).


Type checking

The isa operator checks whether a value belongs to a Temporal type:

let date = Temporal.PlainDate.from("2026-03-23");
date isa Temporal.PlainDate      // true
date isa Temporal.PlainDateTime  // false
date isa Temporal               // true (any Temporal type)

Formatting patterns

.format(pattern) accepts CLDR-style patterns:

Token Meaning Example
yyyy 4-digit year 2026
yy 2-digit year 26
MMMM Full month name March
MMM Short month name Mar
MM 2-digit month 03
M Month number 3
dd 2-digit day 23
d Day number 23
HH 24-hour hour (0-padded) 14
H 24-hour hour 14
hh 12-hour hour (0-padded) 02
h 12-hour hour 2
mm Minutes (0-padded) 30
ss Seconds (0-padded) 00
a AM/PM PM
'...' Literal text 'at'at

Testable examples

Pattern-based formatting with .format() in a rendered page:

GET /temporal-ref/formatting.html
HTTP/1.1 200

<!DOCTYPE html>
<html>
<body>
  <div>
    <p>ISO: 2026-03-23</p>
    <p>European: 23/03/2026</p>
    <p>Long: March 23, 2026</p>
    <p>12-hour time: 2:30 PM</p>
  </div>
</body>
</html>

.toLocaleString(locale) uses ICU4X locale-aware formatting:

Temporal.PlainDate.from("2026-03-23").toLocaleString("en-US")   // "3/23/2026"
Temporal.PlainDate.from("2026-03-23").toLocaleString("de-DE")   // "23.3.2026"
Temporal.PlainDate.from("2026-03-23").toLocaleString("ja-JP")   // "2026/3/23"

See also