Skip to the code
cf.

Build DOM from a JSON spec

MediumDOM

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"

What the tests check

  1. turns strings and numbers into text nodes
    const text = buildDom("hi");
    expect(text.nodeType).toBe(Node.TEXT_NODE);
    expect(text.data).toBe("hi");
    expect(buildDom(42).data).toBe("42");
    
  2. creates the element with its attributes
    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");
    
  3. true adds an empty attribute; false, null, and undefined leave it out
    const input = buildDom({ tag: "input", attrs: { required: true, disabled: false, title: null, placeholder: undefined } });
    expect(input.getAttribute("required")).toBe("");
    expect(input.getAttributeNames()).toEqual(["required"]);
    
  4. attrs and children are optional
    expect(buildDom({ tag: "p" }).outerHTML).toBe("<p></p>");
    
  5. builds nested children in order
    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>');
    
  6. skips null, undefined, and false children, but keeps 0
    const p = buildDom({ tag: "p", children: ["a", null, false, undefined, 0, "b"] });
    expect(p.childNodes).toHaveLength(3);
    expect(p.textContent).toBe("a0b");
    
  7. attaches on* functions as event listeners
    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);
    
  8. inserts text as text, never as HTML
    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);
    

Learn more

solution.jsSaved in this browser

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).