// A small DOM for tests/area-profile.test.ts: enough of Element (attributes, children, text, // `closest`, `querySelectorAll` over the selectors Profile+ uses, listeners, `cloneNode`) to run the // Profile+ modules and the old User Notes plugin over X-shaped markup without a browser, plus the // pieces of X a profile feature reads (./x.ts builds them). Not a DOM library: it implements only // what these tests use, and a selector it does not understand throws. type Simple = { tag?: string; id?: string; classes: string[]; attrs: { name: string; op?: string; value?: string }[]; nots: Simple[] }; type Step = { simple: Simple; child: boolean }; /** Splits at top-level separators (not inside brackets, parentheses or quotes). */ function split(source: string, separators: string): { part: string; by: string }[] { const out: { part: string; by: string }[] = []; let depth = 0; let quote = ''; let part = ''; let by = ''; for (const char of source) { if (quote) { part += char; if (char === quote) quote = ''; continue; } if (char === '"' || char === "'") { quote = char; part += char; continue; } if (char === '[' || char === '(') depth += 1; if (char === ']' || char === ')') depth -= 1; if (depth === 0 && separators.includes(char)) { if (part) { out.push({ part, by }); part = ''; by = ''; } if (char !== ' ') by = char; continue; } part += char; } if (part) out.push({ part, by }); return out; } function parseSimple(source: string): Simple { const simple: Simple = { classes: [], attrs: [], nots: [] }; let rest = source; const take = (pattern: RegExp) => { const match = pattern.exec(rest); if (match) rest = rest.slice(match[0].length); return match; }; let match = take(/^[a-zA-Z][\w-]*/); if (match) simple.tag = match[0].toLowerCase(); while (rest) { if ((match = take(/^#([\w-]+)/))) simple.id = match[1]; else if ((match = take(/^\.([\w-]+)/))) simple.classes.push(match[1]!); else if ((match = take(/^\[([\w-]+)(?:([\^$*]?=)"([^"]*)")?\]/))) simple.attrs.push({ name: match[1]!, op: match[2], value: match[3] }); else if ((match = take(/^:not\(([^()]*)\)/))) simple.nots.push(parseSimple(match[1]!)); else throw new Error(`miniDom: selector not supported: ${source}`); } return simple; } const parsed = new Map(); function parse(selector: string): Step[][] { let list = parsed.get(selector); if (!list) { list = split(selector, ',').map(({ part }) => split(part.trim(), ' >').map(step => ({ simple: parseSimple(step.part), child: step.by === '>' }))); parsed.set(selector, list); } return list; } function matchesSimple(el: El, simple: Simple): boolean { if (simple.tag && el.tagName.toLowerCase() !== simple.tag) return false; if (simple.id && el.getAttribute('id') !== simple.id) return false; for (const name of simple.classes) if (!el.classList.contains(name)) return false; for (const { name, op, value } of simple.attrs) { const actual = el.getAttribute(name); if (actual === null) return false; if (op === '=' && actual !== value) return false; if (op === '^=' && !actual.startsWith(value!)) return false; if (op === '$=' && !actual.endsWith(value!)) return false; if (op === '*=' && !actual.includes(value!)) return false; } return !simple.nots.some(not => matchesSimple(el, not)); } function matchesSteps(el: El, steps: Step[], index = steps.length - 1): boolean { const step = steps[index]!; if (!matchesSimple(el, step.simple)) return false; if (index === 0) return true; if (step.child) return !!el.parentElement && matchesSteps(el.parentElement, steps, index - 1); for (let up = el.parentElement; up; up = up.parentElement) if (matchesSteps(up, steps, index - 1)) return true; return false; } export class Txt { text: string; parentElement: El | null = null; constructor(text: string) { this.text = text; } } type Listener = (event: Record) => void; const kebab = (key: string) => `data-${key.replace(/[A-Z]/g, char => `-${char.toLowerCase()}`)}`; export class El { tagName: string; parentElement: El | null = null; childNodes: (El | Txt)[] = []; tabIndex = -1; value = ''; private attributes = new Map(); private listeners = new Map(); constructor(tag: string) { this.tagName = tag.toUpperCase(); } get nodeName() { return this.tagName; } get children(): El[] { return this.childNodes.filter((node): node is El => node instanceof El); } get firstElementChild(): El | null { return this.children[0] ?? null; } get previousElementSibling(): El | null { const all = this.parentElement?.children ?? []; return all[all.indexOf(this) - 1] ?? null; } get nextElementSibling(): El | null { const all = this.parentElement?.children ?? []; return all[all.indexOf(this) + 1] ?? null; } get isConnected(): boolean { for (let up: El | null = this; up; up = up.parentElement) if (up.tagName === 'HTML') return true; return false; } getAttribute(name: string) { return this.attributes.get(name) ?? null; } hasAttribute(name: string) { return this.attributes.has(name); } setAttribute(name: string, value: string) { this.attributes.set(name, String(value)); } removeAttribute(name: string) { this.attributes.delete(name); } get className() { return this.getAttribute('class') ?? ''; } set className(value: string) { this.setAttribute('class', value); } get classList() { const names = () => this.className.split(/\s+/).filter(Boolean); return { contains: (name: string) => names().includes(name), add: (name: string) => { if (!names().includes(name)) this.className = [...names(), name].join(' '); }, remove: (name: string) => { this.className = names().filter(item => item !== name).join(' '); }, }; } get dataset(): Record { return new Proxy({}, { get: (_, key) => this.getAttribute(kebab(String(key))) ?? undefined, set: (_, key, value) => { this.setAttribute(kebab(String(key)), String(value)); return true; }, }) as Record; } get textContent(): string { return this.childNodes.map(node => (node instanceof El ? node.textContent : node.text)).join(''); } set textContent(value: string) { for (const node of this.childNodes) node.parentElement = null; this.childNodes = []; if (value !== '') this.append(String(value)); } private adopt(nodes: (El | Txt | string)[]): (El | Txt)[] { return nodes.map(node => { const own = typeof node === 'string' ? new Txt(node) : node; if (own instanceof El) own.remove(); return own; }); } private insert(at: number, nodes: (El | Txt)[]) { this.childNodes.splice(at, 0, ...nodes); for (const node of nodes) node.parentElement = this; } append(...nodes: (El | Txt | string)[]) { this.insert(this.childNodes.length, this.adopt(nodes)); } appendChild(node: T): T { this.append(node); return node; } after(...nodes: (El | Txt | string)[]) { const own = this.adopt(nodes); this.parentElement!.insert(this.parentElement!.childNodes.indexOf(this) + 1, own); } before(...nodes: (El | Txt | string)[]) { const own = this.adopt(nodes); this.parentElement!.insert(this.parentElement!.childNodes.indexOf(this), own); } remove() { const parent = this.parentElement; if (!parent) return; parent.childNodes.splice(parent.childNodes.indexOf(this), 1); this.parentElement = null; } cloneNode(deep = false): El { const copy = new El(this.tagName); for (const [name, value] of this.attributes) copy.setAttribute(name, value); if (deep) copy.append(...this.childNodes.map(node => (node instanceof El ? node.cloneNode(true) : node.text))); return copy; } matches(selector: string): boolean { return parse(selector).some(steps => matchesSteps(this, steps)); } closest(selector: string): El | null { for (let up: El | null = this; up; up = up.parentElement) if (up.matches(selector)) return up; return null; } querySelectorAll(selector: string): El[] { const out: El[] = []; const walk = (el: El) => { for (const child of el.children) { if (child.matches(selector)) out.push(child); walk(child); } }; walk(this); return out; } querySelector(selector: string): El | null { return this.querySelectorAll(selector)[0] ?? null; } addEventListener(type: string, listener: Listener) { (this.listeners.get(type) ?? this.listeners.set(type, []).get(type)!).push(listener); } removeEventListener(type: string, listener: Listener) { this.listeners.set(type, (this.listeners.get(type) ?? []).filter(item => item !== listener)); } /** Calls this element's own listeners (no bubbling). */ emit(type: string, event: Record = {}) { for (const listener of [...(this.listeners.get(type) ?? [])]) listener({ type, target: this, preventDefault() {}, stopPropagation() {}, ...event }); } click() { this.emit('click'); } /** The markup, for comparing two trees. */ get outerHTML(): string { const attrs = [...this.attributes].map(([name, value]) => ` ${name}="${value.replace(/"/g, '"')}"`).join(''); const tag = this.tagName.toLowerCase(); return `<${tag}${attrs}>${this.childNodes.map(node => (node instanceof El ? node.outerHTML : node.text)).join('')}`; } } type Child = El | string | null | false | undefined; /** `h('div', { 'data-testid': 'UserName' }, h('span', null, 'Name'))`. */ export function h(tag: string, attrs?: Record | null, ...children: Child[]): El { const el = new El(tag); for (const [name, value] of Object.entries(attrs ?? {})) el.setAttribute(name, value); el.append(...children.filter((child): child is El | string => !!child)); return el; } /** Shorthand for X's test ids: `t('UserName', ...)` is `
`. */ export const t = (testid: string, ...children: Child[]) => h('div', { 'data-testid': testid }, ...children); /** * Puts `…children` behind an `areaPage` document: its queries and the * elements it creates are this DOM's (a `