formaui-net.DOMQL 1.0.0-preview.2

Prefix Reserved
This is a prerelease version of formaui-net.DOMQL.
dotnet add package formaui-net.DOMQL --version 1.0.0-preview.2
                    
NuGet\Install-Package formaui-net.DOMQL -Version 1.0.0-preview.2
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="formaui-net.DOMQL" Version="1.0.0-preview.2" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="formaui-net.DOMQL" Version="1.0.0-preview.2" />
                    
Directory.Packages.props
<PackageReference Include="formaui-net.DOMQL" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add formaui-net.DOMQL --version 1.0.0-preview.2
                    
#r "nuget: formaui-net.DOMQL, 1.0.0-preview.2"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package formaui-net.DOMQL@1.0.0-preview.2
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=formaui-net.DOMQL&version=1.0.0-preview.2&prerelease
                    
Install as a Cake Addin
#tool nuget:?package=formaui-net.DOMQL&version=1.0.0-preview.2&prerelease
                    
Install as a Cake Tool

DOMQL library guide

A declarative approach to managing the DOM.

DOMQL is a JavaScript library for querying the DOM, allowing you to read information from a document once or observe it as it changes. This guide takes you from your first query to watching results, handling events and extending the library. Its queries are written in Nexo.

Preview: no breaking redesign is planned, and a version that breaks what the one before it allowed says how to migrate. formaui-net.DOMQL is published to NuGet as a pre-release, which you install by name. The package distributes a JavaScript ES module; the API in this guide runs in JavaScript. Every example here runs on this preview. The runtime, 1.0.0-preview.2, implements DOMQL's specification 1.0.0-preview.1 on Nexo's language 1.0.0-preview.1, definition version 38. The status overview says what is implemented and what is being built.

In this guide

Install and load

From your application's project directory, add the package from nuget.org. It's a pre-release, so dotnet add package needs --prerelease:

dotnet add package formaui-net.DOMQL --prerelease

To try a change before it's released, build the package yourself:

  1. Run ./build.ps1 -Full in the repository. It writes the package to artifacts/, verified as an application installs it. The repository setup instructions list the prerequisites.
  2. Add it from there, adjusting the path to your artifacts/ directory: dotnet add package formaui-net.DOMQL --prerelease --source ./artifacts.

In a .NET web application hosted at the site root, import DOMQL from a JavaScript module:

import { domql } from '/_content/domql/domql.js';

If your application uses a URL subpath, include it before /_content/. You can also serve the generated bundle directly in a non-.NET application.

TypeScript

The package includes domql.d.ts beside domql.js, covering the query API, returned handles, errors and module declarations. Make those declarations available to your TypeScript project when importing the bundle by its path.

You can supply a result type when creating a query:

import { domql } from '/_content/domql/domql.js';

const panel = document.getElementById('panel');

if (panel === null) {
    throw new Error('The example needs an element with id="panel".');
}

const count = domql.query<number>('panel.children.count', { panel }).evaluate();

Evaluate a query

The examples use this panel. Run them after its elements exist, with the markup rendered without additional styling:

<div id="panel">
    <div data-key="a1" data-unread="2" style="height: 48px"></div>
    <div data-key="a2" data-unread="5" style="height: 64px" aria-selected="true"></div>
    <div data-key="a3" style="height: 48px"></div>
</div>

Read its child count and the key and selection state of each item:

const panel = document.getElementById('panel');

const query = domql.query(`
    panel {
        count = it.children.count,
        items = it.all("[data-key]") {
            key = it["data-key"],
            selected = it.aria["selected"] == "true"
        }
    }
`, { panel });

const snapshot = query.evaluate();

The result is:

{
    "count": 3,
    "items": [
        { "key": "a1", "selected": false },
        { "key": "a2", "selected": true },
        { "key": "a3", "selected": false }
    ]
}

Three parts of the query are worth understanding first:

  • panel reads the binding supplied in { panel }. It is not an ID lookup. A name reads a local of that name where one is in scope, and otherwise the binding of that name.
  • An object expression chooses output fields. Here, the outer result has count and items.
  • An object expression after a list runs for each item. Inside it, it refers to that item.

domql.query creates the query without reading the DOM. evaluate() resolves it as needed and reads the current values synchronously. The returned snapshot is immutable and contains no live element references.

Evaluate again or bind another target

Call evaluate() again to read the current state. Earlier snapshots remain unchanged. DOMQL reuses parsed text and compatible resolutions, but reads DOM values again.

Use with to create a query with different bindings:

const countQuery = domql.query('panel.children.count', { panel });

countQuery.evaluate();
// 3

const emptyPanel = document.createElement('div');
const rebound = countQuery.with({ panel: emptyPanel });

rebound.evaluate();
// 0

with leaves the original query and its running watches unchanged.

Reuse a definition

query.definition contains the immutable query structure without bindings. Pass it to domql.query(definition, bindings) to create another query, or store it as JSON for later use.

A query object belongs to the DOMQL instance that created it. If independently bundled libraries each contain DOMQL, pass the definition and a plain bindings object between them:

const localQuery = domql.query(otherQuery.definition, { panel });

The receiving instance validates the definition against its own vocabulary. Modules registered only in the other instance are not available to it.

Wait for a browser observation

Some values need an initial sample from a browser observer, such as whether an element is in view, which intersects reports. Count the items the viewport shows, as a lazy loader or an impression tracker needs, and wait for the browser's first report on each:

const visibleItems = await domql.query(
    'panel.all("[data-key]").where(it.intersects()).count',
    { panel }
).evaluateAsync();
// 3: every item of the example panel is in view

DOMQL starts one intersection observation for each item and waits until each has reported. Synchronous evaluate() rejects queries that require an observation sample.

evaluateAsync() also accepts ordinary queries. Pass { signal } to cancel a pending evaluation. Observations started for the evaluation are released when it completes, fails or is canceled.

Watch a query

Use watch() when you want updates as the result changes. It returns a handle that you subscribe to and later dispose.

Keep a toolbar in step with the panel's selection, whichever code selects an item:

const selection = domql.query(`
    panel.children
        .where(it.aria["selected"] == "true") {
            key = it["data-key"]
        }
`, { panel }).watch();

selection.subscribe('change', (sender, args) => {
    if (args.status === 'failed') {
        console.error('The query could not be evaluated.');
        return;
    }

    console.log(args.value.map(item => item.key));
});

await selection.refreshAsync();
// ['a2']

// For this example only: your app, a UI library or the user changes the page; DOMQL only observes it.
panel.children[0].setAttribute('aria-selected', 'true');
await selection.refreshAsync();
// ['a1', 'a2']

panel.children[0].removeAttribute('aria-selected');
await selection.refreshAsync();
// ['a2']

selection.dispose();

The query says what the toolbar needs, and the watch works out what to observe: the panel's children and the two attributes it reads of each, an item added included. It reads the attribute rather than matches, so every change it depends on is observed and the watch needs no option, as observation limits explains. The listener receives an initial snapshot and then a new one when the result changes, never one equal to the last. Handlers receive (sender, args), where sender is the watch handle.

A watch responds automatically to observed changes, normally at the next animation frame. The example calls refreshAsync() to make each step explicit: it evaluates immediately and resolves after listeners have received the result. Use schedule: 'immediate' to evaluate without waiting for the next frame: the first invalidation queues an evaluation in a microtask, and invalidations received before that evaluation begins share it, while resizes the browser reports one element at a time evaluate once for each, which the frame schedule batches.

Call dispose() when you no longer need updates. It stops the watch's listeners and releases its observations.

Observation limits

A watch can only respond to changes reported by its members' observations. Some members have gaps: matches misses pointer state such as :hover, and rect can miss position changes that none of its observations detect.

