Sessel
← All sections · part of the machine-readable /all/ index.
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.
- Querying Elements -- selectors, data extraction, the
from clause, and the self and prior context variables.
- Working with Collections -- counting, filtering, mapping, aggregation, sorting, and null handling.
- Constructing HTML -- building elements with
new, CSS shorthand for classes and IDs, attributes, children, and setter methods.
- 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.
- Types -- the full type system: Number, String, Boolean, Null, List, Dictionary, Element, Temporal, Instance, Class, and Selector.
- Number -- integer and decimal arithmetic, comparison operators, coercion, rounding, and random generation.
- String -- concatenation, interpolation, substring methods, regex matching, cryptographic hashing, and random generation.
- List -- access, aggregation, transformation, validation, ordering, joining, and reduction.
- Dictionary -- literal syntax, dot and subscript access, property assignment, and methods like
.merge() and .entries().
- Element -- queried vs. constructed elements, getter and setter methods, provenance, sub-select, cloning, and mutation.
- Syntax -- complete grammar, operator precedence,
let bindings, if expressions, lambdas, destructuring, try/catch, and from clauses.
- Temporal -- date and time types modeled on JavaScript's
Temporal API, with arithmetic, formatting, and timezone support.
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
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.
Once you have a list, .first() gives you the first element. From there, three methods let you read its content:
.text() — the text content of the element (all descendant text nodes joined, no HTML markup)
.value() — the microdata value, following WHATWG rules (for most elements this is text content, but <a> returns its href, <img> its src, <time> its datetime, and so on)
.attr(name) — the value of a named attribute
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:
.path() — the document path the element was found in
.document() — the document element (root) of the source document
.selector() — the CSS selector that matched this element
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:
- Element — all element methods, including setters for constructing HTML
- Syntax — complete grammar, operator precedence, and all language constructs
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:
- Queried elements — obtained via a CSS selector expression such as
${h1}.first(). These are immutable snapshots from the document store.
- Constructed elements — built with
new p { text: "Hello" }. These are mutable and can be modified with setter methods before use.
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?) — find all instances of this type across the document store.
.construct(properties) — create a new instance programmatically. Equivalent to new UserConfig { ... } but works when the class is held in a variable.
- Static methods — user-defined methods marked
static: true on the schema are called on the class object rather than on instances.
.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
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:
.first() — the first element, or null if the list is empty
.last() — the last element, or null if the list is empty
.at(n) — the element at position n (0-indexed), or null if n is out of range
.slice(start, end) — a sub-list from position start up to (but not including) position end
${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()
.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:
.all(el => predicate) — returns true if the predicate is true for every element; returns true for an empty list
.any(el => predicate) — returns true if the predicate is true for at least one element; returns false for an empty list
${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:
.sum() — adds all element values together, parsing each as a number
.min() — returns the smallest numeric value, or null if none are numeric
.max() — returns the largest numeric value, or null if none are numeric
${[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 values (compared by text representation) and returns a new list, preserving first-occurrence order
.subset(other) — true if every value in this list also appears in the other list
.disjoint(other) — true if this list and the other list share no common 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
- Constructing HTML — the next guide in the sequence: building new elements from Sessel expressions
- List — complete reference for every list method, including sorting, flattening, and joining
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.14 → 3 |
.Float() |
Parses as float: "42" → 42.0 |
Widens: 42 → 42.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
- String — string type and coercion methods
- Types — overview of all Sessel types and their methods
- Syntax — operators and operator precedence
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:
.text(expr) — sets the text content of the element
.value(expr) — sets the microdata value, using the appropriate attribute for the tag (href for <a>, src for <img>, datetime for <time>, content for <meta>, and text content for everything else)
.attr(name, expr) — sets a named attribute to the result of expr
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:
${[itemprop="name"]}.first().text() — the product name as text
${[itemprop="price"]}.first().value() — the price via microdata rules (the value attribute of a <data> element, or text content of a <span>)
${[itemprop="url"]}.first().value() — the URL via microdata rules (the href of an <a>, the content of a <meta>, etc.)
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:
- Element — all element methods, including the full setter API
- Syntax — complete grammar and operator precedence
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
- Types — overview of all Sessel types and their methods
- Syntax & Operators — operators, including
+ for concatenation
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:
- The
@namespace declaration makes p| available for Pagelove attributes.
let products captures the list of product elements once.
let count derives a number from that list.
let header builds an <h2> element using string concatenation.
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>.
- 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:
- Types — full type system, coercion rules, and operator semantics
- Dictionary — all dictionary methods, including
.entries() and .contains()
- List — all list methods, including sorting, flattening, joining, and the full set of aggregation methods
- Syntax — complete grammar, operator precedence,
@namespace, let, and every language construct
- Expression Binding — binding computed values into templates with Sessel (
e:) or JavaScript (j:) expressions
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
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
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
- Types — overview of all Sessel types
- List — ordered collections
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 }
tag — required element tag name
.class — shorthand for class="..."
#id — shorthand for id="..."
[attr="literal"] — attribute with a string literal value
[attr=expression] — attribute with a Sessel expression as value
[boolean-attr] — boolean attribute (present, no value)
{ children } — inline child content (text or nested elements)
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
- List — element lists from selectors
- Constructing HTML — full tutorial for building elements
- Syntax — complete grammar including construction syntax
- Types — overview of all Sessel types and their methods
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:
- The tag name, class, id, and attribute modifiers describe the element to create.
- Attribute values: quoted strings are literal, unquoted expressions are evaluated, no
= means a boolean attribute.
- The body
{ } contains either text: expression for text content, or a comma-separated list of child expressions.
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 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
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
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
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
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
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
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
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)
.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
- Types — overview of all Sessel types
- Syntax — operators and operator precedence