Skip to main content

priv/inspector/assets/js/render.js

/**
 * render.js - DOM helpers and content rendering
 */

/**
 * Create a DOM element with attributes and children.
 * @param {string} tag
 * @param {Object} [attrs] - key/value pairs; "class" for className, "on*" for events, "dataset" for data-*, "style" for object styles
 * @param {Array|string|Node} [children]
 * @returns {HTMLElement}
 */
export function el(tag, attrs, children) {
  const node = document.createElement(tag);
  if (attrs) {
    for (const [k, v] of Object.entries(attrs)) {
      if (k === "class" || k === "className") {
        if (v) node.className = v;
      } else if (k === "dataset") {
        for (const [dk, dv] of Object.entries(v)) {
          node.dataset[dk] = dv;
        }
      } else if (k === "style" && typeof v === "object") {
        Object.assign(node.style, v);
      } else if (k.startsWith("on") && typeof v === "function") {
        node.addEventListener(k.slice(2).toLowerCase(), v);
      } else if (k === "htmlFor") {
        node.htmlFor = v;
      } else if (k === "checked" || k === "disabled" || k === "required" || k === "readOnly" || k === "selected" || k === "controls" || k === "autoplay" || k === "multiple") {
        if (v) node[k] = true;
      } else if (v !== null && v !== undefined && v !== false) {
        node.setAttribute(k, v);
      }
    }
  }
  if (children !== undefined && children !== null) {
    appendChildren(node, children);
  }
  return node;
}

function appendChildren(parent, children) {
  if (Array.isArray(children)) {
    for (const child of children) {
      if (child === null || child === undefined) continue;
      if (typeof child === "string" || typeof child === "number") {
        parent.appendChild(document.createTextNode(String(child)));
      } else if (child instanceof Node) {
        parent.appendChild(child);
      } else if (Array.isArray(child)) {
        appendChildren(parent, child);
      }
    }
  } else if (typeof children === "string" || typeof children === "number") {
    parent.appendChild(document.createTextNode(String(children)));
  } else if (children instanceof Node) {
    parent.appendChild(children);
  }
}

/**
 * Try to detect if a string is JSON and return parsed version, or null.
 */
function tryParseJSON(str) {
  if (typeof str !== "string") return null;
  const trimmed = str.trim();
  if ((trimmed.startsWith("{") && trimmed.endsWith("}")) ||
      (trimmed.startsWith("[") && trimmed.endsWith("]"))) {
    try {
      return JSON.parse(trimmed);
    } catch {
      return null;
    }
  }
  return null;
}

/**
 * Render an array of MCP content items into a container.
 * Content types: text, image, audio, resource_link, resource
 */
export function renderContent(contentArray, container) {
  container.innerHTML = "";
  if (!Array.isArray(contentArray)) {
    contentArray = [contentArray];
  }

  for (const item of contentArray) {
    if (!item || typeof item !== "object") continue;

    switch (item.type) {
      case "text": {
        const parsed = tryParseJSON(item.text);
        if (parsed !== null) {
          const wrapper = el("div", {}, []);
          const toggleBtn = el("span", { class: "raw-toggle" }, "Toggle raw");
          const pre = el("pre", {}, JSON.stringify(parsed, null, 2));
          const raw = el("pre", { style: { display: "none" } }, item.text);
          let showingPretty = true;
          toggleBtn.addEventListener("click", () => {
            showingPretty = !showingPretty;
            pre.style.display = showingPretty ? "" : "none";
            raw.style.display = showingPretty ? "none" : "";
          });
          wrapper.append(toggleBtn, pre, raw);
          container.appendChild(wrapper);
        } else {
          container.appendChild(el("pre", { style: { whiteSpace: "pre-wrap" } }, item.text ?? ""));
        }
        break;
      }

      case "image": {
        const src = `data:${item.mimeType || "image/png"};base64,${item.data}`;
        container.appendChild(el("img", { class: "rendered-image", src, alt: "image content" }));
        break;
      }

      case "audio": {
        const audioSrc = `data:${item.mimeType || "audio/wav"};base64,${item.data}`;
        const audio = el("audio", { controls: true, class: "rendered-audio" }, [
          el("source", { src: audioSrc, type: item.mimeType || "audio/wav" })
        ]);
        container.appendChild(audio);
        break;
      }

      case "resource_link": {
        container.appendChild(el("div", { class: "resource-link-row" }, [
          el("span", {}, "🔗"),
          el("code", {}, item.uri || ""),
          item.name ? el("span", { class: "text-muted" }, ` (${item.name})`) : null
        ]));
        break;
      }

      case "resource": {
        if (item.resource) {
          const nested = el("div", { class: "card", style: { marginTop: "8px" } });
          nested.appendChild(el("div", { class: "mono", style: { marginBottom: "4px" } }, item.resource.uri || ""));
          const contentEl = el("div", {});
          renderResourceContents([item.resource], contentEl);
          nested.appendChild(contentEl);
          container.appendChild(nested);
        }
        break;
      }

      default: {
        container.appendChild(el("pre", {}, JSON.stringify(item, null, 2)));
      }
    }
  }
}

/**
 * Render resource contents (from /resources/read response).
 * Each item has {uri, mimeType?, text?, blob?}
 */
export function renderResourceContents(contents, container) {
  container.innerHTML = "";
  if (!Array.isArray(contents)) return;

  for (const item of contents) {
    const wrapper = el("div", { style: { marginBottom: "8px" } });

    if (item.uri) {
      wrapper.appendChild(el("div", { class: "mono", style: { fontSize: "11px", color: "var(--text-muted)", marginBottom: "4px" } }, item.uri));
    }

    if (item.text !== undefined) {
      const mime = item.mimeType || "";
      if (mime === "application/json" || mime.endsWith("+json")) {
        const parsed = tryParseJSON(item.text);
        if (parsed !== null) {
          wrapper.appendChild(el("pre", {}, JSON.stringify(parsed, null, 2)));
        } else {
          wrapper.appendChild(el("pre", {}, item.text));
        }
      } else {
        wrapper.appendChild(el("pre", {}, item.text));
      }
    } else if (item.blob) {
      const mime = item.mimeType || "";
      if (mime.startsWith("image/")) {
        const src = `data:${mime};base64,${item.blob}`;
        wrapper.appendChild(el("img", { class: "rendered-image", src, alt: item.uri || "resource" }));
      } else if (mime.startsWith("audio/")) {
        const audioSrc = `data:${mime};base64,${item.blob}`;
        wrapper.appendChild(el("audio", { controls: true, class: "rendered-audio" }, [
          el("source", { src: audioSrc, type: mime })
        ]));
      } else {
        const byteLength = Math.ceil((item.blob.length * 3) / 4);
        const sizeStr = byteLength > 1024 * 1024
          ? `${(byteLength / (1024 * 1024)).toFixed(1)} MB`
          : byteLength > 1024
            ? `${(byteLength / 1024).toFixed(1)} KB`
            : `${byteLength} bytes`;

        const downloadBtn = el("button", { class: "btn-sm" }, `Download (${sizeStr})`);
        downloadBtn.addEventListener("click", () => {
          const link = document.createElement("a");
          link.href = `data:${mime || "application/octet-stream"};base64,${item.blob}`;
          link.download = (item.uri || "download").split("/").pop();
          link.click();
        });
        wrapper.appendChild(downloadBtn);
      }
    }

    container.appendChild(wrapper);
  }
}