Watching a partly observable member requires an explicit option:

const itemWatch = query.watch({
    acceptPartialObservation: true
});

Without that option, DOMQL rejects the watch and identifies the members and changes it cannot fully observe. A missed change appears in the next evaluation, whether triggered by another observed change or by refreshAsync(). Dispose of itemWatch when you finish using it.

Failures and selected results

A failed evaluation is delivered as a change event with status: 'failed'. A handler failure is reported through the watch's error event. Neither stops the watch. Inspect watch.status, watch.snapshot and watch.error for its current state.

For an object result, watch.select('items') creates a view of that field and shares the existing watch's evaluation. Use query.select('items') before starting an operation when you want that operation to return only the selected part.

Take new samples

A member such as intersects reads the browser's latest report, which arrives only after the browser lays out its next frame. A refresh right after a load therefore reads where the last item was before the load. Pass resample: true to have every such observation take a new sample first:

const sentinel = domql.query('{ nearEnd = panel.children.last?.intersects() }', { panel }).watch();

await sentinel.readyAsync();

// After a load appends items, take where the browser reports the last one now.
await sentinel.refreshAsync({ resample: true });
console.log(sentinel.snapshot);
// { nearEnd: true }

sentinel.dispose();

The refresh evaluates once every new sample has arrived. Like any refresh, it raises no event where the result is unchanged, so read snapshot when you need the result whatever it is. Pass { signal } to stop waiting.

Write to the elements a result came from

A result holds data alone, never an element. A handler in the browser that writes to the element an item came from asks the watch for it with sourceOf(...path), the item's path in the snapshot:

const unreadItems = domql.query('panel.children.where(it["data-unread"] != null) { count = it["data-unread"] }', { panel }).watch();

unreadItems.subscribe('change', (sender, args) => {
    args.value.forEach((item, index) => {
        sender.sourceOf(index).title = `${item.count} unread`;
    });
});

await unreadItems.refreshAsync();
console.log(panel.children[1].title);
// '5 unread'

unreadItems.dispose();

sourceOf answers the element each item of a list was projected from, through a filter or an ordering too, and null for an item that came from data. It reads the watch's current snapshot, so call it before the handler awaits anything.

Wait for a burst of changes to end

A costly query, such as one that serializes an editor's content, should run once a burst of changes ends, not at every keystroke. Schedule its watch as delayed: it evaluates once its delay has passed with no change, and refreshAsync() evaluates at once, as when the editor loses focus:

const settled = domql.query('panel.children.where(it["data-unread"] != null).count', { panel }).watch({ schedule: 'delayed', delay: 300 });

settled.subscribe('change', (sender, args) => console.log(args.value));
await settled.readyAsync();
// 2

// For this example only: the page changes; the watch evaluates 300 ms after the last change, or at once when refreshed.
panel.children[2].setAttribute('data-unread', '1');
await settled.refreshAsync();
// 3

settled.dispose();
panel.children[2].removeAttribute('data-unread');

Two more options shape what a watch reports. reportOn: ['selection'] reports a snapshot, whole, only where one of the parts it names changed. A subscription takes throttle: 100, which hands its handler at most one event every 100 ms, the latest of those heard meanwhile.

Live state

By default, each update is an immutable snapshot. Use updateStrategy: 'liveState' when you want one object updated in place.

Live state suits a view that patches what changed rather than rendering everything again. This example uses a list of its own and can be run independently:

const list = document.createElement('ul');

list.innerHTML = '<li data-key="b1">Apples</li><li data-key="b2">Pears</li>';

const live = domql.query(`
    list.children {
        key = it["data-key"],
        selected = it.aria["selected"] == "true"
    }
`, { list }).watch({ updateStrategy: 'liveState' });

live.subscribe('change', (sender, args) => {
    if (args.status === 'failed') {
        console.error('The query could not be evaluated.');
        return;
    }

    console.log(args.changes);
});

await live.refreshAsync();
// []

const pears = live.liveState[1];

// For this example only: your app, a UI library or the user changes the page; DOMQL only observes it.
list.children[1].setAttribute('aria-selected', 'true');
await live.refreshAsync();
// [{ op: 'replace', path: '/1/selected', value: true }]

console.log(live.liveState[1] === pears);
// true

live.dispose();

The first event is the baseline, with no changes. Selecting the pears changes one field, so the change set holds that one replacement, and the object for the pears is the same object, now selected.

The current object is available as watch.liveState and as args.liveState in change events. args.changes contains the applied change set. Objects and lists retain their identity; in a list projected from elements, an item's object follows the element when it moves.

Treat live state as read-only: the watch owns and updates it. Use snapshots or copy the data if you need to retain a previous version. Live state requires a non-null object or list result.

Subscribe to events

An event subscription turns each event into a record of what happened and the state around it at that moment. Record which item a click activated, whether it was already selected, and how many items were selected then:

const activations = domql.query(`
    panel.on("click") {
        let item = it.target?.closest("[data-key]"),

        key = item?["data-key"],
        wasSelected = item?.aria["selected"] == "true",
        selectedCount = panel.all("[aria-selected=true]").count
    }
`, { panel }).subscribe((sender, args) => {
    if (args.status === 'failed') {
        console.error('The event could not be projected.');
        return;
    }

    console.log(args.value);
});

// For this example only: the user clicks the second item; DOMQL only listens.
panel.querySelector('[data-key="a2"]').click();
// { key: 'a2', wasSelected: true, selectedCount: 1 }

activations.dispose();

Each record is plain data read as the event was dispatched, holding no event object and no element, so it can go straight into application state, a log or a message to a worker, and a handler that runs later still sees the state of that moment. The query is checked when you subscribe, so a misspelled member fails then rather than at the first click.

The object expression reads event fields such as target, button, clientX, pointerId, a key's key and repeat, the modifiers, ctrlKey, metaKey, altKey and shiftKey, and every event's timeStamp, defaultPrevented and composedPath, and anything else the bindings reach; the specification lists the event types and their members. The subscription does not change the event's propagation or default behavior. To hear an event before any listener nearer its target can stop it, listen in the capture phase, Domql.document.on("click", { capture = true }).

Listening starts during subscribe() and ends with dispose(). A failed projection reaches the handler with status: 'failed'; a handler failure goes to the window's error reporting. The subscription continues in either case.

Event projections do not wait for browser observations. A maintained value such as intersects is null until its first sample arrives.

React inside the event

Some changes must happen while the browser still dispatches the event: writing the clipboard, sharing and playing need the user's activation, and selecting a field's text as it takes focus must come before anything else runs. A caller across a boundary, as a .NET host, can't answer in time. Give the subscription its reactions instead, once executions it carries out as it hears each event, before calling the handler:

const announce = domql.query('panel.dispatch({ type = "chosen", detail = event.key })', { panel });
let reacted;

const choices = domql.query('panel.on("click") { key = it.target?["data-key"] }', { panel }).subscribe((sender, args) => {
    console.log(args.value);
    reacted = args.reactions[0];
}, { reactions: [announce] });

panel.addEventListener('chosen', event => console.log(event.detail), { once: true });

// For this example only: the user clicks the third item.
panel.querySelector('[data-key="a3"]').click();
// 'a3': the reaction, inside the click
// { key: 'a3' }: then the handler

console.log(await reacted);
// false: no listener cancelled the event the reaction dispatched

choices.dispose();

Each reaction is bound to its own bindings and event, the event's projected value, and carried out for the subscription's owner. The handler receives a promise of each reaction's result as args.reactions, in order, each settling after the handler was called, so forwarding the event and then each result keeps every result after the event that triggered it. An event whose projection fails is reacted to by none.

The language

Each code block in this section shows a separate query or JavaScript example. Examples that use panel refer to the original three-item markup above.

