A Scripting Engine
The most common misconception about JavaScript in a browser is that the DOM is
part of the language. It is not, and nothing in the JavaScript specification
mentions document, window, fetch or an element.
What the specification defines is a language: values, functions, objects, control flow. What a browser does is embed that language and then install a set of names into its global scope. Those names are host objects, and they are the entire connection between a script and a page.
Node has a different set. A game engine embedding a scripting language has a third. The language is the same in all of them.
The program
const std = @import("std");
const dom = @import("html.zig");
/// The smallest evaluator that can show the binding: names, integers, `+`,
/// `<` and `print`. The real language is the one built in the Tiny Language
/// section, with a lexer, a parser, an interpreter and a bytecode VM. It is
/// not imported here because each snippet on this site compiles on its own,
/// and in a browser the two are separate libraries linked together anyway.
///
/// What matters is the seam, and the seam is `Env`.
const Env = struct {
names: [8][]const u8 = undefined,
values: [8]i64 = undefined,
count: usize = 0,
fn define(e: *Env, name: []const u8, value: i64) !void {
if (e.count == e.names.len) return error.Full;
e.names[e.count] = name;
e.values[e.count] = value;
e.count += 1;
}
fn get(e: Env, name: []const u8) !i64 {
for (e.names[0..e.count], 0..) |n, i| {
if (std.mem.eql(u8, n, name)) return e.values[i];
}
return error.UndefinedVariable;
}
};
/// `print <expr>;` where an expression is a chain of names and numbers joined
/// by `+`. Enough to demonstrate that a script reads host-provided values and
/// nothing more.
fn eval(env: Env, expr: []const u8) !i64 {
var total: i64 = 0;
var terms = std.mem.tokenizeAny(u8, expr, "+ \t");
while (terms.next()) |term| {
total += std.fmt.parseInt(i64, term, 10) catch try env.get(term);
}
return total;
}
fn run(env: Env, script: []const u8, out: *std.Io.Writer) !void {
var lines = std.mem.tokenizeAny(u8, script, ";\n");
while (lines.next()) |raw| {
const line = std.mem.trim(u8, raw, " \t");
if (line.len == 0) continue;
if (!std.mem.startsWith(u8, line, "print ")) return error.Unsupported;
try out.print(" {d}\n", .{try eval(env, line["print ".len..])});
}
}
/// Count the elements with a given tag, anywhere in the tree.
fn countTag(nodes: []const dom.Node, index: u32, tag: []const u8) u32 {
var total: u32 = 0;
const node = nodes[index];
if (node.kind == .element and std.ascii.eqlIgnoreCase(node.name, tag)) total += 1;
var child = node.first;
while (child) |c| : (child = nodes[c].next) total += countTag(nodes, c, tag);
return total;
}
/// The bridge. The evaluator has no idea what an element is; the host walks
/// the document and defines ordinary variables. Everything the script can see
/// about the page is what was put here, which is exactly what a "host object"
/// means: not part of the language, installed by whatever is embedding it.
fn bind(env: *Env, nodes: []const dom.Node, root: u32) !void {
try env.define("paragraphs", countTag(nodes, root, "p"));
try env.define("headings", countTag(nodes, root, "h1"));
try env.define("images", countTag(nodes, root, "img"));
}
pub fn main(init: std.process.Init) !void {
var buf: [4096]u8 = undefined;
var stdout_writer = std.Io.File.stdout().writerStreaming(init.io, &buf);
const out = &stdout_writer.interface;
const html =
\\<h1>Gallery</h1>
\\<p>One</p>
\\<p>Two</p>
\\<p>Three</p>
\\<img src=a.png>
\\<img src=b.png>
;
var doc: [32]dom.Node = undefined;
const tree = try dom.parseInto(html, &doc);
var env: Env = .{};
try bind(&env, &doc, tree.root);
try out.writeAll("what the host put in scope\n");
for ([_][]const u8{ "paragraphs", "headings", "images" }) |name| {
try out.print(" {s: <11} {d}\n", .{ name, try env.get(name) });
}
// The script has no syntax for documents and needs none. `paragraphs` is
// an ordinary variable that happens to have been set by walking a tree.
const script =
\\print paragraphs;
\\print paragraphs + images;
\\print headings + paragraphs + images;
;
try out.writeAll("\nwhat the script printed\n");
try run(env, script, out);
// A name the host never defined is an ordinary undefined variable. There
// is no separate category of "DOM error".
try out.writeAll("\nasking for something not in scope\n");
if (run(env, "print stylesheets;", out)) |_| {
try out.writeAll(" accepted, which is wrong\n");
} else |err| {
try out.print(" {t}\n", .{err});
}
try out.flush();
}What just happened
The host walked the document and defined three ordinary variables.
paragraphs is 3 because a tree walk counted three <p> elements. To the
evaluator it is a name with an integer behind it, indistinguishable from one
the script declared itself.
The script has no syntax for documents, and needs none. print paragraphs + images is arithmetic. Nothing in the grammar knows what an image is, and
adding a new capability to the page means the host defining another name, not
the language growing a feature.
A name the host never defined is an ordinary undefined variable. Asking for
stylesheets produced UndefinedVariable, the same error a typo in a local
name would produce. There is no separate category of “DOM error”, which is why
document.foo is not a function and myTypo is not defined come from the same
machinery in a real browser.
The evaluator here is deliberately tiny. It handles names, integers, +
and print, and that is all it needs to show the seam. The real language is
the one built in the Tiny Language section, with a
lexer, a parser, a tree-walking interpreter and a bytecode VM. In a browser the
two are separate libraries linked together, which is exactly the relationship
being demonstrated: V8 does not contain a DOM, and Chrome supplies one.
What a real binding adds
Writing, not just reading. A script that can only observe the page is a
curiosity. element.textContent = "x" changes the tree, which invalidates the
boxes, which means layout runs again and then painting runs again. That is the
whole cost model of a dynamic page, and it is why the
layout chapter’s two passes matter so much: they are
the thing being repeated.
Objects rather than flat names. document.querySelectorAll("p").length
instead of paragraphs. That needs the language to have objects and the host
to expose functions on them, which is more plumbing and the same idea.
Events. Nothing above ever runs twice. A real engine keeps the environment alive and calls back into it when something happens, so the script’s variables outlive the script, and now the host has a lifetime problem: a listener holding a reference to a removed element keeps it alive. That is the most common memory leak on the web, and it exists because the seam has objects on both sides of it.
Check yourself
If the language has no idea what an element is, how does a script get a type error when it calls a method on the wrong thing?
Because host objects are ordinary objects as far as the language is concerned,
so calling a missing method on one fails the same way calling a missing method
on any object fails. The engine does not know that document is special; it
knows that the thing named document has no property called queryselectorAll
and that calling undefined is an error.
This is why the error messages are usually so unhelpful about what you actually did wrong. The engine reporting them has no concept of a document to explain.
Next: the whole pipeline, with every stage in one program.