Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions src/slack/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <url|label> 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 "";
Expand Down
4 changes: 4 additions & 0 deletions test/fixtures/render/attachment-fallback-only.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"text": "",
"attachments": [{ "id": 1, "fallback": "[no preview available]" }]
}
1 change: 1 addition & 0 deletions test/fixtures/render/attachment-fallback-only.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
[no preview available]
37 changes: 37 additions & 0 deletions test/fixtures/render/table-cell-edge-cases.json
Original file line number Diff line number Diff line change
@@ -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 &amp; Q2" }
]
]
}
]
}
4 changes: 4 additions & 0 deletions test/fixtures/render/table-cell-edge-cases.txt
Original file line number Diff line number Diff line change
@@ -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 | |
19 changes: 19 additions & 0 deletions test/fixtures/render/table-empty-rows.json
Original file line number Diff line number Diff line change
@@ -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": []
}
]
}
1 change: 1 addition & 0 deletions test/fixtures/render/table-empty-rows.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Quarterly roundup
88 changes: 88 additions & 0 deletions test/fixtures/render/table-in-attachment.json
Original file line number Diff line number Diff line change
@@ -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%" }
]
]
}
]
}
]
}
10 changes: 10 additions & 0 deletions test/fixtures/render/table-in-attachment.txt
Original file line number Diff line number Diff line change
@@ -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% |
42 changes: 42 additions & 0 deletions test/render-fixtures.test.ts
Original file line number Diff line number Diff line change
@@ -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/<name>.json` (a raw message
* object as passed to renderSlackMessageContent) must render exactly to the
* markdown in the sibling `<name>.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);
});
}
});
Loading