Read properties and call members

Use a dot to read a property:

panel.children.count

Members that take input use the form declared by the vocabulary. all is a call with parentheses; this query counts its matches:

panel.all("[data-key]").count

matches is a call too, which tests an element against a selector, for a question only a selector asks, such as where the element stands among its siblings:

button.matches(".toolbar > button:first-child")

A named indexer reads a value of its receiver by a key, written after its name in brackets. attribute reads an attribute, style a computed style, and class whether the element has a class; the window's media reads whether a media query matches, which a watch follows as it begins and ceases to:

panel.attribute["data-key"]
panel.style["display"]
panel.class["open"]
Domql.window.media["(min-width: 40em)"]

The key can be any expression of the type the indexer takes, such as a binding, panel.attribute[name]. An element's unnamed indexer reads an attribute as well, so panel["data-key"] reads the attribute too, and a list's reads an item, so panel.children[0] is its first child. Use ?[ when the receiver can be null: panel.first(".row")?["data-key"] is null when there is no row, and its key is not evaluated.

All DOMQL names are case-sensitive and use names such as clientSize, rectRelativeTo and devicePixelRatio. Any word names a field, a local, a function, a parameter or a member, a keyword, true, null, it, root and DOMQL's module name, Domql, among them, as { end = selection.end } and record.then do. Where a value stands, such a word keeps its own meaning, so a local or a field it names is read as @end, and a caller's binding it names as ~end. Strings passed to browser APIs, including selectors and attribute names, follow the browser's rules. A string escapes \", \\, \n, \r, \t and \e, the escape character, and names any character by its code point with \u{…}, as "\u{A0}" writes a no-break space, which is hard to tell from a space when written as itself. A raw string, in single quotes, keeps its backslashes as text, as 'C:\Users\Eyal\' and '\d{3}-\d{2}' do, and doubles its quote to hold one, 'It''s ready'; $'…' interpolates one as $"…" does a string. An inline string stands on one line; for text on lines of its own, write a block, three or more quotes and a line break opening it and as many closing it first on a later line, whose indentation is removed from every line: """, ''', $""" or $'''.

Calls take their arguments by position, separated by commas, as many as the member's signature declares. When a member takes options, pass one object whose fields supply its parameters; like any object expression, its fields are evaluated once each, in the order written, and a later one reads an earlier one by its name. This query checks for an intersection with a margin around the panel and needs evaluateAsync():

sentinel.intersects({ root = panel, margin = 200 })

A call whose receiver can be null is written with ?., which skips the call, its argument included, and answers null where the receiver is null. This asks whether the nearest group is open, and is false where there is none:

it.closest("[data-group]")?.matches(".open") ?? false

Using the wrong call form produces an error that shows the expected form.

Choose output fields

An object expression gives each result field a name:

domql.query(`
    panel {
        count = it.children.count,
        attached = it.attached,
        hasChildren = it.hasChildren
    }
`, { panel }).evaluate();
// { count: 3, attached: true, hasChildren: true }

Every output field needs an explicit name. Separate fields with commas; a trailing comma is allowed. Queries also support // line comments and /* */ block comments.

Raw DOM nodes cannot appear in a result. For example, panel.children reads a list of elements but cannot be returned by evaluate() as data. Project the properties you need instead.

Project and filter lists

An object expression after a list runs for each item, with it referring to that item:

domql.query(`
    panel.all("[data-key]") {
        key = it["data-key"],
        selected = it.aria["selected"] == "true"
    }
`, { panel }).evaluate();
// [
//   { key: 'a1', selected: false },
//   { key: 'a2', selected: true },
//   { key: 'a3', selected: false }
// ]

A list keeps the order of its source, here the document's, and neither projecting nor filtering it reorders it.

A member after a list operates on the list itself. Use count, first, last and its unnamed indexer, [index], as it.children[2] for the third child, to inspect it. Use where to keep items whose expression is truthy:

domql.query(`
    panel.all("[data-key]")
        .where(it.aria["selected"] == "true")
        .count
`, { panel }).evaluate();
// 1

Summarize a collection

max, min and sum evaluate an expression for each item. This reads the tallest item's border-box height in CSS pixels:

domql.query(
    'panel.all("[data-key]").max(it.rect?.height)',
    { panel }
).evaluate();
// 64

Aggregates skip null expression results. If there are no numeric results, max and min return null; sum returns zero.

Reuse a value with a local

Declare a local with let before the output fields, then refer to it by name:

domql.query(`
    panel {
        let items = it.all("[data-key]"),

        count = items.count,
        tallest = items.max(it.rect?.height)
    }
`, { panel }).evaluate();
// { count: 3, tallest: 64 }

A field's name can be read by the fields after it, as { count = rows.count, label = $"{count} rows" } does, which reuses the field's value; there it hides a local or a binding of the same name, which ~count still reads for the binding.

Inside an object expression, root refers to the outermost object being built, so a nested object reads its fields even where a nearer field has the same name: in { bar = 4, inner = { bar = 2, outermost = root.bar } }, outermost is 4. Only fields whose initializers have completed are available through root, and root itself is never part of a result, so read a field, as root.bar, rather than writing { copy = root }.

Locals are not included in the result. A local is evaluated on first use, at most once for each value its object expression applies to. Unused locals are not evaluated.

it refers to the value the expression is evaluated against, as the element an object expression follows is for its fields. it inside a local refers to the value where the local is declared. A bare name refers to a local or an earlier field, and otherwise to the caller's binding; use it.rect, for example, to read a member of the implicit parameter. Write ~ before a name to read the caller's binding of it even where a local or a field shares the name, as ~panel does inside { let panel = …, original = ~panel.children.count }, or where a reserved word names it, as ~as.

Name the steps of a calculation

Write a query as a body when it builds on intermediate values: locals, each let, a name and =, and then return and the result.

let rows = panel.all("[data-key]")
let count = rows.count
return {
    count = count,
    summary = $"{count} rows",
    tallest = rows.max(it.rect?.height)
}

do … end writes a body wherever an expression stands, as doubled = do width / 2 * 4 end does in a field, its locals and its return each on a line of its own where it has them, and each branch of a conditional, if … then … else … end, is one too: within one conditional, every branch gives its result with return or none does. A body's locals run once each, in order, whether or not anything uses them, while an object expression's locals run only where a field reads them. return completes its own body, so a do block's return gives that block's value, never the query's.

An argument, an indexer's key, a computed property access and an interpolation each hold a simple expression, and an argument may be an arrow function instead: values, operators, calls and member reads, and a conditional of one if branch and one else branch of those, as calculate(if compact then 10 else 20 end) is. A do block, a conditional that binds a value, has elseif or gives its branches with return, and an object expression with locals, are bound to a local first, and the local is passed. A function called there may use any of them in its own body.

Reuse a calculation with a function

Declare a function as the value of a local, and call it by name with its arguments:

let half = function(box) (box?.rect?.width ?? 0) / 2 end
return {
    input = half(panel.first("input")),
    panel = half(panel)
}

A function's body is a body like any other, an expression alone or locals and a return, and it sees the locals and it where it is declared, never those of the place that calls it. Each call is checked against its own arguments, so one function serves an element in one call and an element that may be missing in another.

An arrow function writes a short one as its parameters, => and one expression, as let half = box => (box?.rect?.width ?? 0) / 2 does, and only an arrow function is written directly as an argument, as apply(row => row.attached, panel) is; a function declared with function is bound to a local and passed by its name. A function of either form is a value within the query: a local holds it, an argument passes it, a conditional chooses between functions and a function returns one, but no field or result holds one. A module's function can declare a parameter that takes a function, which it calls while its own invocation lasts; the built-in where, max, min and sum take their argument as a body of each item, as rows.where(it.attached) does, or as a function of the item, as rows.where(row => row.attached) does.

Handle missing values

Null means a value is missing or unavailable. Use ?. to read through a value that may be null and ?? to supply a fallback:

domql.query(
    'panel.first("input")?.disabled ?? false',
    { panel }
).evaluate();
// false: the example panel has no input.

If first("input") returns null, optional access skips the rest of that chain. ?? then supplies false. Accessing a nullable receiver with . is a validation error.

You can also make every access in a group optional:

(?panel.first(".row").rect.height)

This is shorthand for panel?.first(".row")?.rect?.height.

Geometry such as rect and size is null when an element is detached or has no layout box, including when it or an ancestor has display: none. A box with zero dimensions still returns zero dimensions.

A targeted object expression returns null when its receiver is null, as optional access does. For example:

domql.query(
    'panel.first(".missing")?["data-key"]',
    { panel }
).evaluate();
// null

Null does not hide an invalid query. An unknown member or incompatible argument remains an error.

Read a state

An element's state that the user or the browser changes, such as whether it is checked, disabled or focused, is a Boolean property, read without a call. It follows the browser's own rules, and the matching ARIA state is read through .aria:

control { checked = it.checked, ariaChecked = it.aria.checked }

.checked is true for a checked checkbox or radio button, or a selected option, as :checked matches. .aria.checked is true only where aria-checked is "true"; read it.aria["checked"], the attribute's own text as it["aria-checked"] reads it, or null where it is absent, to tell "mixed" from "false". The two never stand in for each other, so write it.checked or it.aria.checked where either counts.

  • Elements: disabled, readonly, textEditable, hovered, popoverOpen, checked, indeterminate, autofilled, focused and focusWithin.
  • The document: visible and focused.
  • The window: canShare and hasClipboard, whether the browser offers each API.

A state can be read through DOMQL's module as well, its receiver its first argument: Domql.checked(it) is it.checked. Filter by one with a body, rows.where(it.checked): only a function has a reference, so rows.where(Domql.checked) is refused. A member that does something is a function, called with its parentheses, as the executions are: it.focus() moves focus to the element, and it.focused says whether it has it.

displayed asks whether the browser renders a box for an element, and focusable whether it can take focus. With the document's activeElement, the focused element through open shadow roots, a query finds where focus can go and where it is:

panel {
    stops = it.all("*").where(it.focusable).count,
    focused = Domql.document.activeElement?["id"],
    rtl = it.direction == "rtl"
}

Compare and combine conditions

Use == and != to compare values. Nodes are compared by identity. The result is always a non-null Boolean, including when testing for null:

panel.parent == null

Use and, or and not for Boolean logic:

input.disabled or (input.readonly and not input.textEditable)

not binds more tightly than and, which binds more tightly than or. Evaluation stops as soon as an operand determines the answer: the operands after it are still validated, but are not evaluated or observed and cannot fail at run time.

Only null and false are falsey; every other value is truthy, including 0, "" and an empty list. A condition, an operand of and, or or not, and the expression of where can be any value. and and or return the operand that decides them, so they also choose values:

panel["data-label"] or "untitled"
panel { let input = it.first("input"), width = input and input.rect?.width }

In the second line, input.rect reads the input with a plain ., because the right side of and runs only when input is truthy. DOMQL narrows locals, if let names, parameters and it this way, but not members, which are read again wherever they are written.

or falls back for null and false, and ?? for null alone: false or true is true, and false ?? true is false.

DOMQL types every expression, so the values and and or give must share a type where the value is used. A condition, the operand of not and an operand of another and or or test truthiness alone, so it.disabled and "locked" or "open" is a String: the false that selects "open" reaches nothing. it.disabled and it, used as a value, could give false or an element, and is refused; write if it.disabled then it else null end if null is an acceptable alternative. Use a conditional whenever the chosen value may itself be false or null: c and x or y gives y where x is falsey, which if c then x else y end does not.

Mixing ?? with ==, !=, not, and or or requires parentheses around one of the expressions.

Choose a result with a conditional

Use if, then and else to choose between values. Add elseif for another condition. A conditional that spans lines is laid out in block form, each then and else ending its line and end on a line of its own:

input {
    state = if it.disabled then
        "locked"
    elseif it.readonly then
        "read"
    else
        "open"
    end
}

Conditions are tested in order, and only the selected branch is evaluated; the others are still validated, but are not evaluated or observed and cannot fail at run time. Branches must have compatible types; the result is nullable if either branch can be null. Group a conditional when using it as a condition, an operand or the start of a member chain.

Use if let to name a value and choose by whether it is truthy. The name is available in the then branch alone, where it cannot be null:

if let row = cell.closest(".row") then
    { key = row["data-key"], top = row.rect?.top }
else
    null
end

if let tests truthiness as if does, so 0, empty text and an empty list take the then branch, and false takes else. It pairs with try: if let count = try panel["data-count"] to Number then … else … end converts once and uses the number in its branch.

Cast a node

parent and root return nodes, which may be elements, documents, document fragments or shadow roots. Use as when a node must be of a specific kind, and try … as when it may be of another:

(panel.parent as Element)?.rect?.width
(try panel.parent as Element)?.rect?.width

The first fails with an error if the parent is not an element, such as a document or a shadow root, and the second returns null for it. Both return null for a missing parent, and the optional accesses account for that and for an element without a layout box.

Convert a value

Attributes hold text. Use to to convert a value to another type, such as the unread counts the items carry:

domql.query(
    'panel.all("[data-key]").sum(it["data-unread"] to Number ?? 0)',
    { panel }
).evaluate();
// 7

to converts what comes before it, here the attribute. An item without the attribute gives null, which ?? 0 counts as zero. Text that is no number, such as "5 new", fails the evaluation with an error rather than becoming null or zero.

To handle text that does not convert yourself, attempt the conversion with try, which answers null where the conversion rejects the value:

panel { count = try it["data-count"] to Number ?? 0 }

A missing attribute and malformed text both give null, so ?? 0 supplies zero, and valid text converts to its number. A fault in a conversion, such as one that throws, is still an error.

A conversion never answers null for text it accepts, so where text is present, a null from try means the text was malformed. To keep the number and still tell missing text from malformed text, return a status beside the value:

panel {
    let count = it["data-count"],
    count = if let n = try count to Number then
        { status = "valid", value = n }
    elseif count == null then
        { status = "missing", value = null }
    else
        { status = "malformed", value = null }
    end
}

The attribute is read once into a local, so both checks see the same text.

The built-in conversions are from a string to a number or a Boolean, and from a number or a Boolean to a string. A string converts to a number when it is written as HTML writes a number, such as "-12", " 3.5 " or "1e3", and to a Boolean when it is exactly "true" or "false". A value already of the type converts to itself.

to is not as: a cast narrows a node to one of its kinds and converts nothing.

Calculate and compare numbers

Use +, -, *, / and % to derive the values a result needs from those a query reads, - before a value to negate it, and <, <=, > and >= to test them:

panel {
    remaining = (it.clientSize?.width ?? 0) - (it.first("[data-key]")?.rect?.width ?? 0),
    half = (it.rect?.width ?? 0) / 2,
    unreadAfterNext = (it.first("[data-unread]")?["data-unread"] to Number ?? 0) + 1,
    fits = 0 < (it.clientSize?.width ?? 0) - 32 <= 400
}

Arithmetic takes numbers alone: a value that can be null, such as a size an element without a layout box has none of, is an error until it takes a fallback with ?? or a test, so missing data never becomes zero, and text becomes a number only through to, which binds tighter than arithmetic. *, / and % bind tighter than + and -, each level left to right. Dividing by zero, or a result too large for a number, fails the evaluation rather than giving infinity. A comparison answers a Boolean, and a chain of them pointing one way tests a range, as 0 < width <= 600, which evaluates width once; it binds looser than arithmetic and tighter than and and or, and is grouped beside == or ??, as (a < b) == c.

Build text from values

Write $ before a string to insert values into it, each expression in braces:

panel {
    summary = $"{it.children.count} items, {it["data-count"] ?? "none"} unread"
}

An interpolation inserts a string as it is and a number as to String writes it. A value that can be null needs a fallback, as ?? "none" gives the attribute above, and a Boolean takes to String or a conditional, so no null or true reaches the text by accident. Write a literal brace twice, {{ or }}, and begin an interpolation that starts with an object expression with a space, { { x = 1 }.x}.

Cross a shadow boundary

Selectors do not enter shadow roots automatically. Access the open shadow root explicitly:

host.shadow.root?.all("p").count

shadow.root is null when there is no open shadow root. element.root.host accesses the host of the shadow tree containing an element. slot.shadow.elements reads the elements a slot displays and returns null when the receiver is not a slot.

Measure layout and scrolling

rect, size and clientSize measure a box in CSS pixels, clientRect its padding box, and scroll how far it is scrolled along each axis of its writing mode, from the start of its content even under RTL:

panel.scroll {
    scrolled = it.block.offset > 0,
    atEnd = it.block.offset + it.block.viewport >= it.block.extent - 1
}

scrollContainer() finds the nearest ancestor the user can scroll, across shadow roots and slots, and is null where the window scrolls the element. sizeLimits reads the min-width, max-width, min-height and max-height a box is kept within, in pixels, each null where it is no length. A watch compares each measurement within a twentieth of a pixel, so a box settling by less reports no change.

Read values and text

value reads what a user entered in a control, text an element's text and html its markup, classes its class list and localName its name; a watch follows what the user types:

panel {
    name = it.localName,
    classes = it.classes,
    entered = it.first("input")?.value
}

files reads the files a file input holds, each its name, bytes, type and lastModified. A value the page's code sets fires no event, so a watch of value or files needs acceptPartialObservation, as the specification's members say of each.

Read the selection

Domql.document.selection reads the document's selection as it stands, and its range what the selection holds: its text, its box, the elements it touches and its offsets in an element's text. A text control's or an editable element's own selection answers its start and end:

Domql.document.selection?.range {
    text = it.text,
    links = it.all("a[href]") { href = it["href"] },
    top = it.rect?.top
}

The browser reports every selection the user or the page's code makes, so a watch follows the selection as it moves.

Read where an element stands

index reads an element's place among its parent's child elements, previousSibling and nextSibling its neighbours, and contains whether a node holds another:

item {
    index = it.index,
    next = it.nextSibling?["data-key"],
    inPanel = panel.contains(it)
}

Domql.window.location reads the window's URL, which a watch follows as the history API changes it, and Domql.window.shortcutModifier the key a shortcut is written with, "Meta" on Apple platforms and "Control" elsewhere.

Choose a property by name

Use .[…], brackets after a dot, when the property name comes from a string:

domql.query('panel.[name].count', {
    panel,
    name: 'children'
}).evaluate();
// 3

DOMQL must be able to determine the possible property names during resolution and validate them against the receiver. This supports literals, bound parameters, locals and if let names holding supported name expressions, conditionals, ??, and and or; it is not unrestricted lookup from an arbitrary runtime string. See the specification for the full rules.

Bind values that are not there yet

Bindings can contain DOM elements or supported data. Bind a target as null when it is not available yet:

const optional = domql.query(`
    {
        panel = panel { size = it.size },
        ids = ids
    }
`, { panel: null, ids: [] });

optional.evaluate();
// { panel: null, ids: [] }

A binding's type comes from its value, unless the query declares it. Null bindings and empty lists can leave a type unknown; applicable property checks are deferred until the type is known, while calls are still checked against the vocabulary. Use optional access when reading a member from a nullable binding.

Keep independent results in a top-level object, as above. If you instead write panel { size = it.size, ids = ids }, the entire result is null while panel is null.

API and errors

API Purpose
domql.query(text, bindings) Create a query from text. Also accepts a definition instead of text.
query.definition Read the immutable definition, without bindings.
query.with(bindings) Create a new query with the same definition and new bindings.
query.resolve(options) Validate names and types without evaluating.
query.select(...path) Select the part returned by evaluate, evaluateAsync or watch.
query.evaluate(options) Read a snapshot synchronously. Accepts an optional window; rejects members that require observation samples.
query.evaluateAsync(options) Read a snapshot, waiting for required observation samples. Accepts signal and window.
query.watch(options) Start a watch. Options include acceptPartialObservation, schedule, updateStrategy, signal and window.
query.subscribe(handler, options) Project events into data. Returns a listener with status and dispose().
query.executeAsync(options) Carry out a module-defined once execution and return a promise of its projected result.
query.run(options) Start a module-defined continuous execution. Returns a handle with status, update(bindings), subscribe('error', handler) and dispose().
domql.evaluate(text, bindings, options), and evaluateAsync, watch, executeAsync and run alike Run a query directly, in one call: domql.evaluate(text, bindings) answers what domql.query(text, bindings).evaluate() does. options also takes the query's types. Keep a query object to run a query again.
domql.subscribe(text, bindings, handler, options) Subscribe directly, in one call, as domql.query(text, bindings).subscribe(handler, options) does.
domql.format(source) Return canonical query text from text, a definition or a query object; no bindings or registered vocabulary are needed.
domql.createModule(name, contents) Create an extension module.
domql.registerModule(module) Register a module for query resolution.
domql.info Read the build's versions: its runtime, the DOMQL specification it follows, and the Nexo language it runs on.

A watch handle exposes status, snapshot, error, sourceOf(...path), readyAsync(), refreshAsync({ resample }), subscribe(eventName, handler) and dispose(). Snapshot watches support select(...path); live-state watches expose liveState. See Watch a query for examples.

Choose the matching operation

DOMQL rejects operations that do not match the request before running them. The error identifies the request kind and its supported operations. For example, a read query is evaluated; a once execution is executed.

Cancel an operation

Pass an AbortSignal in the options for evaluateAsync, watch, subscribe, executeAsync or run. Aborting ends the operation and releases its resources. An already-aborted signal prevents the operation from starting. Cancellation does not undo changes made by an execution.

Validate without evaluating

resolve() checks names and types without reading the DOM and can run without a browser:

const resolved = domql.query('panel?.children.count', { panel: null }).resolve();

resolved.kind; // 'value'
resolved.type; // 'Number?'

Handle errors

DOMQL reports failures as a NexoError with a code, a kind, a message and a structured location. The code names the condition: NEXO and four digits for Nexo's, its language's and its runtime's, DOMQL and four digits for DOMQL's own, as the specification's errors list them. The location identifies a text position, a definition node by JSON Pointer, or the relevant binding or declaration. Use these structured fields rather than matching message text.

Kind Failure
syntax Invalid query text
structure Invalid definition structure, binding or use of the API
validation Unknown members, incompatible types, invalid arguments or unsupported request usage
module Invalid module declarations
evaluation A failure while evaluating the query
try {
    domql.query('panel[200]', { panel }).evaluate();
} catch (error) {
    if (error?.name !== 'NexoError') {
        throw error;
    }

    console.error(error.code, error.kind, error.message, error.location);
    // NEXO3042
    // validation
    // The argument 'name' of '[…]' expects String and finds Number
    // (line 1, column 7, at /expression/argument)
}

A misspelled member is an error; a selector with no match returns null. Raw elements are not valid snapshot data, so project the properties you want rather than returning the elements themselves.

Environments

In a browser, DOMQL uses the default window and document. Queries read them as Domql.window and Domql.document, members of DOMQL's module, Domql, which every query imports.

To use another supported DOM environment, pass its window to query.evaluate({ window: suppliedWindow }). Domql.window and Domql.document then refer to that environment.

Node.js can create and resolve queries without a DOM. Evaluation requires a supplied DOM environment; without one, it fails with an evaluation error. DOM emulators do not provide full browser layout behavior.

Extend the vocabulary

Modules add capabilities to DOMQL without changing its syntax. Each module supplies declarations describing its types and members, and each declaration that runs code carries the JavaScript function that implements it as its impl. A declaration names its own module's types and the language's, as Number, by their names, and another module's qualified by that module's name, as Domql.Element.

A module adds members to DOMQL's types in an extension, { name, kind: 'extension', target: 'Domql.Element', members }. A query imports the module, import Metrics on its first line, and reads an extension's member with a colon, panel:metrics, where DOMQL's own members are read with a dot.

An implementation receives one object that holds what it is given by name: a member's receiver, its args by parameter name and the env it reads in, the window and the document. Implementations used for synchronous query evaluation must be synchronous and read-only.

This example adds a metrics member to elements, exposing a child count:

const metricsModule = domql.createModule('Metrics', {
    types: [{
        name: 'ChildMetrics',
        kind: 'structured',
        members: [{ name: 'childCount', kind: 'property', result: 'Number' }]
    }, {
        name: 'MetricElements',
        kind: 'extension',
        target: 'Domql.Element',
        members: [{
            name: 'metrics',
            kind: 'property',
            result: 'ChildMetrics',
            updates: { changes: 'unobserved', reads: 'fresh' },
            impl: ({ receiver: element }) => ({ childCount: element.children.length })
        }]
    }]
});

domql.registerModule(metricsModule);

domql.query('import Metrics\npanel:metrics { childCount = it.childCount }', { panel }).evaluate();
// { childCount: 3 }

A member's updates declare how its value changes and how it is read. reads: 'fresh' means evaluation reads the value at that time. The result can change, but this example provides no observation to report changes, so it declares changes: 'unobserved'. It supports evaluation without promising watch support.

Register modules once during setup, rather than on every evaluation.

Modules can also declare Boolean members, properties and functions whose result is a Boolean, and conversions from their own types and to them, each an operator, { kind: 'operator', name: 'convert', result }, within the type or the extension it converts from. Its implementation receives { value, reject }, and calls reject(reason) for a value it cannot convert, which stops it at once; any other exception it throws is reported as a fault.

A member the host invokes, invokedBy: 'host', is a host call that DOMQL carries out: an event source, requestKind: 'subscription', or an execution, requestKind: 'execution', which runs once or continuous as its executionMode says. An event source's implementation receives the receiver, arguments, environment and an event-delivery callback, as { receiver, args, env, deliver }, and returns an object with a stop method to end listening. Each event is captured as the type its declaration's result names, and a module adds event types of its own as mappings of DOMQL's type selector event, { selector: 'event', key, type }, each naming the type its events are captured as.

Nexo's modules specify the full extension contracts. The next section shows execution modules.

Bind data as a module's type

A binding's data is typed from itself, so a list of objects is no list of a module's structured type, and a member that takes one refuses it (NEXO3042). Declare the binding's type when making the query:

const shortcutsModule = domql.createModule('Shortcuts', {
    types: [{
        name: 'Shortcut',
        kind: 'structured',
        members: [
            { name: 'key', kind: 'property', result: 'String' },
            { name: 'action', kind: 'property', result: 'String' }
        ]
    }],
    members: [{
        name: 'actionFor',
        kind: 'function',
        result: 'String?',
        arguments: 'positional',
        updates: { changes: 'constant', reads: 'fresh' },
        parameters: [
            { name: 'shortcuts', kind: 'value', type: 'List<Shortcut>' },
            { name: 'key', kind: 'value', type: 'String' }
        ],
        impl: ({ args }) => args.shortcuts.find(shortcut => shortcut.key === args.key)?.action ?? null
    }]
});

domql.registerModule(shortcutsModule);

domql.query(
    'import Shortcuts\nShortcuts.actionFor(shortcuts, "Enter")',
    { shortcuts: [{ key: 'Enter', action: 'submit' }] },
    { types: { shortcuts: 'List<Shortcuts.Shortcut>' } }
).evaluate();
// 'submit'
  • A declared type is a notation, naming each module's type qualified where another of its name would hide it, as List<Shortcuts.Shortcut>.
  • The data is captured as that type: a property it leaves out is null where its type is nullable, a map binds as a dictionary and a set as a list, and data the type doesn't admit refuses the binding (NEXO2045).
  • Every binding made from the query keeps the types: with's, an execution's update and a reaction's.
  • A module a type names is registered first, or loaded, before the query is made, since the query binds as it is made.

Load a module on request

A page can load a module's code only when a query first needs it. Register a loader by the module's name, which answers the module, and load what a query imports before carrying it out:

domql.registerModuleLoader('Unread', async () => domql.createModule('Unread', {
    types: [{
        name: 'UnreadElements',
        kind: 'extension',
        target: 'Domql.Element',
        members: [{
            name: 'unread',
            kind: 'property',
            result: 'Number',
            updates: { changes: 'observable', reads: 'fresh', observations: [{ type: 'mutation', of: 'receiver', attributes: ['data-unread'] }] },
            impl: ({ receiver: element }) => Number(element.getAttribute('data-unread') ?? 0)
        }]
    }]
}));

const unread = domql.query('import Unread\npanel.children.sum(it:unread)', { panel });

await domql.loadModulesAsync(unread);
unread.evaluate();
// 7

loadModulesAsync takes a query, a definition, a text or a list of module names. It loads only the modules the instance doesn't hold yet, and shares a load that is already under way, so call it before every query that may need one. A query that imports a module whose loader hasn't run yet is refused with DOMQL3007. Load first even for run, which starts its execution before it returns.

Execute once and run continuously

Change the page with a built-in execution

DOMQL makes a few changes itself, each one browser call that answers what it did. Carry one out with executeAsync():

list.scrollTo({ block = 0, behavior = "smooth" })

dispatch({ type, bubbles, detail }) dispatches an event of the page's own and answers whether a listener cancelled it. focus() focuses an element and answers whether focus landed on it, and showPopover(), hidePopover(), showModal() and close() show and hide popovers and dialogs, each answering whether it changed them; capturePointer({ pointerId }) and releasePointer({ pointerId }) capture a pointer while it is pressed; Domql.document.selection?.select({ node }), selectContents and collapse place the document's selection; and replaceValue(value), markIndeterminate(), clearIndeterminate() and select({ start, end }) write a control's state as the page's code does, firing no event. A media element's pause(), mute() and unmute() answer whether they changed it. Domql.window.writeToClipboard(text), readFromClipboard(), download({ … }), print() and requestShare({ … }), and a media element's play(), answer how the browser took the request, as "refused" where it wants the user's activation, which a subscription's handler has while the event that called it lasts. scrollTo and scrollBy scroll an element or Domql.window along the axes of its writing mode, from the start of its content even under RTL, and answer whether it moved once the scroll has ended; scrollIntoView answers whether the element is in view then. ownAttribute, ownClass, ownStyle, ownText and ownFileSource, which shows a chosen file as an image's source, are continuous: run() holds an attribute, a class, an inline style property or the element's text at its value until the handle is disposed, which writes back the value it found unless restore = false, and keep = true writes it again before paint where another writer, as a framework re-rendering the element, changes it, yielding to a writer that writes its own value again in answer. ownText writes into the text node the element holds, in place, so a framework that rendered the node keeps it. The specification lists every built-in execution and what each needs of the user.

Execute once

A module declares once executions of its own, host calls that can change the page. domql.executeAsync(text, bindings), or executeAsync() on a query object, prepares its receiver and arguments, invokes it, and returns a promise of its result. Its implementation may also return a promise.

This example registers a once execution that sets a data attribute and reports whether the value changed:

domql.registerModule(domql.createModule('Marks', {
    types: [{
        name: 'MarkElements',
        kind: 'extension',
        target: 'Domql.Element',
        members: [{
            name: 'mark',
            kind: 'function',
            invokedBy: 'host',
            requestKind: 'execution',
            executionMode: 'once',
            arguments: 'positional',
            parameters: [{ name: 'label', kind: 'value', type: 'String', required: true, nulls: 'propagate' }],
            result: 'Boolean',
            impl: ({ receiver: element, args: { label } }) => {
                const changed = element.dataset.mark !== label;

                element.dataset.mark = label;

                return changed;
            }
        }]
    }]
}));

await domql.executeAsync('import Marks\npanel:mark("reviewed")', { panel });
// true

An object expression after the execution can shape its result.

To cancel, pass { signal } to executeAsync(). The promise rejects with the signal's reason, and the implementation receives the cancellation through its signal. Changes already made remain in place, including when an execution fails.

Run continuously

A continuous execution stays active until disposed. run() starts it before returning a handle. Use that handle to update its bindings, subscribe to error events or dispose of it.

This example maintains a data attribute while the execution runs:

domql.registerModule(domql.createModule('Highlights', {
    types: [{
        name: 'HighlightElements',
        kind: 'extension',
        target: 'Domql.Element',
        members: [{
            name: 'highlight',
            kind: 'function',
            invokedBy: 'host',
            requestKind: 'execution',
            executionMode: 'continuous',
            arguments: 'positional',
            parameters: [{ name: 'color', kind: 'value', type: 'String', required: true, nulls: 'propagate' }],
            impl: ({ receiver: element, args: { color } }) => {
                let current = element;

                current.dataset.highlight = color;

                return {
                    update: (target, { color: next }) => {
                        delete current.dataset.highlight;
                        current = target;
                        current.dataset.highlight = next;
                    },
                    dispose: () => delete current.dataset.highlight
                };
            }
        }]
    }]
}));

const highlight = domql.run('import Highlights\npanel:highlight(color)', { panel, color: 'gold' });
console.log(panel.dataset.highlight);
// 'gold'

highlight.update({ panel, color: 'teal' });
console.log(panel.dataset.highlight);
// 'teal'

highlight.dispose();
console.log(panel.dataset.highlight);
// undefined

update() replaces the complete bindings object and validates it before applying a change. A validation failure leaves the current execution running. Disposing the handle ends the execution; its module remains registered.

To end an execution once its element leaves the page, watch whether the element is attached, and dispose both when it no longer is:

const badge = document.createElement('span');

document.body.append(badge);

const marked = domql.query('badge.ownClass({ name = "busy" })', { badge }).run();
const attached = domql.query('badge.attached', { badge }).watch({ acceptPartialObservation: true });

attached.subscribe('change', (sender, args) => {
    if (args.status === 'ready' && !args.value) {
        marked.dispose();
        attached.dispose();
    }
});

// For this example only: the page removes the element.
badge.remove();
await attached.refreshAsync();
console.log(marked.status);
// 'disposed'

The watch evaluates at the next animation frame, so an element a framework moves, removing and inserting it in one task, keeps its execution. It accepts partial observation, since what attached misses is a detached tree inserted into a shadow root, never a removal.

Write beside a framework

A framework that renders an element writes its whole class and style attributes again as it renders, dropping a class or a property it doesn't know, and holds the text nodes it rendered. Hold what you write with an owned write instead of writing it directly:

const dragging = domql.query('panel.ownClass({ name = "dragging", keep = true })', { panel }).run();
const status = domql.query('item.ownText({ value = text })', { item: panel.children[0], text: '2 unread' }).run();

// For this example only: a framework renders the panel's class attribute again, dropping a class it doesn't know.
panel.className = 'rendered';
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
console.log(panel.className);
// 'rendered dragging'
console.log(panel.children[0].textContent);
// '2 unread'

dragging.dispose();
status.dispose();

keep = true writes the value again before the browser paints, wherever another writer changed it; a writer that writes its own value again in answer, as code holding the same attribute does, is yielded to rather than fought, while a framework's renders are written against however soon they follow one another. ownText writes into the text node the element holds, so the framework keeps the node it rendered. A position a gesture commits to the framework is held with restore = false, so it stands once the holding ends, until the framework renders it. Module code holds what it writes the same way, running an owned write with env.domql.

An owned write updated often, as a position following a pointer, binds numbers and writes the unit in its text, value = $"{left}px". A string, a Boolean or a null bound is part of what the query is resolved for, since one can select what a member reads, so each new string an update binds is resolved anew, where a new number is not.

Reach a running behavior

Every operation takes an owner, any value, which each implementation it reaches receives as env.owner. A continuous execution runs for its owner, and a command, a once execution whose declaration names that execution as its behavior, reaches the instance running for its own owner on its receiver: its implementation receives that instance, what the behavior's implementation answered, as instance. With none running, the command rejects with DOMQL5016.

This example opens a disclosure through a command:

domql.registerModule(domql.createModule('Disclosures', {
    types: [{
        name: 'DisclosureElements',
        kind: 'extension',
        target: 'Domql.Element',
        members: [{
            name: 'disclosure',
            kind: 'function',
            invokedBy: 'host',
            requestKind: 'execution',
            executionMode: 'continuous',
            impl: ({ receiver: element }) => ({
                open: () => element.toggleAttribute('data-open', true),
                update: () => {},
                dispose: () => element.removeAttribute('data-open')
            })
        }, {
            name: 'open',
            kind: 'function',
            invokedBy: 'host',
            requestKind: 'execution',
            executionMode: 'once',
            behavior: 'disclosure',
            result: 'Boolean',
            impl: ({ instance }) => instance.open()
        }]
    }]
}));

const disclosure = domql.query('import Disclosures\npanel:disclosure()', { panel }).run({ owner: 'menu' });

await domql.query('import Disclosures\npanel:open()', { panel }).executeAsync({ owner: 'menu' });
// true

disclosure.dispose();

Report what a behavior does

A behavior reports what happens, a drop, a move or a choice, as an occurrence: it hands a value to an event source its module declares for it, deliver('picks', value), and every subscription of that source listening for the behavior's owner on its receiver projects it and calls its handler before deliver returns. The source names the behavior as its behavior, and declares no implementation and no parameters, since DOMQL carries it out. A subscription made before the behavior starts hears it, and goes on listening as the behavior is updated or replaced.

deliver answers a promise that settles once each subscription has applied the occurrence, a handler's returned promise included, and rejects where none listens or one failed; a behavior that doesn't need to know may leave it.

domql.registerModule(domql.createModule('Pickers', {
    types: [{
        name: 'PickerElements',
        kind: 'extension',
        target: 'Domql.Element',
        members: [{
            name: 'picker',
            kind: 'function',
            invokedBy: 'host',
            requestKind: 'execution',
            executionMode: 'continuous',
            impl: ({ receiver: element, deliver }) => {
                const pick = event => deliver('picks', event.target);

                element.addEventListener('click', pick);

                return {
                    update: () => {},
                    dispose: () => element.removeEventListener('click', pick)
                };
            }
        }, {
            name: 'picks',
            kind: 'function',
            invokedBy: 'host',
            requestKind: 'subscription',
            behavior: 'picker',
            result: 'Domql.Element'
        }]
    }]
}));

const picks = domql.query('import Pickers\npanel:picks() { key = it["data-key"] }', { panel }).subscribe((sender, args) => console.log(args.value));
const picker = domql.query('import Pickers\npanel:picker()', { panel }).run();

panel.querySelector('[data-key="a3"]').click();
// { key: 'a3' }

picker.dispose();
picks.dispose();

Offer a behavior's state

What a behavior holds that a caller reads, as a count or a placement, is a member of its module that names the behavior as its behavior: its implementation receives the instance running for the owner on its receiver, or null where none runs, as instance. The member observes DOMQL's behavior observation, so a watch reading it evaluates again as the behavior starts, is updated or ends, and each time the behavior calls the notify() its implementation receives.

domql.registerModule(domql.createModule('Clicks', {
    types: [{
        name: 'ClickElements',
        kind: 'extension',
        target: 'Domql.Element',
        members: [{
            name: 'clickCounter',
            kind: 'function',
            invokedBy: 'host',
            requestKind: 'execution',
            executionMode: 'continuous',
            impl: ({ receiver: element, notify }) => {
                const counter = { count: 0, update: () => {}, dispose: () => element.removeEventListener('click', count) };
                const count = () => {
                    counter.count++;
                    notify();
                };

                element.addEventListener('click', count);

                return counter;
            }
        }, {
            name: 'clicks',
            kind: 'property',
            result: 'Number?',
            behavior: 'clickCounter',
            updates: { changes: 'observable', reads: 'fresh', observations: [{ type: 'behavior', of: 'receiver', behavior: 'clickCounter' }] },
            impl: ({ instance }) => instance?.count ?? null
        }]
    }]
}));

const clickCounter = domql.query('import Clicks\npanel:clickCounter()', { panel }).run();

panel.click();
domql.query('import Clicks\npanel:clicks', { panel }).evaluate();
// 1

clickCounter.dispose();

Observe and write through DOMQL

Module code observes through the observations DOMQL's watches share, rather than observers of its own: env.observe({ type, target, ...arguments }, notify) holds one, by any registered observation type, until its stop(), and a mutation observation notifies as the browser delivers the mutation. env.frame(callback) runs a write in the next animation frame, before the watches DOMQL evaluates in that frame read it; { phase: 'read' } runs a read among them. env.measureUnder(element, { class }, read) reads an element under a class it lacks, which DOMQL's own observers never hear, and env.domql is the instance of DOMQL, for queries of the module's own. A member reads alone, so DOMQL refuses each of these, and every execution, while a query is evaluated; an observation type's implementation receives an environment whose tools it may always use.

domql.registerModule(domql.createModule('Counts', {
    types: [{
        name: 'CountElements',
        kind: 'extension',
        target: 'Domql.Element',
        members: [{
            name: 'childCount',
            kind: 'function',
            invokedBy: 'host',
            requestKind: 'execution',
            executionMode: 'continuous',
            impl: ({ receiver: element, env }) => {
                let frame = null;
                const write = () => {
                    frame = null;
                    element.dataset.count = String(element.children.length);
                };
                const children = env.observe({ type: 'mutation', target: element, childList: true }, () => {
                    frame ??= env.frame(write);
                });

                write();

                return {
                    update: () => {},
                    dispose: () => {
                        children.stop();
                        frame?.cancel();
                    }
                };
            }
        }]
    }]
}));

const childCount = domql.query('import Counts\npanel:childCount()', { panel }).run();

panel.append(document.createElement('div'));

// For this example only: wait for the next frame to be painted.
await new Promise(resolve => setTimeout(resolve, 50));
console.log(panel.dataset.count);
// '4'

childCount.dispose();

Keep state for a document

A module keeps what it holds for a page by env.document, in a WeakMap of its own, made as it first meets the document and let go with it. The module's code is the same wherever it is registered, so the state is one for each document even where two copies of DOMQL each register the module. A member that keeps what it measured before keys that history by its env, which is one for each watch, wait or subscription, so each keeps its own.

const layersByDocument = new WeakMap();
const layersOf = page => {
    let layers = layersByDocument.get(page);

    if (layers === undefined) {
        layers = [];
        layersByDocument.set(page, layers);
    }

    return layers;
};

domql.registerModule(domql.createModule('Layers', {
    types: [{
        name: 'LayerElements',
        kind: 'extension',
        target: 'Domql.Element',
        members: [{
            name: 'layer',
            kind: 'function',
            invokedBy: 'host',
            requestKind: 'execution',
            executionMode: 'continuous',
            impl: ({ receiver: element, env }) => {
                const layers = layersOf(env.document);

                layers.push(element);

                return {
                    update: () => {},
                    dispose: () => layers.splice(layers.indexOf(element), 1)
                };
            }
        }]
    }, {
        name: 'LayerDocuments',
        kind: 'extension',
        target: 'Domql.Document',
        members: [{
            name: 'layerCount',
            kind: 'property',
            result: 'Number',
            updates: { changes: 'unobserved', reads: 'fresh' },
            impl: ({ receiver: page }) => layersOf(page).length
        }]
    }]
}));

const layer = domql.query('import Layers\npanel:layer()', { panel }).run();

domql.query('import Layers\nDomql.document:layerCount').evaluate();
// 1

layer.dispose();

Keep a function a query gives

A host call may take a function parameter, as a test of what a behavior hears: when = it["data-unread"] != null reaches its implementation as a function it keeps and calls later, each call reading the page as it is then. DOMQL ends it as the execution ends, or as an update gives the behavior another, after which calling it fails with NEXO5051.

domql.registerModule(domql.createModule('Filters', {
    types: [{
        name: 'FilterElements',
        kind: 'extension',
        target: 'Domql.Element',
        members: [{
            name: 'clickFilter',
            kind: 'function',
            invokedBy: 'host',
            requestKind: 'execution',
            executionMode: 'continuous',
            arguments: 'options',
            parameters: [{ name: 'when', kind: 'function', type: 'Boolean', parameterTypes: ['Domql.Element'] }],
            impl: ({ receiver: element, args, deliver }) => {
                let { when } = args;
                const filter = event => {
                    if (when(event.target)) {
                        deliver('filtered', event.target);
                    }
                };

                element.addEventListener('click', filter);

                return {
                    update: (target, next) => {
                        when = next.when;
                    },
                    dispose: () => element.removeEventListener('click', filter)
                };
            }
        }, {
            name: 'filtered',
            kind: 'function',
            invokedBy: 'host',
            requestKind: 'subscription',
            behavior: 'clickFilter',
            result: 'Domql.Element'
        }]
    }]
}));

const filtered = domql.query('import Filters\npanel:filtered() { key = it["data-key"] }', { panel }).subscribe((sender, args) => console.log(args.value));
const clickFilter = domql.query('import Filters\npanel:clickFilter({ when = it["data-unread"] != null })', { panel }).run();

panel.querySelector('[data-key="a3"]').click();
panel.querySelector('[data-key="a2"]').click();
// { key: 'a2' }

clickFilter.dispose();
filtered.dispose();

Learn more

  • Repository README: what DOMQL is for, what is built, and how to build and test it.

  • Nexo specification: the language DOMQL is written in, its syntax, types, null semantics, imports and extensions, and the runtime contracts of its modules.

  • DOMQL specification: queries, the DOM's vocabulary, evaluating and watching a query, its results, events, executions, and the API.

  • DOMQL design: how DOMQL is built, and the decisions it did not take, and the Nexo design: how the language and its runtime are built.

  • Changelog: each release of the package and what it changed.

  • Status: what is implemented, what is being built, and what remains.

Product Compatible and additional computed target framework versions.
.NET net11.0 is compatible. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.
  • net11.0

    • No dependencies.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
1.0.0-preview.2 46 11/5/2026
1.0.0-preview.1 46 11/5/2026