zebflow 0.11.14

Reactive web automation platform — pipelines, templates, and real-time collaboration in a single binary.
Documentation
import { h } from "zeb/react";
import { sanitizeAttrs, sanitizeTag } from "zeb/ui/editor-extension";

/**
 * Stored document HTML on a page — the narrow door, not a raw-HTML escape.
 *
 *   <DocumentHtml html={row.body_html} />
 *
 * A project that stores `renderDocumentHtml(doc, { extensions })` beside the
 * document's JSON shows it with this. The string is never trusted and never
 * set as HTML: it is parsed here into a tree, and every element is rebuilt
 * through the same allowlist `renderDocumentHtml` and `<DocumentView>` use
 * (`sanitizeTag` / `sanitizeAttrs` in `zeb/ui/editor-extension`). A tampered
 * row still cannot put a script, a style, an `on…` handler, an iframe or a
 * `javascript:` link on the page.
 *
 * The result is ordinary Zeb React elements, so the server and the browser
 * build the same tree from the same string and hydration matches. HTML that
 * `renderDocumentHtml` wrote renders byte for byte as `<DocumentView>` would
 * render its document. A block whose library mounts from markup (the Potoru
 * player's placeholder) is in the server HTML, so the page loads that
 * library — and only when such a block is there. (Never write that marker
 * in this file, even in a comment: RWE reads the client code for it too.)
 * A block whose component needs its own handlers in the page needs
 * `<DocumentView doc>`: stored HTML carries markup, not code.
 *
 * Plain JavaScript (no JSX), like every editor extension file.
 */

// Elements with no end tag and no content.
const VOID_TAGS = new Set(["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr"]);
// Elements whose content is text up to their own end tag, never markup.
const RAW_TEXT_TAGS = new Set(["script", "style", "textarea", "title", "xmp", "iframe", "noembed", "noframes", "noscript"]);
// Attributes that are on when present, whatever their value.
const BOOLEAN_ATTRS = new Set(["checked", "disabled"]);
// Deeper than this, elements are kept as their parent's content.
const MAX_DEPTH = 200;

const NAMED_ENTITIES = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: "\u00a0" };

function decodeEntities(text) {
  if (text.indexOf("&") < 0) return text;
  return text.replace(/&(#[xX][0-9a-fA-F]+|#[0-9]+|[a-zA-Z]+);/g, (whole, body) => {
    if (body[0] === "#") {
      const code = body[1] === "x" || body[1] === "X" ? parseInt(body.slice(2), 16) : parseInt(body.slice(1), 10);
      return Number.isFinite(code) && code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : "\ufffd";
    }
    const named = NAMED_ENTITIES[body.toLowerCase()];
    return named === undefined ? whole : named;
  });
}

const NAME = /[A-Za-z][A-Za-z0-9:-]*/y;
const SPACE = /[\s/]*/y;
const ATTR_NAME = /[^\s"'>/=]+/y;
const ATTR_VALUE = /\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]*))/y;

function readAt(re, text, at) {
  re.lastIndex = at;
  return re.exec(text);
}

/** A start tag at `at` (just after `<`): `{ tag, attrs, end, selfClosing }`, or null. */
function readStartTag(text, at) {
  const name = readAt(NAME, text, at);
  if (!name) return null;
  let i = at + name[0].length;
  const attrs = {};
  for (;;) {
    i += readAt(SPACE, text, i)[0].length;
    if (i >= text.length) return { tag: name[0].toLowerCase(), attrs, end: text.length, selfClosing: false };
    if (text[i] === ">") {
      return { tag: name[0].toLowerCase(), attrs, end: i + 1, selfClosing: text[i - 1] === "/" };
    }
    const attr = readAt(ATTR_NAME, text, i);
    if (!attr) { i += 1; continue; }
    i += attr[0].length;
    const value = readAt(ATTR_VALUE, text, i);
    let raw = "";
    if (value) {
      i += value[0].length;
      raw = value[1] ?? value[2] ?? value[3] ?? "";
    }
    const key = attr[0].toLowerCase();
    if (!(key in attrs)) attrs[key] = decodeEntities(raw);
  }
}

/**
 * HTML → a tree of `{ tag, attrs, children }` and strings. Tolerant: an end
 * tag closes the nearest open element of its name and is ignored when none
 * is open; comments, doctypes and processing instructions are dropped.
 */
export function parseDocumentHtml(html) {
  const text = typeof html === "string" ? html : "";
  const root = { tag: "", attrs: {}, children: [] };
  const stack = [root];
  const top = () => stack[stack.length - 1];
  const pushText = (value) => { if (value) top().children.push(decodeEntities(value)); };
  let i = 0;
  while (i < text.length) {
    const lt = text.indexOf("<", i);
    if (lt < 0) { pushText(text.slice(i)); break; }
    pushText(text.slice(i, lt));
    const next = text[lt + 1];
    if (text.startsWith("<!--", lt)) {
      const close = text.indexOf("-->", lt + 4);
      i = close < 0 ? text.length : close + 3;
    } else if (next === "!" || next === "?") {
      const close = text.indexOf(">", lt);
      i = close < 0 ? text.length : close + 1;
    } else if (next === "/") {
      const name = readAt(NAME, text, lt + 2);
      const close = text.indexOf(">", lt);
      i = close < 0 ? text.length : close + 1;
      if (!name) continue;
      const tag = name[0].toLowerCase();
      for (let s = stack.length - 1; s > 0; s -= 1) {
        if (stack[s].tag === tag) { stack.length = s; break; }
      }
    } else {
      const start = readStartTag(text, lt + 1);
      if (!start) { pushText("<"); i = lt + 1; continue; }
      i = start.end;
      const element = { tag: start.tag, attrs: start.attrs, children: [] };
      if (stack.length > MAX_DEPTH) {
        if (!VOID_TAGS.has(start.tag) && !start.selfClosing) continue;
      } else {
        top().children.push(element);
      }
      if (RAW_TEXT_TAGS.has(start.tag)) {
        const close = text.toLowerCase().indexOf(`</${start.tag}`, i);
        const end = close < 0 ? text.length : close;
        if (end > i) element.children.push(text.slice(i, end));
        const gt = close < 0 ? -1 : text.indexOf(">", close);
        i = gt < 0 ? text.length : gt + 1;
      } else if (!VOID_TAGS.has(start.tag) && !start.selfClosing && stack.length <= MAX_DEPTH) {
        stack.push(element);
      }
    }
  }
  return root.children;
}

/** One parsed node as an element, through the document allowlist. */
function toElement(node, key) {
  if (typeof node === "string") return node;
  const name = sanitizeTag(node.tag);
  if (!name) return null;
  const attrs = {};
  for (const [attr, value] of Object.entries(node.attrs)) {
    attrs[attr] = value === "" && BOOLEAN_ATTRS.has(attr) ? true : value;
  }
  const clean = sanitizeAttrs(name, attrs, false);
  // `class` printed last, as the document renderer prints it.
  if (clean.class !== undefined) { const cls = clean.class; delete clean.class; clean.className = cls; }
  if (VOID_TAGS.has(name)) return h(name, { key, ...clean });
  return h(name, { key, ...clean }, ...toChildren(node.children));
}

function toChildren(nodes) {
  const out = [];
  nodes.forEach((node, i) => {
    const element = toElement(node, i);
    if (element !== null && element !== "") out.push(element);
  });
  return out;
}

/** Stored document HTML, sanitized, as elements: the same on the server and in the browser. */
export function DocumentHtml({ html, className }) {
  return h("div", { "data-slot": "document", className }, ...toChildren(parseDocumentHtml(html)));
}

export default DocumentHtml;