Rasterize content
Slack has no Mermaid and no LaTeX. slackmark-node renders both through a local Chromium
and uploads the resulting PNG assets through the configured uploader. It returns Slack
file-backed image blocks; your application posts the message payload. This is the message
the quickstart below produces:
```mermaid flowchart LR Agent --> Slack ``` $$ x^2 + y^2 = z^2 $$
slackmark-node run rather than produced live, so the pictures are the renderer's own PNGs.Block Kit JSON
Chromium cannot run in a browser, so this figure replays a committed recording of a real createNodeConverter() run (slackmark-node with mermaid 11.16.0, KaTeX 0.18.1, Chromium via puppeteer 25.3.0). Regenerate it with pnpm docs:raster-examples. The pictures are painted from PNG bytes committed to this repo (528×140, 5 kB, renderer bytes; 324×126, 4 kB, renderer bytes), substituted for the payload's image reference because a browser cannot fetch it. A capturing stub stood in for the Slack uploader, so the slack_file IDs below are synthetic; a real upload returns workspace file IDs. Nothing else was touched: the JSON below is what convert() returned.
[
{
"type": "image",
"alt_text": "mermaid diagram",
"slack_file": {
"id": "F09DOCS0001"
}
},
{
"type": "image",
"alt_text": "display math",
"slack_file": {
"id": "F09DOCS0002"
}
}
]Install
pnpm add slackmark slackmark-nodeRequires Node.js 22.12+. This complete flow uses standard fetch; install a Slack SDK
only if your app otherwise needs it.
Create a bot with chat:write and files:write, invite it to the channel, save the
first program below as quickstart.mjs, then run:
SLACK_BOT_TOKEN=xoxb-… SLACK_CHANNEL_ID=C… \
node quickstart.mjsQuickstart
Happy path only, with no retry or crash recovery:
import { FetchSlackUploader } from "slackmark/slack";
import { createNodeConverter } from "slackmark-node";
const token = process.env["SLACK_BOT_TOKEN"];
const channel = process.env["SLACK_CHANNEL_ID"];
if (!token || !channel) throw new Error("SLACK_BOT_TOKEN and SLACK_CHANNEL_ID required");
const markdown = `\`\`\`mermaid
flowchart LR
Agent --> Slack
\`\`\`
$$
x^2 + y^2 = z^2
$$`;
const converter = createNodeConverter({ uploader: new FetchSlackUploader({ token }) });
try {
const { blocks, text } = await converter.convert(markdown, { timeoutMs: 30_000 });
const response = await fetch("https://slack.com/api/chat.postMessage", {
method: "POST",
headers: {
authorization: `Bearer ${token}`,
"content-type": "application/json; charset=utf-8",
},
body: JSON.stringify({ channel, blocks, text }),
});
const body = await response.json();
if (!response.ok || body.ok !== true) throw new Error("Slack rejected the post");
} finally {
await converter.close();
}Shut down cleanly
For production, the complete program below separates a definite rejection from an ambiguous
timeout or lost response, and preserves a conversion error and a close error together. That
last part is the shape worth copying — converter is the one built above, and postPrepared
is the prepared-envelope call the full file carries:
let primaryError: unknown;
try {
const result = await converter.convert(markdown, { timeoutMs: CONVERT_TIMEOUT_MS });
await postPrepared(token, {
channel,
blocks: result.blocks,
text: result.text,
receipts: result.receipts,
});
} catch (error) {
primaryError = error;
throw error;
} finally {
try {
await converter.close({ behavior: "drain", timeoutMs: CLOSE_TIMEOUT_MS });
} catch (closeError) {
if (primaryError !== undefined) {
throw new AggregateError([primaryError, closeError], "Operation and close both failed");
}
throw closeError;
}
}That is the same prepared-envelope pattern as Getting started, and it is why a close failure can never quietly replace the error that actually broke the run.
On post failure, the thrown error retains the exact prepared envelope. Upload to Slack covers retry ambiguity, optional crash-recovery persistence, and sensitive receipts without repeating that policy here.
Next: Custom renderers, Upload to Slack, and Production.