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 ~~~
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.
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:
/** 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
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.