From afbff4ed26d89611cce9829c319560c0b26b5ccc Mon Sep 17 00:00:00 2001 From: JP Richardson Date: Sun, 9 Aug 2026 09:39:53 -0500 Subject: [PATCH] feat(render): render Slack table blocks as GFM tables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Composer-built tables arrive as table blocks — directly in blocks, or nested inside attachment blocks where the fallback text is just '[no preview available]' — and were previously dropped from rendered message content. Render them as GitHub-flavored tables: rich_text or raw_text cells, newlines flattened, pipes escaped while tokens stay intact for the mrkdwn conversion pass, ragged rows padded to the widest row. Adds a fixture-pair test harness (json in, expected md out); regenerate expected outputs with UPDATE_RENDER_FIXTURES=1 bun test render-fixtures. Co-Authored-By: Claude Fable 5 --- src/slack/render.ts | 66 ++++++++++++++ .../render/attachment-fallback-only.json | 4 + .../render/attachment-fallback-only.txt | 1 + .../render/table-cell-edge-cases.json | 37 ++++++++ .../fixtures/render/table-cell-edge-cases.txt | 4 + test/fixtures/render/table-empty-rows.json | 19 ++++ test/fixtures/render/table-empty-rows.txt | 1 + test/fixtures/render/table-in-attachment.json | 88 +++++++++++++++++++ test/fixtures/render/table-in-attachment.txt | 10 +++ test/render-fixtures.test.ts | 42 +++++++++ 10 files changed, 272 insertions(+) create mode 100644 test/fixtures/render/attachment-fallback-only.json create mode 100644 test/fixtures/render/attachment-fallback-only.txt create mode 100644 test/fixtures/render/table-cell-edge-cases.json create mode 100644 test/fixtures/render/table-cell-edge-cases.txt create mode 100644 test/fixtures/render/table-empty-rows.json create mode 100644 test/fixtures/render/table-empty-rows.txt create mode 100644 test/fixtures/render/table-in-attachment.json create mode 100644 test/fixtures/render/table-in-attachment.txt create mode 100644 test/render-fixtures.test.ts diff --git a/src/slack/render.ts b/src/slack/render.ts index a2b3b90..41b143b 100644 --- a/src/slack/render.ts +++ b/src/slack/render.ts @@ -109,11 +109,77 @@ function extractMrkdwnFromBlocks(blocks: unknown): string { } continue; } + if (type === "table" && Array.isArray(b.rows)) { + const table = renderTableBlock(b.rows); + if (table) { + out.push(table); + } + continue; + } } return out.join("\n\n"); } +/** + * Render a Slack `table` block (composer-pasted tables; also delivered inside + * attachment blocks with a "[no preview available]" fallback) as a GFM table. + * Rows are arrays of cells; each cell is a rich_text block or `{type: "raw_text", text}`. + */ +function renderTableBlock(rows: unknown[]): string { + const rendered: string[][] = []; + for (const row of rows) { + if (!Array.isArray(row)) { + continue; + } + const cells: string[] = []; + for (const cell of row) { + cells.push(renderTableCell(cell)); + } + rendered.push(cells); + } + if (rendered.length === 0) { + return ""; + } + + let width = 0; + for (const cells of rendered) { + width = Math.max(width, cells.length); + } + + const lines: string[] = []; + lines.push(formatTableRow(rendered[0]!, width)); + lines.push(formatTableRow(Array(width).fill("---"), width)); + for (const cells of rendered.slice(1)) { + lines.push(formatTableRow(cells, width)); + } + return lines.join("\n"); +} + +function formatTableRow(cells: string[], width: number): string { + const padded = [...cells]; + while (padded.length < width) { + padded.push(""); + } + return `| ${padded.join(" | ")} |`; +} + +function renderTableCell(cell: unknown): string { + if (!isRecord(cell)) { + return ""; + } + const text = + getString(cell.type) === "rich_text" + ? extractMrkdwnFromRichTextBlock(cell) + : getString(cell.text); + // Flatten to one line and escape pipes, but keep link tokens + // intact so slackMrkdwnToMarkdown can still convert them. + return text + .replace(/\s*\n\s*/g, " ") + .replace(/(<[^>]*>)|\|/g, (match, token) => (token ? match : "\\|")) + .trim(); +} + function extractMrkdwnFromAttachments(attachments: unknown, state: RenderState): string { if (state.depth >= MAX_ATTACHMENT_DEPTH) { return ""; diff --git a/test/fixtures/render/attachment-fallback-only.json b/test/fixtures/render/attachment-fallback-only.json new file mode 100644 index 0000000..1ccec0e --- /dev/null +++ b/test/fixtures/render/attachment-fallback-only.json @@ -0,0 +1,4 @@ +{ + "text": "", + "attachments": [{ "id": 1, "fallback": "[no preview available]" }] +} diff --git a/test/fixtures/render/attachment-fallback-only.txt b/test/fixtures/render/attachment-fallback-only.txt new file mode 100644 index 0000000..11931b5 --- /dev/null +++ b/test/fixtures/render/attachment-fallback-only.txt @@ -0,0 +1 @@ +[no preview available] diff --git a/test/fixtures/render/table-cell-edge-cases.json b/test/fixtures/render/table-cell-edge-cases.json new file mode 100644 index 0000000..bbb2c3f --- /dev/null +++ b/test/fixtures/render/table-cell-edge-cases.json @@ -0,0 +1,37 @@ +{ + "blocks": [ + { + "type": "table", + "block_id": "tbl-edge", + "rows": [ + [ + { "type": "raw_text", "text": "Item" }, + { "type": "raw_text", "text": "Status" }, + { "type": "raw_text", "text": "Notes" }, + { "type": "raw_text", "text": "Link" } + ], + [ + { "type": "raw_text", "text": "Widget A|B" }, + { "type": "raw_text", "text": "ok" }, + { "type": "raw_text", "text": "line one\nline two" }, + { + "type": "rich_text", + "block_id": "cell-link", + "elements": [ + { + "type": "rich_text_section", + "elements": [{ "type": "link", "url": "https://example.com/docs", "text": "Docs" }] + } + ] + } + ], + "bogus-row", + [ + { "type": "raw_text", "text": "Widget C" }, + { "type": "raw_text", "text": "pending" }, + { "type": "raw_text", "text": "Q1 & Q2" } + ] + ] + } + ] +} diff --git a/test/fixtures/render/table-cell-edge-cases.txt b/test/fixtures/render/table-cell-edge-cases.txt new file mode 100644 index 0000000..f5c93ac --- /dev/null +++ b/test/fixtures/render/table-cell-edge-cases.txt @@ -0,0 +1,4 @@ +| Item | Status | Notes | Link | +| --- | --- | --- | --- | +| Widget A\|B | ok | line one line two | [Docs](https://example.com/docs) | +| Widget C | pending | Q1 & Q2 | | diff --git a/test/fixtures/render/table-empty-rows.json b/test/fixtures/render/table-empty-rows.json new file mode 100644 index 0000000..7738371 --- /dev/null +++ b/test/fixtures/render/table-empty-rows.json @@ -0,0 +1,19 @@ +{ + "blocks": [ + { + "type": "rich_text", + "block_id": "prose", + "elements": [ + { + "type": "rich_text_section", + "elements": [{ "type": "text", "text": "Quarterly roundup" }] + } + ] + }, + { + "type": "table", + "block_id": "tbl-empty", + "rows": [] + } + ] +} diff --git a/test/fixtures/render/table-empty-rows.txt b/test/fixtures/render/table-empty-rows.txt new file mode 100644 index 0000000..59a79d7 --- /dev/null +++ b/test/fixtures/render/table-empty-rows.txt @@ -0,0 +1 @@ +Quarterly roundup diff --git a/test/fixtures/render/table-in-attachment.json b/test/fixtures/render/table-in-attachment.json new file mode 100644 index 0000000..d7af2d8 --- /dev/null +++ b/test/fixtures/render/table-in-attachment.json @@ -0,0 +1,88 @@ +{ + "text": "Atlas Signups\n===\n\nMedian = +40% WoW\nDriver = beta invite rollout", + "blocks": [ + { + "type": "rich_text", + "block_id": "prose", + "elements": [ + { + "type": "rich_text_section", + "elements": [ + { + "type": "text", + "text": "Atlas Signups\n===\n\nMedian = +40% WoW\nDriver = beta invite rollout" + } + ] + } + ] + } + ], + "attachments": [ + { + "id": 1, + "fallback": "[no preview available]", + "blocks": [ + { + "type": "table", + "block_id": "tbl1", + "rows": [ + [ + { + "type": "rich_text", + "block_id": "h1", + "elements": [ + { + "type": "rich_text_section", + "elements": [{ "type": "text", "text": "Metric", "style": { "bold": true } }] + } + ] + }, + { + "type": "rich_text", + "block_id": "h2", + "elements": [ + { + "type": "rich_text_section", + "elements": [{ "type": "text", "text": "Wk 1", "style": { "bold": true } }] + } + ] + }, + { + "type": "rich_text", + "block_id": "h3", + "elements": [ + { + "type": "rich_text_section", + "elements": [{ "type": "text", "text": "Wk 2", "style": { "bold": true } }] + } + ] + }, + { + "type": "rich_text", + "block_id": "h4", + "elements": [ + { + "type": "rich_text_section", + "elements": [{ "type": "text", "text": "Wk 3", "style": { "bold": true } }] + } + ] + } + ], + [ + { "type": "raw_text", "text": "Signups" }, + { "type": "raw_text", "text": "120" }, + { "type": "raw_text", "text": "240" }, + { "type": "raw_text", "text": "480" } + ], + [ + { "type": "raw_text", "text": "WoW" }, + { "type": "raw_text", "text": "–" }, + { "type": "raw_text", "text": "+100%" }, + { "type": "raw_text", "text": "+100%" } + ] + ] + } + ] + } + ] +} diff --git a/test/fixtures/render/table-in-attachment.txt b/test/fixtures/render/table-in-attachment.txt new file mode 100644 index 0000000..744f94b --- /dev/null +++ b/test/fixtures/render/table-in-attachment.txt @@ -0,0 +1,10 @@ +Atlas Signups +=== + +Median = +40% WoW +Driver = beta invite rollout + +| *Metric* | *Wk 1* | *Wk 2* | *Wk 3* | +| --- | --- | --- | --- | +| Signups | 120 | 240 | 480 | +| WoW | – | +100% | +100% | diff --git a/test/render-fixtures.test.ts b/test/render-fixtures.test.ts new file mode 100644 index 0000000..e131e18 --- /dev/null +++ b/test/render-fixtures.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, test } from "bun:test"; +import { readdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { renderSlackMessageContent } from "../src/slack/render.ts"; + +/** + * Fixture-pair harness: every `test/fixtures/render/.json` (a raw message + * object as passed to renderSlackMessageContent) must render exactly to the + * markdown in the sibling `.txt`. Add a new case by dropping in a pair. + * Expected outputs are .txt, not .md, so formatters (oxfmt pre-commit) never + * rewrite them — they are exact renderer output, not prose. + * + * Regenerate expected outputs after an intentional rendering change with: + * UPDATE_RENDER_FIXTURES=1 bun test render-fixtures + */ +const FIXTURES_DIR = join(import.meta.dir, "fixtures", "render"); +const UPDATE = process.env.UPDATE_RENDER_FIXTURES === "1"; + +const fixtureNames = readdirSync(FIXTURES_DIR) + .filter((f) => f.endsWith(".json")) + .map((f) => f.replace(/\.json$/, "")) + .sort(); + +describe("render fixtures", () => { + test("fixture directory is not empty", () => { + expect(fixtureNames.length).toBeGreaterThan(0); + }); + + for (const name of fixtureNames) { + test(name, () => { + const input = JSON.parse(readFileSync(join(FIXTURES_DIR, `${name}.json`), "utf8")); + const rendered = renderSlackMessageContent(input); + const expectedPath = join(FIXTURES_DIR, `${name}.txt`); + if (UPDATE) { + writeFileSync(expectedPath, rendered ? `${rendered}\n` : ""); + return; + } + const expected = readFileSync(expectedPath, "utf8").replace(/\n$/, ""); + expect(rendered).toBe(expected); + }); + } +});