zebflow 0.11.14

Reactive web automation platform — pipelines, templates, and real-time collaboration in a single binary.
Documentation
/**
 * Editor extension — how a project adds a block, an inline node or a mark to
 * `zeb/ui/editor`, and how the same declaration renders it everywhere.
 *
 * One `render(node, r)` serves three places: the editor's live DOM (through
 * the engine's toDOM), `<DocumentView>` elements, and the `renderDocumentHtml`
 * string. All three build through `r.h`, which keeps only an allowlist of
 * tags and attributes — a script, a style, an `on…` handler or a
 * `javascript:` link never leaves this file — so the published HTML is
 * sanitized whatever an extension or a stored document says.
 *
 *   defineExtension({
 *     name: "note",
 *     node: { group: "block", content: "inline*", attrs: { tone: { default: "info" } } },
 *     render: (node, r) => r.h("p", { class: "my-2 border-l-2 pl-3", "data-note": node.attrs.tone }, r.content),
 *     parse: [{ tag: "p[data-note]", getAttrs: (dom) => ({ tone: dom.getAttribute("data-note") }) }],
 *     insert: [{ label: "Note", hint: "A short aside", keys: "note", run: (api) => api.insert("note") }],
 *   })
 *
 * A node may be drawn by a Zeb React component instead of `render` — see
 * `component` and `editComponent` below.
 *
 * This file is plain JavaScript (no JSX) on purpose: the server, the browser
 * and the engine load the same object.
 */

// Engine-defined names an extension may not reuse.
const BASE_NAMES = [
  "doc", "paragraph", "heading", "blockquote", "code_block", "horizontal_rule", "image", "bullet_list",
  "ordered_list", "todo_list", "list_item", "todo_item", "text", "hard_break", "unknown_block", "unknown_text",
  "unknown_inline", "link", "bold", "italic", "underline", "strike", "code",
];

// Elements whose content is never published: dropped with everything inside.
const DROPPED_TAGS = new Set([
  "script", "style", "iframe", "frame", "frameset", "object", "embed", "applet", "link", "meta", "base",
  "template", "noscript", "svg", "math", "form", "textarea", "select", "button", "option", "title", "head",
  "html", "body",
]);

// Elements a document may contain. Anything else is rendered as a `span`.
const ALLOWED_TAGS = new Set([
  "p", "h1", "h2", "h3", "h4", "h5", "h6", "blockquote", "aside", "div", "span", "a", "strong", "b", "em", "i",
  "u", "s", "del", "ins", "code", "pre", "kbd", "mark", "small", "sub", "sup", "cite", "q", "abbr", "time", "br",
  "hr", "img", "figure", "figcaption", "ul", "ol", "li", "dl", "dt", "dd", "table", "thead", "tbody", "tfoot",
  "tr", "th", "td", "caption", "section", "header", "footer", "article", "details", "summary", "input", "label",
]);

const ALLOWED_ATTRS = new Set([
  "class", "id", "title", "alt", "href", "src", "width", "height", "colspan", "rowspan", "start", "rel",
  "target", "lang", "dir", "datetime", "cite", "loading", "role", "type", "checked", "disabled", "contenteditable",
]);
const URL_ATTRS = new Set(["href", "src", "cite"]);

