1// A DOM just big enough for the glue of static/js/files.js: elements with
2// attributes, dataset, children and the few selectors the script uses
3// ("tag.class[attr][attr=v]:not([attr])", optionally after ":scope >" or
4// "#id >"). Not a test file: the tests import it.
5import assert from 'node:assert/strict';
6
7const kebab = (k) => 'data-' + k.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase());
8
9export class Text {
10 constructor(data) {
11 this.nodeType = 3;
12 this.data = data;
13 this.parent = null;
14 }
15 remove() {
16 if (!this.parent) return;
17 this.parent.nodes.splice(this.parent.nodes.indexOf(this), 1);
18 this.parent = null;
19 }
20 get textContent() { return this.data; }
21}
22
23// A compound selector is checked on one element: tag, .class, #id, [attr],
24// [attr=v], :not([attr]). `hidden` is a property of the element, as in a page.
25function compound(sel) {
26 const re = /([a-z][a-z0-9]*)|\.([\w-]+)|#([\w-]+)|:not\(\[([\w-]+)\]\)|\[([\w-]+)(?:=(?:"([^"]*)"|([^\]]*)))?\]/y;
27 const want = { tag: null, cls: [], id: null, not: [], attrs: [] };
28 while (re.lastIndex < sel.length) {
29 const m = re.exec(sel);
30 assert.ok(m, 'unsupported selector: ' + sel);
31 if (m[1]) want.tag = m[1].toUpperCase();
32 else if (m[2]) want.cls.push(m[2]);
33 else if (m[3]) want.id = m[3];
34 else if (m[4]) want.not.push(m[4]);
35 else want.attrs.push([m[5], m[6] ?? m[7]]);
36 }
37 return (el) => {
38 if (want.tag && el.tagName !== want.tag) return false;
39 if (want.id && el.id !== want.id) return false;
40 if (!want.cls.every((c) => el.className.split(/\s+/).includes(c))) return false;
41 if (want.not.some((a) => el.hasAttribute(a))) return false;
42 return want.attrs.every(([a, v]) => (v === undefined ? el.hasAttribute(a) : el.getAttribute(a) === v));
43 };
44}
45
46export class El {
47 constructor(tag) {
48 this.nodeType = 1;
49 this.tagName = tag.toUpperCase();
50 this.attrs = new Map();
51 this.nodes = [];
52 this.parent = null;
53 this.hidden = false;
54 this.className = '';
55 this.id = '';
56 this.value = '';
57 this.href = '';
58 this.tabIndex = 0;
59 this.text = '';
60 this.style = { props: {}, setProperty(k, v) { this.props[k] = v; } };
61 this.dataset = new Proxy({}, {
62 get: (_, k) => this.getAttribute(kebab(k)) ?? undefined,
63 set: (_, k, v) => { this.setAttribute(kebab(k), v); return true; },
64 has: (_, k) => this.hasAttribute(kebab(k)),
65 });
66 }
67 setAttribute(k, v) { this.attrs.set(k, String(v)); }
68 getAttribute(k) { return this.attrs.has(k) ? this.attrs.get(k) : null; }
69 hasAttribute(k) { return k === 'hidden' ? this.hidden : this.attrs.has(k); }
70 removeAttribute(k) { this.attrs.delete(k); }
71
72 get children() { return this.nodes.filter((n) => n.nodeType === 1); }
73 get parentElement() { return this.parent; }
74 get nextElementSibling() {
75 if (!this.parent) return null;
76 const sib = this.parent.children;
77 return sib[sib.indexOf(this) + 1] || null;
78 }
79 get previousElementSibling() {
80 if (!this.parent) return null;
81 const sib = this.parent.children;
82 return sib[sib.indexOf(this) - 1] || null;
83 }
84 get textContent() { return this.text + this.nodes.map((n) => n.textContent).join(''); }
85 set textContent(v) {
86 this.nodes.forEach((n) => { n.parent = null; });
87 this.nodes = [];
88 this.text = String(v);
89 }
90
91 adopt(list) {
92 return list.map((n) => {
93 const c = typeof n === 'string' ? new Text(n) : n;
94 c.remove();
95 c.parent = this;
96 return c;
97 });
98 }
99 append(...list) { this.nodes.push(...this.adopt(list)); }
100 replaceChildren(...list) {
101 this.nodes.forEach((n) => { n.parent = null; });
102 this.nodes = [];
103 this.append(...list);
104 }
105 after(...list) {
106 const p = this.parent;
107 const added = p.adopt(list);
108 p.nodes.splice(p.nodes.indexOf(this) + 1, 0, ...added);
109 }
110 remove() {
111 if (!this.parent) return;
112 this.parent.nodes.splice(this.parent.nodes.indexOf(this), 1);
113 this.parent = null;
114 }
115 closest(sel) {
116 const ok = compound(sel);
117 for (let e = this; e; e = e.parent) if (ok(e)) return e;
118 return null;
119 }
120 matches(sel) { return compound(sel)(this); }
121
122 // querySelectorAll: ":scope > c" and "#id > c" look at the children of the
123 // scope or of the element with that id; anything else at the descendants.
124 querySelectorAll(sel) {
125 const m = /^(:scope|#[\w-]+)\s*>\s*(.+)$/.exec(sel);
126 const ok = compound(m ? m[2] : sel);
127 if (m) {
128 const scope = m[1] === ':scope' ? this : this.find((e) => e.id === m[1].slice(1));
129 return scope ? scope.children.filter(ok) : [];
130 }
131 const out = [];
132 const walk = (e) => e.children.forEach((c) => { if (ok(c)) out.push(c); walk(c); });
133 walk(this);
134 return out;
135 }
136 querySelector(sel) { return this.querySelectorAll(sel)[0] || null; }
137 find(pred) {
138 for (const c of this.children) {
139 if (pred(c)) return c;
140 const d = c.find(pred);
141 if (d) return d;
142 }
143 return null;
144 }
145 scrollIntoView() {}
146 focus() { dom.focused.push(this); }
147 click() { dom.clicked.push(this); }
148}
149
150export const dom = { clicked: [], focused: [], body: null, holdFrames: false, frames: new Map(), frameID: 0 };
151
152// flushFrames runs the frames held since the last flush.
153export function flushFrames() {
154 const fs = [...dom.frames.values()];
155 dom.frames.clear();
156 fs.forEach((f) => f());
157}
158
159// installDom puts the globals files.js expects. parseHTML(text) answers the
160// nodes that a template's innerHTML makes.
161export function installDom({ parseHTML } = {}) {
162 dom.body = new El('body');
163 dom.clicked = [];
164 dom.focused = [];
165 const doc = {
166 body: dom.body,
167 createElement(tag) {
168 const e = new El(tag);
169 if (tag === 'template') {
170 e.content = new El('#fragment');
171 Object.defineProperty(e, 'innerHTML', { set(text) { e.content.replaceChildren(...parseHTML(text)); } });
172 }
173 return e;
174 },
175 createTextNode: (data) => new Text(data),
176 getElementById: (id) => dom.body.find((e) => e.id === id),
177 querySelectorAll: (sel) => dom.body.querySelectorAll(sel),
178 querySelector: (sel) => dom.body.querySelector(sel),
179 };
180 globalThis.window = globalThis;
181 globalThis.document = doc;
182 globalThis.location = { href: '' };
183 // A frame runs at once, unless a test holds the frames (dom.holdFrames) to
184 // act between a keystroke and its frame, as in a browser; flushFrames runs them.
185 globalThis.requestAnimationFrame = (f) => {
186 if (!dom.holdFrames) { f(); return 0; }
187 dom.frames.set(++dom.frameID, f);
188 return dom.frameID;
189 };
190 globalThis.cancelAnimationFrame = (id) => { dom.frames.delete(id); };
191 return doc;
192}
193
194// el makes an element: el('li', {id, cls, attrs}, ...children).
195export function el(tag, { id = '', cls = '', attrs = {}, ...props } = {}, ...children) {
196 const e = new El(tag);
197 e.id = id;
198 e.className = cls;
199 Object.assign(e, props);
200 for (const [k, v] of Object.entries(attrs)) e.setAttribute(k, v);
201 e.append(...children);
202 return e;
203}
204
205export const settle = () => new Promise((r) => setTimeout(r, 0));