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 eithertext: expressionfor 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).