An Elixir implementation of the browser DOM. A document is a GenServer that
owns a private ETS table of per-type node records; you manipulate it through
immutable handles (%DOM.Node{server, node_id, type}) rather than live objects.
It exists to test DOM interactions from Elixir — the tree, CSS selectors, events,
and the WHATWG event loop — without a browser, with behavior verified against real
Chromium and Firefox.
doc = DOM.new("<ul id='list'><li class='item'>a</li><li class='item'>b</li></ul>")
DOM.query_selector_all(doc, "#list > .item") # [%DOM.Node{}, %DOM.Node{}]
item = DOM.query_selector(doc, "li.item")
DOM.Element.get_attribute(item, "class") # "item"
DOM.Node.add_event_listener(item, "click", fn _event -> IO.puts("clicked") end)
DOM.Node.dispatch_event(item, DOM.Event.new("click", bubbles: true))DOM.new/0,1 starts an unsupervised document and returns its handle — convenient for
scripts, tests, and the REPL. For a long-lived document, use DOM.start_link/1 under a
supervisor (it takes the same options; you mint the %DOM.Node{type: :document} handle
from the returned server):
{:ok, server} = DOM.start_link(parse: DOM.HTML.tokens(html), document_id: id)
document = %DOM.Node{server: server, node_id: id, type: :document}- Two struct layers.
DOM.Nodeis the one user-facing handle — a struct carrying{server, node_id, type}, never a live object. Internally, six per-typeDOM.NodeData.*records live in the server's ETS tuple space. Operations aretype-guarded function clauses on the handle, never a protocol over the handle. - Scoped dispatch. Generic node operations →
DOM.Node; element-intrinsic ones (attributes,local_name) →DOM.Element; whole-document / query operations →DOM(which is also the GenServer).querySelectoris scoped perParentNodekind (DOM,Element,DocumentFragment,ShadowRoot). - Nested-set tree. Child order and containment are encoded in binary extent keys
in an
:ordered_setindex, soquerySelectorAll, ancestor/containment tests, andcompareDocumentPositionare index range scans, not tree walks.
- Tree & attributes — the everyday node/element surface, the attribute API
(including namespaced attributes and live
Attrnodes),cloneNode, cross-document adoption. - CSS selectors — the full CSS Level-4 grammar parses and matches for everything
derivable from the tree (type/id/class/attribute, all combinators, selector lists,
:not/:is/:where/:has, the structural and*-of-typefamilies, and the derivable UI/form-state pseudo-classes). The combinator engine is index-fused (no per-candidate re-query). - Shadow DOM — shadow roots, maintained slot assignment, shadow-scoped CSS.
- Events & the event loop — full capture→target→bubble dispatch with shadow
retargeting; microtasks,
MutationObserver,slotchange, timers (setTimeout/setInterval); custom elements with synchronous reactions;AbortController/AbortSignal; focus and other interaction state. - Ranges & traversal —
Range,TreeWalker,NodeIterator.
Everything browser-observable is checked against a Chromium+Firefox oracle via Playwright, and every document verifies an internal consistency invariant on teardown.
Dominique aims to be a faithful, thorough implementation of the DOM core — the parts of the WHATWG DOM (and the DOM-facing slices of HTML and CSS Selectors) that are derivable from the tree, its attributes, and the event loop. That surface is broad and browser-verified: tree mutation and traversal, the full attribute model, CSS Level-4 selector matching, Shadow DOM, events with shadow retargeting, the microtask/task event loop, custom elements, ranges, and traversal objects all behave as Chromium and Firefox do.
What it does not implement is anything that requires a subsystem a pure DOM library doesn't have. These are deliberate omissions, not bugs:
- No layout or rendering.
getBoundingClientRect,scrollIntoView,innerText(needs layout), and the rendering/presentation pseudo-classes (:modal,:fullscreen,:picture-in-picture,:popover-open) are out of scope. - No navigation, history, or live URL.
:visited,:target-by-navigation,:local-link, link-follows-on-click, and form submission are not modeled (the:targetfragment and click activations that are derivable — checkbox/radio toggle,<summary>→<details>— are implemented). - No media, autofill, or live-editing engine.
HTMLMediaElementplayback state (:playing/:paused/…),:autofill,:user-valid/:user-invalid, and the finer constraint-validation edges (step,minlengthunder live editing) are absent. - No
Window. Timers andqueueMicrotasklive onDOMas a documented convenience (the document server owns the event loop), but there is noWindow,Location, orSelection/XPath subsystem.
Where a selector references something unmodelable (:playing, :visited, …) it matches
nothing rather than erroring — mirroring how a browser querySelector returns no
elements. And a few browser divergences are intentional design choices for a testing
library (a listener/microtask that raises crashes the document server; handles go stale
after cross-document adoption) — see the notes below.
Dominique does not put every DOM method on one module; each WHATWG interface maps
to a scope module, and members are partitioned by which interface they belong to. This
index maps the JS member to its Elixir equivalent. All functions take a %DOM.Node{}
handle (or another handle struct) as their first argument.
| WHATWG | Dominique |
|---|---|
new Document() / parse |
DOM.new/0,1 / DOM.start_link/1 (start_link preferred) |
documentElement |
DOM.document_element/1 |
body / head |
DOM.body/1 / DOM.head/1 |
createElement |
DOM.create_element/2 |
createElementNS |
DOM.create_element_ns/3 |
createTextNode |
DOM.create_text_node/2 |
createComment |
DOM.create_comment/2 |
createDocumentFragment |
DOM.create_document_fragment/1 |
createAttribute |
DOM.create_attribute/2 |
implementation.createDocumentType |
DOM.create_document_type/4 |
getElementById |
DOM.get_element_by_id/2 |
getElementsByClassName |
DOM.get_elements_by_class_name/2 |
getElementsByTagName |
DOM.get_elements_by_tag_name/2 |
getElementsByName |
DOM.get_elements_by_name/2 |
querySelector / All |
DOM.query_selector/2 / DOM.query_selector_all/2 |
adoptNode / importNode |
DOM.adopt_node/2 / DOM.import_node/2,3 |
activeElement |
DOM.active_element/1 |
createRange |
DOM.Range.create_range/1 |
createTreeWalker |
DOM.create_tree_walker/1,2,3 |
createNodeIterator |
DOM.create_node_iterator/1,2,3 |
customElements.define / get |
DOM.define_element/3 / DOM.custom_element_get/2 |
setTimeout / clearTimeout |
DOM.set_timeout/3 / DOM.clear_timeout/2 |
setInterval / clearInterval |
DOM.set_interval/3 / DOM.clear_interval/2 |
queueMicrotask |
DOM.queue_microtask/2 |
| WHATWG | Dominique |
|---|---|
nodeType / nodeName / nodeValue |
DOM.Node.node_type/1 / node_name/1 / value/1 |
textContent (get / set) |
DOM.Node.text_content/1 / set_text_content/2 |
parentNode / childNodes |
DOM.Node.parent_node/1 / child_nodes/1 |
firstChild / lastChild |
DOM.Node.first_child/1 / last_child/1 |
nextSibling / previousSibling |
DOM.Node.next_sibling/1 / previous_sibling/1 |
children / childElementCount |
DOM.Node.children/1 / child_element_count/1 |
first/lastElementChild |
DOM.Node.first_element_child/1 / last_element_child/1 |
next/previousElementSibling |
DOM.Node.next_element_sibling/1 / previous_element_sibling/1 |
hasChildNodes |
DOM.Node.has_child_nodes/1 |
ownerDocument |
DOM.Node.owner_document/1 |
appendChild / insertBefore |
DOM.Node.append_child/2 / insert_before/3 |
removeChild / replaceChild |
DOM.Node.remove_child/2 / replace_child/3 |
append / prepend |
DOM.Node.append/2 / prepend/2 |
before / after / replaceWith |
DOM.Node.before/2 / after/2 / replace_with/2 |
remove |
DOM.Node.remove/1 |
cloneNode |
DOM.Node.clone_node/1,2 |
normalize |
DOM.Node.normalize/1 |
contains |
DOM.Node.contains/2 |
isSameNode / isEqualNode |
DOM.Node.is_same_node/2 / is_equal_node/2 |
compareDocumentPosition |
DOM.Node.compare_document_position/2 |
isConnected |
DOM.Node.is_connected/1 |
getRootNode |
DOM.Node.get_root_node/1,2 |
assignedSlot |
DOM.Node.assigned_slot/1 |
focus / blur |
DOM.Node.focus/1 / blur/1 |
addEventListener / removeEventListener |
DOM.Node.add_event_listener/4 / remove_event_listener/2,4 |
dispatchEvent |
DOM.Node.dispatch_event/2 |
event.composedPath() (at a node) |
DOM.Node.composed_path/2 |
DocumentType publicId / systemId |
DOM.Node.doctype_ids/1 |
| WHATWG | Dominique |
|---|---|
name / localName |
DOM.Node.attr_name/1 / attr_local_name/1 |
prefix / namespaceURI |
DOM.Node.attr_prefix/1 / attr_namespace_uri/1 |
value (get / set) |
DOM.Node.attr_value/1 / set_attr_value/2 |
ownerElement |
DOM.Node.owner_element/1 |
| WHATWG | Dominique |
|---|---|
localName / namespaceURI |
DOM.Element.local_name/1 / namespace/1 |
getAttribute / setAttribute |
DOM.Element.get_attribute/2 / set_attribute/3 |
hasAttribute / removeAttribute |
DOM.Element.has_attribute/2 / remove_attribute/2 |
toggleAttribute |
DOM.Element.toggle_attribute/2,3 |
getAttributeNames |
DOM.Element.get_attribute_names/1 |
getAttributeNS / setAttributeNS |
DOM.Element.get_attribute_ns/3 / set_attribute_ns/4 |
getAttributeNode / NS |
DOM.Element.get_attribute_node/2 / get_attribute_node_ns/3 |
setAttributeNode / removeAttributeNode |
DOM.Element.set_attribute_node/2 / remove_attribute_node/2 |
querySelector / All |
DOM.Element.query_selector/2 / query_selector_all/2 |
matches / closest |
DOM.Element.matches/2 / closest/2 |
innerHTML (get / set) |
DOM.Element.inner_html/1 / set_inner_html/2 |
outerHTML (get / set) |
DOM.Element.outer_html/1 / set_outer_html/2 |
insertAdjacentHTML / Element / Text |
DOM.Element.insert_adjacent_html/3 / _element/3 / _text/3 |
attachShadow / shadowRoot |
DOM.Element.attach_shadow/2,3 / shadow_root/1 |
lookupPrefix / lookupNamespaceURI |
DOM.Element.lookup_prefix/2 / lookup_namespace_uri/2 |
| WHATWG interface | Dominique module | notable members |
|---|---|---|
DocumentFragment (ParentNode) |
DOM.DocumentFragment |
query_selector/2, query_selector_all/2 |
ShadowRoot |
DOM.ShadowRoot |
host/1, mode/1, inner_html/1, query_selector/2 |
HTMLSlotElement |
DOM.Slot |
assigned_nodes/1, assigned_elements/1, assign/2 |
Range |
DOM.Range |
set_start/3, set_end/3, extract_contents/1, surround_contents/2, compare_boundary_points/3 |
TreeWalker |
DOM.TreeWalker |
current_node/1, next_node/1, parent_node/1, … |
NodeIterator |
DOM.NodeIterator |
next_node/1, previous_node/1, reference_node/1 |
Event |
DOM.Event |
new/1,2, prevent_default/1, stop_propagation/1, stop_immediate_propagation/1 |
AbortController |
DOM.AbortController |
new/1, signal/1, abort/1,2 |
AbortSignal |
DOM.AbortSignal |
aborted?/1, reason/1, throw_if_aborted/1, timeout/2, any/2 |
MutationObserver |
DOM.MutationObserver |
new/2, observe/3, disconnect/1, take_records/1 |
Interaction state that a browser drives from real input (:hover/:active/:target,
fragment navigation) is set through convenience functions on DOM
(set_hover/1, set_active/1, set_fragment/2, set_indeterminate/2, …), since
Dominique has no pointer/URL layer.
Node structs are immutable handles into a DOM GenServer, not live JavaScript objects. Like structs returned from a GenServer or database, a node handle may become stale after ownership changes.
Appending a node owned by another DOM transfers its entire subtree from the
source DOM server to the destination DOM server. DOM.Node.append_child/2
returns a current handle owned by the destination server. Callers must use that
returned handle after a cross-document transfer:
child = DOM.Node.append_child(destination_parent, child)Handles retained from before the transfer still point at the source server and must be considered stale. This intentionally differs from JavaScript, where object identity remains live across document adoption.
DOM operations that defer work — slotchange, and later MutationObserver and
custom-element reactions — enqueue microtasks that run at a checkpoint after
the current operation, matching the WHATWG event loop. The checkpoint drains the
queue inside the document server.
A microtask that raises crashes the document server. This is deliberate and
differs from a browser, which reports the error and continues to the next
microtask. WHATWG does not require isolation here, and Dominique's purpose is to
test DOM interactions from Elixir — so a lambda that raises is a bug in your
code, and letting it take down the document is a correct forcing function for
correctness (and idiomatic OTP "let it crash"). Do not wrap microtask execution
in a try to swallow this: a supervised document that silently survives a broken
listener would hide exactly the defects this library exists to surface.
This package is NOT available on hex and can be installed
by adding dominique to your list of dependencies in mix.exs:
def deps do
[
{:dominique, "~> 0.1.0", git: "https://github.com/ityonemo/dominique"}
]
endDocumentation can be generated with ExDoc and published on HexDocs. Once published, the docs can be found at https://hexdocs.pm/dominique.