/** Only links a browser can be trusted to follow. Anything else becomes `#`. */
export function safeHref(href) {
  const value = String(href || "").trim();
  if (/^(https?:|mailto:|tel:|\/|#|\.)/i.test(value)) return value;
  return "#";
}

/**
 * The attributes an element may publish, by the allowlist. `className` is
 * read as `class`; `contenteditable` survives only in the editor (`edit`).
 */
export function sanitizeAttrs(tag, attrs, edit) {
  const out = {};
  for (const [raw, value] of Object.entries(attrs || {})) {
    if (value === undefined || value === null || value === false) continue;
    const name = raw === "className" ? "class" : raw;
    if (!/^[a-z][a-z0-9-]*$/.test(name)) continue;
    const allowed = ALLOWED_ATTRS.has(name) || name.startsWith("data-") || name.startsWith("aria-");
    if (!allowed) continue;
    if (name === "contenteditable" && !(edit && value === "false")) continue;
    if (name === "type" && tag !== "input") continue;
    if (name === "target" && value !== "_blank") continue;
    out[name] = URL_ATTRS.has(name) ? safeHref(value) : value === true ? true : String(value);
  }
  if (tag === "input") { out.type = "checkbox"; if (!edit) out.disabled = true; }
  if (out.target) out.rel = "noopener noreferrer";
  return out;
}

/** A tag by the allowlist: `null` drops the element and its content. */
export function sanitizeTag(tag) {
  const name = String(tag || "").toLowerCase();
  if (DROPPED_TAGS.has(name)) return null;
  return ALLOWED_TAGS.has(name) ? name : "span";
}

function flatten(children, out = []) {
  for (const child of children) {
    if (Array.isArray(child) && typeof child[0] !== "string") flatten(child, out);
    else if (child !== null && child !== undefined && child !== false && child !== "") out.push(child);
  }
  return out;
}

/**
 * The editor's `r.h`: builds a ProseMirror DOM spec. `r.content` is the hole
 * (0) where the node's children go; it must be the only child of its element.
 */
function specH(tag, attrs, ...children) {
  const name = sanitizeTag(tag);
  if (!name) return "";
  return [name, sanitizeAttrs(name, attrs, true), ...flatten(children)];
}

function normalizeNodes(def) {
  const nodes = {};
  if (def.node) {
    nodes[def.name] = { spec: def.node, render: def.render, parse: def.parse, text: def.text, component: def.component, editComponent: def.editComponent };
  }
  for (const [type, entry] of Object.entries(def.nodes || {})) nodes[type] = entry;
  for (const [type, entry] of Object.entries(nodes)) checkComponents(def.name, type, entry);
  return nodes;
}

function normalizeMarks(def) {
  const marks = {};
  if (def.mark) {
    if (def.component || def.editComponent) throw new Error(`editor extension "${def.name}": a mark is drawn by render, not by a component`);
    marks[def.name] = { spec: def.mark, render: def.render, parse: def.parse };
  }
  for (const [type, entry] of Object.entries(def.marks || {})) marks[type] = entry;
  return marks;
}

/**
 * A node drawn by a component is an atom (its attrs are its content), and
 * its published look comes from exactly one place: `render` or `component`.
 */
function checkComponents(name, type, entry) {
  for (const key of ["component", "editComponent"]) {
    if (entry[key] !== undefined && typeof entry[key] !== "function") throw new Error(`editor extension "${name}": ${key} of "${type}" must be a component (a function)`);
  }
  if (!entry.component && !entry.editComponent) return;
  if (entry.component && entry.render) throw new Error(`editor extension "${name}": "${type}" has both render and component; the page draws it with one of them`);
  if (entry.spec && entry.spec.content) throw new Error(`editor extension "${name}": "${type}" is drawn by a component, so it is an atom: give it attrs, not content`);
}

/**
 * How a component node is written when ProseMirror itself serializes it
 * (copy and paste, `getHTML`): its attrs as JSON, read back by the same rule.
 */
function componentDom(type, inline) {
  const tag = inline ? "span" : "div";
  return {
    toDOM: (value) => [tag, { "data-zeb-node": type, "data-attrs": JSON.stringify(value.attrs) }],
    parse: [{
      tag: `${tag}[data-zeb-node="${type}"]`,
      getAttrs: (dom) => { try { return JSON.parse(dom.getAttribute("data-attrs") || "{}"); } catch { return false; } },
    }],
  };
}

/** What `r` holds while the editor draws a node: no numbering, the hole for content. */
const EDIT_CONTEXT = { h: specH, content: 0, edit: true, safeHref, collect: () => null, collected: () => [] };

/**
 * Declare an extension. Returns the object `<Editor extensions>`,
 * `<DocumentView extensions>` and `renderDocumentHtml(doc, { extensions })`
 * all take.
 *
 * def: {
 *   name,                       unique; also the node's type when `node` is given
 *   node | mark,                ProseMirror spec without toDOM/parseDOM
 *   render(node, r),            r: { h(tag, attrs, ...children), content, edit, collect, collected, safeHref }
 *   parse,                      ProseMirror parseDOM rules (optional)
 *   text(node),                 the node's plain-text form (atoms)
 *   nodes / marks,              more than one type: { type: { spec, render, parse, text } }
 *   footer(r),                  rendered once after the document (references…)
 *   insert,                     slash-menu items: [{ label, hint, keys, run(api) }]
 *   trigger, picker,            "@" and { search(query) → items, toAttrs(item), type? }
 *   fields, actions,            the panel for a node under the caret
 *   inputRules, keymap, plugins, commands   (schema, pm) → engine pieces
 *   component,                  a Zeb React component that draws the node (instead of render)
 *   editComponent,              a Zeb React component that draws it in the editor only
 *   options,                    the extension's configuration, handed to both components
 * }
 *
 * Components (`zeb/ui/editor-component` holds the machinery). A node drawn
 * by a component is an atom: no content, only attrs. Both get the same props:
 *
 *   { node: { type, attrs }, attrs, options, edit, selected, readOnly, update(patch) }
 *
 * `component` is the published look: `<DocumentView>` renders it (so the
 * page's SSR prints it and the page hydrates it), `renderDocumentHtml` prints
 * it with zeb/react's `renderToString` (the same bytes), and the editor draws
 * it too unless `editComponent` is given. Its output passes the same
 * tag/attribute/URL allowlist as `render`; function props such as `onClick`
 * are kept, since they come from code, never from a stored document. There
 * `edit` is false, `readOnly` true and `update` does nothing.
 *
 * `editComponent` is editor chrome: it runs only in the browser, as the
 * node's ProseMirror node view, is not sanitized, may use form controls, and
 * changes the node with `update({ attr: value })`. Pair it with `render` or
 * `component` for the published look.
 */
export function defineExtension(def) {
  if (!def || typeof def.name !== "string" || !/^[a-z][a-z0-9_]*$/.test(def.name)) {
    throw new Error("an editor extension needs a name: lowercase letters, digits and _");
  }
  const nodes = normalizeNodes(def);
  const marks = normalizeMarks(def);
  const toSpec = (entry, isMark, type) => {
    const spec = { ...entry.spec };
    if (entry.parse) spec.parseDOM = entry.parse;
    if (!isMark && !entry.render && (entry.component || entry.editComponent)) {
      const dom = componentDom(type, entry.spec.inline);
      if (!entry.parse) spec.parseDOM = dom.parse;
      spec.toDOM = dom.toDOM;
      return spec;
    }
    spec.toDOM = (value) => {
      const out = entry.render ? entry.render(value, EDIT_CONTEXT) : null;
      if (out) return out;
      return isMark || entry.spec.content ? [entry.spec.inline || isMark ? "span" : "div", 0] : ["span"];
    };
    return spec;
  };
  return {
    ...def,
    panel: Boolean((def.fields && def.fields.length) || (def.actions && def.actions.length)),
    renderers: { nodes, marks },
    nodes: Object.fromEntries(Object.entries(nodes).map(([type, entry]) => [type, toSpec(entry, false, type)])),
    marks: Object.fromEntries(Object.entries(marks).map(([type, entry]) => [type, toSpec(entry, true, type)])),
    insert: def.insert || [],
    options: def.options || {},
  };
}

/**
 * The extensions a document is rendered with, checked: one name each, and no
 * type defined twice or over one of the engine's own.
 */
export function composeExtensions(extensions) {
  const registry = { list: [], nodes: {}, marks: {}, byName: {} };
  for (const ext of extensions || []) {
    if (!ext || !ext.renderers) throw new Error("an editor extension must come from defineExtension");
    if (registry.byName[ext.name]) throw new Error(`editor extension "${ext.name}" is listed twice`);
    // One character opens one picker: two kinds on "@" would leave the second unreachable.
    const sharing = ext.trigger && registry.list.find((other) => other.trigger === ext.trigger);
    if (sharing) throw new Error(`editor extensions "${sharing.name}" and "${ext.name}" share the trigger "${ext.trigger}"`);
    registry.byName[ext.name] = ext;
    registry.list.push(ext);
    for (const [kind, table] of [["nodes", ext.renderers.nodes], ["marks", ext.renderers.marks]]) {
      for (const [type, entry] of Object.entries(table)) {
        if (BASE_NAMES.includes(type) || registry.nodes[type] || registry.marks[type]) {
          throw new Error(`editor extension "${ext.name}" redefines "${type}"`);
        }
        registry[kind][type] = { ...entry, ext };
      }
    }
  }
  return registry;
}

/** A dotted attr path (`snapshot.title`) read from attrs. */
export function readAttrPath(attrs, path) {
  return path.split(".").reduce((value, key) => (value && typeof value === "object" ? value[key] : undefined), attrs);
}

/** The attrs with one dotted path set, copied — never mutated in place. */
export function writeAttrPath(attrs, path, value) {
  const [head, ...rest] = path.split(".");
  if (rest.length === 0) return { ...attrs, [head]: value };
  const inner = attrs && typeof attrs[head] === "object" && attrs[head] ? attrs[head] : {};
  return { ...attrs, [head]: writeAttrPath(inner, rest.join("."), value) };
}

/**
 * A picker that asks a project route: `GET route?q=…` answers a JSON array
 * of `{ id, label, href, snapshot }`. Anything else is an error the picker
 * shows, not an empty list.
 */
export function routeSearch(route) {
  return async (query) => {
    const url = `${route}${route.includes("?") ? "&" : "?"}q=${encodeURIComponent(query)}`;
    const response = await fetch(url, { credentials: "same-origin", headers: { Accept: "application/json" } });
    if (!response.ok) throw new Error(`${route} answered ${response.status}`);
    const items = await response.json();
    if (!Array.isArray(items)) throw new Error(`${route} must answer a JSON array of { id, label, href, snapshot }`);
    return items;
  };
}

export default defineExtension;