@heumsi/slackmd
v0.1.1
Published
Convert Markdown to Slack Block Kit JSON. Inspired by emailmd.
Maintainers
Readme
slackmd
Write Markdown, get Slack Block Kit.
slackmd converts Markdown into Slack Block Kit JSON you can drop straight into chat.postMessage's blocks field.
It is inspired by emailmd — where emailmd turns Markdown into email-safe HTML, slackmd turns it into Slack-safe Block Kit. Write in a format you already know; get clean output for a fussy rendering surface. If you've used emailmd, slackmd should feel immediately familiar.
Why slackmd?
Slack has a native markdown block — so why a tool? Because it falls short exactly where rich messages need it:
| | Raw markdown block | slackmd |
|---|---|---|
| Images | Degraded to link text | Real image blocks |
| Headings | All levels render identically | header blocks (visual hierarchy) |
| Buttons / footers | Not possible | actions / context via directives |
| Tables | Best-effort text | Native table blocks |
| Output | Opaque string | Diffable, testable JSON |
| Problems | Silent | Surfaced in a warnings[] array |
slackmd compiles Markdown to fully structured Block Kit by default — and can still emit a native markdown block when you want one (see Rendering engines).
Install
npm install @heumsi/slackmdRequires Node.js ≥ 20. The package is pure ESM.
Quick start
Library
import { slackmd } from "@heumsi/slackmd";
const { blocks, warnings } = slackmd(`
# Deployment complete
The service is now running in **us-east-1**.
- Version: 2.3.1
- Duration: 45s
`);
await client.chat.postMessage({ channel: "#deploys", blocks });slackmd is a pure function and never throws. Anything that can't map cleanly to Block Kit is downgraded gracefully and reported in warnings — see Warnings and limits.
CLI
slackmd notes.md # → { "blocks": [...] } on stdout
cat notes.md | slackmd --minify # compact, reading from stdinFull flags are in the CLI reference.
Worked example
Input
---
engine: rich_text
---
# Incident: DB Latency Spike
:::callout{level=warning}
Elevated latency detected on `db-primary`. Reads degraded, writes unaffected.
:::
## Timeline
- **14:02** — alert fired
- **14:08** — root cause identified (slow query, no index)
- **14:23** — hotfix deployed, latency normal
:::buttons
::button[View Runbook]{url=https://wiki.example.com/runbook style=primary}
::button[View Metrics]{url=https://metrics.example.com}
:::
:::context
Reported by PagerDuty · auto-posted by slackmd
:::Output (abbreviated)
{
"blocks": [
{ "type": "header", "text": { "type": "plain_text", "text": "Incident: DB Latency Spike", "emoji": true } },
{ "type": "rich_text", "elements": [{ "type": "rich_text_quote", "elements": [{ "type": "text", "text": "⚠️ " }, { "type": "text", "text": "Elevated latency detected on " }, { "type": "text", "text": "db-primary", "style": { "code": true } }, { "type": "text", "text": ". Reads degraded, writes unaffected." }] }] },
{ "type": "header", "text": { "type": "plain_text", "text": "Timeline", "emoji": true } },
{ "type": "rich_text", "elements": [{ "type": "rich_text_list", "style": "bullet", "indent": 0, "elements": ["..."] }] },
{ "type": "actions", "elements": [{ "type": "button", "text": { "type": "plain_text", "text": "View Runbook", "emoji": true }, "url": "https://wiki.example.com/runbook", "style": "primary" }, { "type": "button", "text": { "type": "plain_text", "text": "View Metrics", "emoji": true }, "url": "https://metrics.example.com" }] },
{ "type": "context", "elements": [{ "type": "mrkdwn", "text": "Reported by PagerDuty · auto-posted by slackmd" }] }
]
}Mapping reference
slackmd targets the message surface (chat.postMessage) and emits only message-safe blocks.
| Markdown | Block Kit output |
|---|---|
| # H1, ## H2 | header block (plain_text, up to 150 chars) |
| ### H3 … ###### H6 | bold rich_text_section |
| Paragraph, bold, italic, ~~strike~~, `code`, links | rich_text block |
| :emoji_name: | emoji element inside rich_text |
| - item, * item | rich_text_list (bullet) |
| 1. item | rich_text_list (ordered) |
| - nested | rich_text_list with indent |
| - [ ] task, - [x] done | rich_text_list with ☐/☑ (or emoji) |
| > blockquote | rich_text_quote |
| ```lang … ``` | rich_text_preformatted |
| --- | divider block |
|  | image block |
| GFM table | table block (column alignment preserved) |
| Raw HTML | stripped (HTML_STRIPPED warning) |
| :::buttons :::context :::fields :::callout | see Directives |
Rendering engines
Set the engine via the engine option (or --engine on the CLI).
rich_text (default)
Parses Markdown and emits fully structured Block Kit — header, rich_text, table, image, divider, and directive blocks. Full control over the output, broad compatibility across all bot types. Recommended.
markdown
Passes prose through Slack's native markdown block (one Slack-flavored Markdown string), intercepting only what that block can't do well: images become image blocks and directives become their own blocks.
Caveat: Slack documents the
markdownblock as tied to AI-feature apps, so its availability for a plain bot token isn't guaranteed. Preferrich_textwhen in doubt.
Directives
Slack-native components that Markdown has no syntax for, written with remark-directive syntax. Directives work in both engines.
buttons → actions block
:::buttons
::button[View docs]{url=https://example.com style=primary}
::button[Cancel]{style=danger}
::button[Learn more]{url=https://example.com/learn value=learn}
:::Each ::button supports url (link to open), style (primary or danger; omit for default), and value (payload for your action handler). An empty :::buttons is skipped with an EMPTY_BUTTONS warning.
context → context block
:::context
Posted by **DeployBot** · Region: us-east-1
:::Small, muted text (with optional images) — ideal for footers, captions, and metadata.
fields → section with two-column fields
:::fields
- Status: Active
- Region: us-east-1
- Owner: alice
:::Each list item becomes a field, rendered in a two-column grid. An empty :::fields is skipped with an EMPTY_FIELDS warning.
callout → rich_text_quote with a leading emoji
:::callout{level=warning}
Avoid deploying during peak hours.
:::| Level | Emoji | Use for |
|---|---|---|
| info | ℹ️ | Informational messages |
| warning | ⚠️ | Caution / potential issue |
| error | ⛔ | Errors / failures |
| success | ✅ | Confirmations |
| note (default) | 📝 | Notes / reminders |
Slack's
alertblock is modal-only, so callouts render asrich_text_quoteblocks to display in messages.
Configuration
The same three options can be passed to the library, set in a document's frontmatter, or (on the CLI) given as flags. Precedence: caller option > frontmatter > default.
| Option | Type | Default | Description |
|---|---|---|---|
| engine | rich_text | markdown | rich_text | Rendering engine (see above) |
| headerLevel | 1–6 | 2 | Highest heading level that becomes a header block; deeper headings become bold text |
| taskList | unicode | emoji | unicode | Task checkbox style: ☐/☑ or :white_large_square:/:white_check_mark: |
Frontmatter — set defaults for a single document:
---
engine: rich_text
headerLevel: 2
taskList: unicode
---
# My documentUnknown keys or invalid values are ignored with a warning (UNKNOWN_FRONTMATTER_KEY / INVALID_FRONTMATTER_VALUE).
Headings — Markdown has six heading levels; Slack's header block has a single size and holds plain text only. So levels up to headerLevel become header blocks and the rest become bold rich_text. A heading longer than 150 characters or containing inline formatting also falls back to bold rich_text — no text is lost (HEADER_TRUNCATED / HEADER_FALLBACK_BOLD).
CLI reference
slackmd [input] [options]| Flag | Default | Description |
|---|---|---|
| [input] | stdin | Markdown file path; reads stdin if omitted |
| -o, --out <file> | stdout | Write output to a file |
| -e, --engine <engine> | rich_text | rich_text or markdown |
| --header-level <n> | 2 | Max heading level for header blocks |
| --task-list <style> | unicode | unicode or emoji task checkboxes |
| --wrap <mode> | message | message → { "blocks": [...] }; blocks → bare array |
| --minify | off | Compact JSON (default output is 2-space pretty-printed) |
# Pipe from stdin, emit a bare array for the Slack API `blocks` field
cat release.md | slackmd --wrap blocks
# markdown engine, written to a file
slackmd release.md --engine markdown -o payload.json
# H1–H3 as header blocks, emoji task checkboxes
slackmd notes.md --header-level 3 --task-list emojiAPI reference
import { slackmd } from "@heumsi/slackmd";
import type {
Block, Engine, SlackmdOptions, SlackmdResult, TaskListStyle, Warning,
} from "@heumsi/slackmd";
function slackmd(md: string, options?: SlackmdOptions): SlackmdResult;interface SlackmdOptions {
engine?: "rich_text" | "markdown"; // default: "rich_text"
headerLevel?: number; // 1–6, default: 2
taskList?: "unicode" | "emoji"; // default: "unicode"
}
interface SlackmdResult {
blocks: Block[]; // ready for chat.postMessage({ channel, blocks })
warnings: Warning[]; // never throws — problems are reported here
}
interface Warning {
code: string;
message: string;
line?: number;
}Warnings and limits
slackmd never throws. Whatever can't be represented exactly in Block Kit is handled gracefully and reported in warnings (Array<{ code, message, line? }>). The limits below are Slack platform constraints, not slackmd restrictions.
| Code | Meaning |
|---|---|
| HEADER_TRUNCATED | Heading over 150 chars → rendered as bold rich_text (no text lost) |
| HEADER_FALLBACK_BOLD | Heading with inline formatting → rendered as bold rich_text |
| QUOTE_FLATTENED | Block content inside a blockquote was flattened to text |
| LIST_ITEM_FLATTENED | Non-paragraph content in a list item was dropped |
| TABLE_ROWS_TRUNCATED | Table exceeded 100 rows; extra rows dropped |
| TABLE_COLS_TRUNCATED | Table exceeded 20 columns; extra cells dropped |
| HTML_STRIPPED | Raw HTML was removed |
| BLOCK_LIMIT_EXCEEDED | Over 50 blocks (Slack's per-message cap); output is unchanged — split across messages |
| MARKDOWN_BLOCK_CHAR_LIMIT | markdown engine content exceeded 12,000 chars; split into multiple blocks |
| EMPTY_BUTTONS | :::buttons had no ::button items; block skipped |
| EMPTY_FIELDS | :::fields had no items; block skipped |
| UNKNOWN_DIRECTIVE | Unrecognized :::directive name |
| UNKNOWN_FRONTMATTER_KEY | Unrecognized frontmatter key |
| INVALID_FRONTMATTER_VALUE | Known frontmatter key with an invalid value |
| FRONTMATTER_PARSE_ERROR | Frontmatter is not valid YAML |
Roadmap
- Card / carousel directives — native Slack card components
- Mention resolution —
@username→<@U123>via a user-lookup callback - Attachment-color callouts — colored sidebar via legacy
attachments - 50-block auto-split — automatic message chunking past the block cap
Contributing
Issues and pull requests are welcome — please include tests for new behavior. See CONTRIBUTING.md for the dev setup, commit convention, and release process.
npm install
npm test
npm run build