Write buildDom(spec), which turns a plain description of a tree into real DOM nodes and returns the top one.
A spec is a string, a number, or an element spec { tag, attrs, children }. Strings and numbers become text nodes. An element spec becomes document.createElement(tag); its attrs and children are both optional.
For each entry in attrs: a function under a key that starts with on becomes an event listener for the rest of the key, lowercased (onClick listens for click, onKeyDown for keydown). true adds the attribute with an empty value (disabled: true). false, null, and undefined leave the attribute out. Anything else becomes the attribute's value, as a string.
Each child in children is built the same way and appended in order. null, undefined, and false children are skipped, so you can write isAdmin && { tag: "button" }; 0 is still shown. Text always goes in as text, never as HTML.
const button = buildDom({
tag: "button",
attrs: { type: "button", class: "primary", disabled: false, onClick: () => console.log("saved") },
children: ["Save ", { tag: "span", attrs: { class: "count" }, children: [3] }],
});
// <button type="button" class="primary">Save <span class="count">3</span></button>
// clicking it logs "saved"
const text = buildDom("hi");
expect(text.nodeType).toBe(Node.TEXT_NODE);
expect(text.data).toBe("hi");
expect(buildDom(42).data).toBe("42");
const link = buildDom({ tag: "a", attrs: { href: "/docs", class: "link", "data-id": 7 } });
expect(link.tagName).toBe("A");
expect(link.getAttribute("href")).toBe("/docs");
expect(link.className).toBe("link");
expect(link.dataset.id).toBe("7");
const input = buildDom({ tag: "input", attrs: { required: true, disabled: false, title: null, placeholder: undefined } });
expect(input.getAttribute("required")).toBe("");
expect(input.getAttributeNames()).toEqual(["required"]);
expect(buildDom({ tag: "p" }).outerHTML).toBe("<p></p>");
const list = buildDom({
tag: "ul",
children: [
{ tag: "li", children: ["One"] },
{ tag: "li", attrs: { class: "two" }, children: [2] },
],
});
expect(list.outerHTML).toBe('<ul><li>One</li><li class="two">2</li></ul>');
const p = buildDom({ tag: "p", children: ["a", null, false, undefined, 0, "b"] });
expect(p.childNodes).toHaveLength(3);
expect(p.textContent).toBe("a0b");
const onClick = spy();
const onKeyDown = spy();
const button = buildDom({ tag: "button", attrs: { onClick, onKeyDown }, children: ["Go"] });
document.getElementById("app").append(button);
fire(button, "click");
fire(button, "keydown", { key: "Enter" });
expect(onClick).toHaveBeenCalledTimes(1);
expect(onKeyDown).toHaveBeenCalledTimes(1);
expect(button.hasAttribute("onclick")).toBe(false);
const payload = '"><img src=x onerror=alert(1)>';
const p = buildDom({ tag: "p", attrs: { title: payload }, children: [payload] });
expect(p.querySelector("img")).toBeNull();
expect(p.textContent).toBe(payload);
expect(p.getAttribute("title")).toBe(payload);
Run tests runs every test on your code, here in your browser. The problem is solved when they all pass.
Ctrl + Enter runs the tests (⌘ + Enter on a Mac).
I'm here to help with Build DOM from a JSON spec. I give hints, never the solution, so you get to solve it yourself. Ask for a hint, select code to ask about it, or press Ask Tuto why on a failing test.
Your first 3 answers need no account. for free to keep asking.