Custom renderers

additionalRenderers runs after the local Mermaid and KaTeX renderers. Reach for it when you have a fenced language they do not handle, or an image service you already run.

PlantUML URL with Kroki

No built-in renderer claims a plantuml fence, so it falls through to KrokiRenderer, which returns a URL rather than bytes:

~~~plantuml
Alice -> Bob: authenticated request
Bob --> Alice: 200 OK
~~~
A PlantUML fence routed to KrokiRenderer, which emits a URL instead of bytes. Recorded from a real slackmark-node run rather than produced live. The renderer returned a URL, and the picture is what that URL served at recording time.
Block Kit JSON

This figure replays a committed recording of a real createNodeConverter() run. Regenerate it with pnpm docs:raster-examples. The picture is painted from PNG bytes committed to this repo (228×150, 2.7 kB, fetched from the renderer's URL), substituted for the payload's image reference because a browser cannot fetch it. Nothing else was touched: the JSON below is what convert() returned.

[
  {
    "type": "image",
    "image_url": "https://kroki.io/plantuml/png/eJxzzMlMTlXQtVNwyk-yUkgsLclIzSvJTE4sSU1RKEotLE0tLuFyyk9S0NW1U3AEqbVSMDIwUPD3BgAjkhH8",
    "alt_text": "plantuml diagram"
  }
]
import { KrokiRenderer } from "slackmark/renderers";
import { FetchSlackUploader } from "slackmark/slack";
import { createNodeConverter } from "slackmark-node";

declare const slackBotToken: string;

const converter = createNodeConverter({
  additionalRenderers: [new KrokiRenderer()],
  uploader: new FetchSlackUploader({ token: slackBotToken }),
});

try {
  await converter.convert("~~~plantuml\nAlice -> Bob: authenticated request\n~~~");
} finally {
  await converter.close();
}

The renderer ID is kroki; PlantUML aliases include plantuml and puml. Its default is the public https://kroki.io. Slack later fetches that source-bearing URL, so URL block construction proves neither reachability nor rendering. A self-hosted base URL must be anonymously reachable by Slack; the source still leaves slackmark in the URL. The uploader above keeps built-in Mermaid/KaTeX bytes usable in mixed documents.

Bounded PlantUML bytes endpoint

A renderer is two members: canRender claims a fenced language, and render returns the image. BytesImageRenderer, RenderRequest, OperationContext and BytesImage are all defined in the ImageRenderer API. Everything else on this page is about doing that safely: send and readCappedBody are the two helpers below, and PlantUmlRenderError is a small Error subclass carrying a fixed (code, stage, retryable) triple.

plantuml-bytes-renderer.ts · excerptView on GitHub
export class PlantUmlBytesRenderer implements BytesImageRenderer {
  readonly id = "plantuml-bytes";
  readonly output = "bytes";
  private readonly endpoint: URL;
  private readonly authorization: string;

  constructor(endpoint: string, token: string) {
    const parsed = new URL(endpoint);
    if (parsed.protocol !== "https:" || parsed.username !== "" || parsed.password !== "") {
      throw new Error("PlantUML endpoint must be credential-free HTTPS");
    }
    this.endpoint = parsed;
    this.authorization = `Bearer ${token}`;
  }

  canRender(request: RenderRequest): boolean {
    return request.lang === "plantuml";
  }

  async render(request: RenderRequest, context: OperationContext): Promise<BytesImage> {
    const response = await send(this.endpoint, this.authorization, request, context);
    return {
      kind: "bytes",
      data: await readCappedBody(response),
      mimeType: "image/png",
      filename: "plantuml.png",
    };
  }
}

Bound what comes back

The request refuses redirects, then re-checks the origin it actually reached, the content type, and the declared length. readCappedBody in the full file streams the response and abandons it past a 10 MB ceiling, so an oversized reply is never buffered whole:

plantuml-bytes-renderer.ts · excerptView on GitHub
/** Refuses redirects, then re-checks the origin actually reached and the content type. */
async function send(
  endpoint: URL,
  authorization: string,
  request: RenderRequest,
  context: OperationContext,
): Promise<Response> {
  let response: Response;
  try {
    response = await fetch(endpoint, {
      method: "POST",
      redirect: "error",
      headers: { authorization, "content-type": "text/plain; charset=utf-8" },
      body: request.source,
      signal: context.signal ?? null,
    });
  } catch {
    throw new PlantUmlRenderError("plantuml_transport", "render", true);
  }
  if (new URL(response.url).origin !== endpoint.origin) {
    throw new PlantUmlRenderError("plantuml_origin", "validation", false);
  }
  if (!response.ok) {
    throw new PlantUmlRenderError("plantuml_http", "render", response.status >= 500);
  }
  if (response.headers.get("content-type")?.split(";")[0] !== "image/png") {
    throw new PlantUmlRenderError("plantuml_content_type", "validation", false);
  }
  return response;
}

Wire it up

plantuml-bytes-renderer.ts · excerptView on GitHub
export const converter = createNodeConverter({
  additionalRenderers: [
    new PlantUmlBytesRenderer(
      requiredEnvironment("PLANTUML_RENDER_URL"),
      requiredEnvironment("PLANTUML_RENDER_TOKEN"),
    ),
  ],
  uploader: new FetchSlackUploader({ token: requiredEnvironment("SLACK_BOT_TOKEN") }),
});

Errors expose only fixed codes and stages, never endpoint responses or source. slackmark's core runs its own checks on MIME type, magic bytes, and size after your renderer returns. The PlantUML source is POSTed out of the process to the configured service. Endpoint authentication, trust, retention, and logging are therefore yours to own, even though only the returned image bytes reach Slack.

If you want to replace the pipeline outright, slackmark.createConverter() is still there. Extending it the way this page does never means rebuilding the Puppeteer graph.