diff --git a/apps/discord-bot/src/features/ResponseBridge.ts b/apps/discord-bot/src/features/ResponseBridge.ts index 0cf2b99930a..777f4f40ce4 100644 --- a/apps/discord-bot/src/features/ResponseBridge.ts +++ b/apps/discord-bot/src/features/ResponseBridge.ts @@ -69,6 +69,7 @@ import { stripMarkdownImages, type MarkdownImageRef, } from "../presentation/markdownImages.ts"; +import { rewriteMarkdownTablesForDiscord } from "../presentation/markdownTables.ts"; import { chunkDiscordContent, formatInProgressChunk, @@ -3368,10 +3369,12 @@ export const runBridge = ( } } - const tipDisplayText = activeStreamTipText(fullDisplayText, streamBreakPrefix); - const previousTipDisplayText = activeStreamTipText( - previousFullDisplayText, - streamBreakPrefix, + // Discord cannot render GFM pipe tables — rewrite complete tables to bullets. + const tipDisplayText = rewriteMarkdownTablesForDiscord( + activeStreamTipText(fullDisplayText, streamBreakPrefix), + ); + const previousTipDisplayText = rewriteMarkdownTablesForDiscord( + activeStreamTipText(previousFullDisplayText, streamBreakPrefix), ); // After a tip break, always keep a post-break Working tip for liveness even when @@ -3775,18 +3778,21 @@ export const runBridge = ( // Final channel text: strip image embeds but keep readable local file references. // Never leave Working.. or the stream placeholder. - // Keep Discord markdown as-is (links stay clickable). Do not ASCII-ify tables. + // Keep Discord markdown as-is (links stay clickable). GFM pipe tables → bullets + // (Discord does not render tables; ASCII grids were tried and removed). // Stats footer always gets · [T3](deep link) when the web UI base is configured. - const finalText = rewriteMarkdownLocalFileLinksForDiscord({ - text: stripWorkingIndicator(stripMarkdownImages(text)), - githubUrlsBySrc, - attachedFileNames, - oversizedByName, - }) - .replace(/_\(attachment will attach when done\)_/giu, "") - .replace(/_\(\d+ attachments will attach when done\)_/giu, "") - .replace(/\n{3,}/g, "\n\n") - .trim(); + const finalText = rewriteMarkdownTablesForDiscord( + rewriteMarkdownLocalFileLinksForDiscord({ + text: stripWorkingIndicator(stripMarkdownImages(text)), + githubUrlsBySrc, + attachedFileNames, + oversizedByName, + }) + .replace(/_\(attachment will attach when done\)_/giu, "") + .replace(/_\(\d+ attachments will attach when done\)_/giu, "") + .replace(/\n{3,}/g, "\n\n") + .trim(), + ); const finalInlineGitHubUrlsByToken = yield* resolveGitHubLinksForInlinePathCodeSpans( extractInlinePathCodeSpanRefs(finalText), worktreePath, @@ -4764,7 +4770,8 @@ export const runBridge = ( if (yield* isStreamTipDisplaced(tipId)) return; // Epoch FSM: awaiting → dots only; streaming → current epoch streamText only. - const tipDisplay = hb.tipBody; + // Same GFM→bullets rewrite as the primary stream path. + const tipDisplay = rewriteMarkdownTablesForDiscord(hb.tipBody); if (state.streamBreakPrefix !== "" && tipDisplay.trim() === "") return; const chunks = diff --git a/apps/discord-bot/src/presentation/asciiTables.test.ts b/apps/discord-bot/src/presentation/asciiTables.test.ts deleted file mode 100644 index 35bfce3772d..00000000000 --- a/apps/discord-bot/src/presentation/asciiTables.test.ts +++ /dev/null @@ -1,353 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { - chunkDiscordContentPreservingTables, - extractMarkdownTables, - isSeparatorRow, - renderMysqlTable, - renderRoundedTable, - rewriteMarkdownTablesForDiscord, - splitTableCells, - wrapCellText, -} from "./asciiTables.ts"; - -describe("splitTableCells", () => { - it("splits on unescaped pipes and trims cells", () => { - expect(splitTableCells("| Doc | What it is |")).toEqual(["Doc", "What it is"]); - expect(splitTableCells("Doc | What it is")).toEqual(["Doc", "What it is"]); - }); - - it("keeps escaped pipes inside a cell", () => { - expect(splitTableCells("| a \\| b | c |")).toEqual(["a | b", "c"]); - }); -}); - -describe("isSeparatorRow", () => { - it("accepts GFM separator cells", () => { - expect(isSeparatorRow(["---", ":---", "---:", ":---:"])).toBe(true); - expect(isSeparatorRow(["Doc", "What"])).toBe(false); - }); -}); - -describe("extractMarkdownTables", () => { - it("extracts a short unfenced table", () => { - const text = `| Doc | What it actually is | -|---|---| -| effect-cluster-worker-migration.md | Mixed topology notes. | -| durable-print-roundtrip.md | EasyLife workflow. |`; - - const matches = extractMarkdownTables(text); - expect(matches).toHaveLength(1); - expect(matches[0]?.headers).toEqual(["Doc", "What it actually is"]); - expect(matches[0]?.rows).toEqual([ - ["effect-cluster-worker-migration.md", "Mixed topology notes."], - ["durable-print-roundtrip.md", "EasyLife workflow."], - ]); - expect(matches[0]?.start).toBe(0); - expect(matches[0]?.end).toBe(text.length); - }); - - it("extracts a table wrapped in a code fence", () => { - const text = `\`\`\`bash -| Doc | What | -|---|---| -| a.md | first | -\`\`\``; - const matches = extractMarkdownTables(text); - expect(matches).toHaveLength(1); - expect(matches[0]?.headers).toEqual(["Doc", "What"]); - expect(matches[0]?.rows).toEqual([["a.md", "first"]]); - expect(matches[0]?.raw.startsWith("```")).toBe(true); - }); - - it("finds a table with surrounding prose", () => { - const text = `Intro line. - -| A | B | -|---|---| -| 1 | 2 | - -Outro line.`; - const matches = extractMarkdownTables(text); - expect(matches).toHaveLength(1); - expect(matches[0]?.headers).toEqual(["A", "B"]); - expect(text.slice(0, matches[0]!.start)).toContain("Intro"); - expect(text.slice(matches[0]!.end)).toContain("Outro"); - }); - - it("returns empty when there is no table", () => { - expect(extractMarkdownTables("just a paragraph\nand another")).toEqual([]); - expect(extractMarkdownTables("| not a table without separator |")).toEqual([]); - }); -}); - -describe("wrapCellText", () => { - it("wraps on word boundaries", () => { - expect(wrapCellText("hello world friend", 10)).toEqual(["hello", "world", "friend"]); - expect(wrapCellText("short", 60)).toEqual(["short"]); - }); - - it("hard-breaks overlong tokens", () => { - expect(wrapCellText("abcdefghij", 4)).toEqual(["abcd", "efgh", "ij"]); - }); -}); - -describe("renderRoundedTable", () => { - it("renders a short aligned rounded table", () => { - const rendered = renderRoundedTable( - ["Col1", "Col2"], - [ - ["Value 1", "Value 2"], - ["x", "y"], - ], - ); - expect(rendered).toBe( - [ - ".---------.---------.", - "| Col1 | Col2 |", - ":---------+---------:", - "| Value 1 | Value 2 |", - ":---------+---------:", - "| x | y |", - "'---------'---------'", - ].join("\n"), - ); - }); - - it("wraps a long-description column within the Discord line width cap", () => { - const long = - "Incomplete — only inbound SFTP settle window (10s). Missing ABAS create/fetch delivery note."; - const rendered = renderRoundedTable( - ["Doc", "What it actually is"], - [["abas-file-handoff.md", long]], - 40, - 72, - ); - const lines = rendered.split("\n"); - // Multi-line body row for the wrapped description. - const bodyLines = lines.filter((line) => line.startsWith("|")); - expect(bodyLines.length).toBeGreaterThan(2); // header + at least 2 wrapped body lines - for (const line of lines) { - expect(line.length).toBeLessThanOrEqual(72); - } - expect(rendered).toContain("abas-file-handoff.md"); - expect(rendered).toContain("Incomplete"); - expect(rendered.startsWith(".")).toBe(true); - expect(rendered.endsWith("'")).toBe(true); - }); - - it("right-aligns mostly-numeric columns", () => { - const rendered = renderRoundedTable( - ["Name", "Amount"], - [ - ["a", "10.0"], - ["b", "-2,027.1"], - ], - ); - expect(rendered).toContain("| 10.0 |"); - expect(rendered).toContain("| -2,027.1 |"); - }); -}); - -describe("renderMysqlTable", () => { - it("uses +---+ box borders with a separator after every row", () => { - const rendered = renderMysqlTable( - ["H1", "H2"], - [ - ["a", "b"], - ["c", "d"], - ], - ); - expect(rendered).toBe( - [ - "+----+----+", - "| H1 | H2 |", - "+----+----+", - "| a | b |", - "+----+----+", - "| c | d |", - "+----+----+", - ].join("\n"), - ); - }); -}); - -describe("rewriteMarkdownTablesForDiscord", () => { - it("rewrites a short table to a fenced rounded ASCII table", () => { - const input = `| Doc | What | -|---|---| -| a.md | first |`; - const result = rewriteMarkdownTablesForDiscord(input); - expect(result.attachments).toEqual([]); - expect(result.text.startsWith("```\n")).toBe(true); - expect(result.text.endsWith("\n```")).toBe(true); - expect(result.text).toContain("| Doc "); - expect(result.text).toContain("| a.md"); - expect(result.text).toContain(".---"); - }); - - it("preserves surrounding text", () => { - const input = `Before. - -| A | B | -|---|---| -| 1 | 2 | - -After.`; - const result = rewriteMarkdownTablesForDiscord(input); - expect(result.text.startsWith("Before.")).toBe(true); - expect(result.text.endsWith("After.")).toBe(true); - expect(result.text).toContain("```"); - expect(result.text).not.toContain("|---|"); - }); - - it("is a passthrough when there is no table", () => { - const input = "No tables here, just prose."; - expect(rewriteMarkdownTablesForDiscord(input)).toEqual({ - text: input, - attachments: [], - }); - }); - - it("replaces fenced markdown tables", () => { - const input = `\`\`\`bash -| Doc | What | -|---|---| -| a.md | first | -\`\`\``; - const result = rewriteMarkdownTablesForDiscord(input); - expect(result.text).not.toContain("```bash"); - expect(result.text).toContain("| Doc "); - expect(result.attachments).toEqual([]); - }); - - it("keeps long-text multi-row content in a single table", () => { - const input = `| Doc | What it actually is | -|---|---| -| effect-cluster-worker-migration.md | Mixed: some current topology (shard groups, storage protocols) plus cutover history, removed files, bug notes (void RPC), follow-ups. Title/index still read as migration, not the living ops reference. EasyLife-centric. | -| durable-print-roundtrip.md | EasyLife workflow round-trip (activities, keys, pack SM). Not deploy/runtime/poll/alerts. | -| abas-file-handoff.md | Incomplete — only inbound SFTP settle window (10s). Missing ABAS create/fetch delivery note, packaging import, closeout CSV write, mounts, who runs where. | -| packstations-and-printers.md | Printer ids / CUPS naming / company maps. Not cluster mailbox model. |`; - const result = rewriteMarkdownTablesForDiscord(input); - expect(result.attachments).toEqual([]); - // One table, one fence — do not split into one mini-table per row. - expect(result.text.match(/```/g)?.length).toBe(2); - expect(result.text.startsWith("```\n")).toBe(true); - expect(result.text.endsWith("\n```")).toBe(true); - expect(result.text).not.toContain("\u200B"); - // Only one header block / top border (not one mini-table per row). - const pipeLines = result.text.split("\n").filter((line) => line.startsWith("|")); - expect(pipeLines.filter((line) => line.includes("What it actually is")).length).toBe(1); - expect(result.text.split("\n").filter((line) => line.startsWith(".")).length).toBe(1); - expect(result.text).toContain("EasyLife-centric."); - expect(result.text).toContain("effect-cluster-worker-migration.md"); - expect(result.text).toContain("packstations-and-printers.md"); - for (const line of result.text.split("\n")) { - if ( - line.startsWith("|") || - line.startsWith(".") || - line.startsWith(":") || - line.startsWith("'") - ) { - expect(line.length).toBeLessThanOrEqual(72); - } - } - }); - - it("attaches a single row only when it still exceeds the message limit", () => { - const huge = "word ".repeat(500).trim(); - const input = ["| Col | Description |", "|---|---|", `| only | ${huge} |`].join("\n"); - const result = rewriteMarkdownTablesForDiscord(input, { - messageLimit: 200, - maxColWidth: 40, - maxTableWidth: 72, - }); - expect(result.attachments.length).toBe(1); - expect(result.attachments[0]?.name).toBe("table.txt"); - expect(result.attachments[0]?.body).toContain("word"); - expect(result.text).toContain("table.txt"); - }); - - it("right-aligns a numeric column wider than its header", () => { - const rendered = renderRoundedTable( - ["Name", "Amount"], - [ - ["a", "10.0"], - ["b", "-2,027.1"], - ["c", "1,234,567.89"], - ], - ); - expect(rendered).toContain("| Name | Amount |"); - expect(rendered).toContain("| a | 10.0 |"); - expect(rendered).toContain("| b | -2,027.1 |"); - expect(rendered).toContain("| c | 1,234,567.89 |"); - }); - - it("does not convert a fenced bash block that only looks a bit like a table", () => { - const input = `\`\`\`bash -| not a real table without separator style -|--- -echo "hello | world" -\`\`\``; - const result = rewriteMarkdownTablesForDiscord(input); - expect(result.attachments).toEqual([]); - expect(result.text).toBe(input); - expect(extractMarkdownTables(input)).toEqual([]); - }); - - it("names dual oversized table attachments in document reading order", () => { - const huge = "word ".repeat(400).trim(); - const input = [ - "| ColA | DescA |", - "|---|---|", - `| first-table | ${huge} |`, - "", - "Some prose between.", - "", - "| ColB | DescB |", - "|---|---|", - `| second-table | ${huge} |`, - ].join("\n"); - const result = rewriteMarkdownTablesForDiscord(input, { - messageLimit: 300, - maxColWidth: 40, - maxTableWidth: 72, - }); - expect(result.attachments.map((entry) => entry.name)).toEqual(["table-1.txt", "table-2.txt"]); - expect(result.attachments[0]?.body).toContain("first-table"); - expect(result.attachments[1]?.body).toContain("second-table"); - // Notes in body follow the same reading order. - const firstNote = result.text.indexOf("table-1.txt"); - const secondNote = result.text.indexOf("table-2.txt"); - expect(firstNote).toBeGreaterThanOrEqual(0); - expect(secondNote).toBeGreaterThan(firstNote); - expect(result.text.indexOf("first-table")).toBe(-1); - expect(result.text.indexOf("second-table")).toBe(-1); - }); -}); - -describe("chunkDiscordContentPreservingTables", () => { - it("does not split inside a fenced ASCII table", () => { - const table = [ - "```", - ".----+----.", - "| A | B |", - ":----+----:", - "| 1 | 2 |", - "'----+----'", - "```", - ].join("\n"); - // Force a second chunk: large prose + full fenced table > limit. - const prefix = "x".repeat(1950); - const text = `${prefix}\n\n${table}`; - expect(text.length).toBeGreaterThan(2000); - const chunks = chunkDiscordContentPreservingTables(text, 2000); - expect(chunks.length).toBeGreaterThan(1); - const withTable = chunks.find((chunk) => chunk.includes(".----+----.")); - expect(withTable).toBeDefined(); - expect(withTable).toContain("'----+----'"); - expect(withTable).toContain("```"); - // Fence must stay contiguous in one chunk. - expect(withTable?.indexOf("```")).toBeLessThan(withTable!.lastIndexOf("```")); - }); -}); diff --git a/apps/discord-bot/src/presentation/asciiTables.ts b/apps/discord-bot/src/presentation/asciiTables.ts deleted file mode 100644 index f16f27eb875..00000000000 --- a/apps/discord-bot/src/presentation/asciiTables.ts +++ /dev/null @@ -1,838 +0,0 @@ -/** - * Convert GFM pipe tables into aligned ASCII tables for Discord. - * Discord does not render markdown tables, so monospace box drawing is required. - */ - -export interface TableMatch { - /** Inclusive start offset of the matched source span. */ - readonly start: number; - /** Exclusive end offset of the matched source span. */ - readonly end: number; - /** Original matched text (may include surrounding code fences). */ - readonly raw: string; - readonly headers: ReadonlyArray; - readonly rows: ReadonlyArray>; -} - -export interface DiscordTableAttachment { - readonly name: string; - readonly body: string; -} - -export interface RewriteMarkdownTablesResult { - readonly text: string; - readonly attachments: ReadonlyArray; -} - -export type AsciiTableStyle = "rounded" | "mysql"; - -/** Per-column wrap width before word-break. Kept modest for Discord code blocks. */ -const DEFAULT_MAX_COL_WIDTH = 40; -/** - * Hard cap on a single rendered table line (borders included). - * Wider lines soft-wrap in Discord clients and destroy alignment. - */ -const DEFAULT_MAX_TABLE_WIDTH = 72; -const DEFAULT_MESSAGE_LIMIT = 2000; - -/** Split a markdown table row into cells on unescaped `|`. */ -export function splitTableCells(line: string): string[] { - let body = line.trim(); - if (body.startsWith("|")) body = body.slice(1); - if (body.endsWith("|")) body = body.slice(0, -1); - - const cells: string[] = []; - let current = ""; - let escaped = false; - for (const char of body) { - if (escaped) { - current += char; - escaped = false; - continue; - } - if (char === "\\") { - escaped = true; - continue; - } - if (char === "|") { - cells.push(unescapeTableCell(current.trim())); - current = ""; - continue; - } - current += char; - } - cells.push(unescapeTableCell(current.trim())); - return cells; -} - -function unescapeTableCell(value: string): string { - return value - .replace(/\\([\\|`*_{}[\]()#+\-.!])/g, "$1") - .replace(/\s+/g, " ") - .trim(); -} - -/** True when every cell looks like a GFM separator segment (`---`, `:---:`, etc.). */ -export function isSeparatorRow(cells: ReadonlyArray): boolean { - if (cells.length === 0) return false; - return cells.every((cell) => /^:?-{1,}:?$/.test(cell.replace(/\s+/g, ""))); -} - -function isTableRowLine(line: string): boolean { - const trimmed = line.trim(); - if (trimmed.length === 0) return false; - // Require a pipe that actually separates content (not a lone `|`). - if (!trimmed.includes("|")) return false; - // Reject pure fence markers. - if (trimmed.startsWith("```")) return false; - return true; -} - -function normalizeRow(cells: ReadonlyArray, columnCount: number): string[] { - const row = cells.slice(0, columnCount).map((cell) => cell); - while (row.length < columnCount) row.push(""); - return row; -} - -function tryParseTableLines( - lines: ReadonlyArray, - startIndex: number, -): { - readonly endIndex: number; - readonly headers: string[]; - readonly rows: string[][]; -} | null { - if (startIndex + 1 >= lines.length) return null; - const headerLine = lines[startIndex] ?? ""; - const separatorLine = lines[startIndex + 1] ?? ""; - if (!isTableRowLine(headerLine) || !isTableRowLine(separatorLine)) return null; - - const headers = splitTableCells(headerLine); - const separatorCells = splitTableCells(separatorLine); - if (headers.length === 0 || !isSeparatorRow(separatorCells)) return null; - // Require ≥2 columns so bash/prose like `| note` + `|---` is not treated as a table. - if (headers.length < 2 || separatorCells.length < 2) return null; - // Separator column count should roughly match the header (allow off-by-one). - if (Math.abs(headers.length - separatorCells.length) > 1) return null; - - const columnCount = Math.max(headers.length, separatorCells.length); - const normalizedHeaders = normalizeRow(headers, columnCount); - const rows: string[][] = []; - let endIndex = startIndex + 1; - - for (let index = startIndex + 2; index < lines.length; index += 1) { - const line = lines[index] ?? ""; - if (!isTableRowLine(line)) break; - const cells = splitTableCells(line); - // A second separator row ends the table (defensive). - if (isSeparatorRow(cells)) break; - // Blank-looking pipe rows still count as data. - rows.push(normalizeRow(cells, columnCount)); - endIndex = index; - } - - if (rows.length === 0) return null; - return { endIndex, headers: normalizedHeaders, rows }; -} - -/** - * Find GFM pipe tables in `text`. - * Also matches tables wrapped in fenced code blocks (``` / ```bash / etc.). - */ -export function extractMarkdownTables(text: string): TableMatch[] { - const matches: TableMatch[] = []; - const lineStarts: number[] = [0]; - for (let index = 0; index < text.length; index += 1) { - if (text[index] === "\n") lineStarts.push(index + 1); - } - const lines = text.split(/\r?\n/); - - let lineIndex = 0; - while (lineIndex < lines.length) { - const line = lines[lineIndex] ?? ""; - const fenceOpen = line.match(/^(\s*)```([\w+-]*)\s*$/); - if (fenceOpen) { - const openIndex = lineIndex; - let closeIndex = -1; - for (let probe = lineIndex + 1; probe < lines.length; probe += 1) { - if (/^\s*```\s*$/.test(lines[probe] ?? "")) { - closeIndex = probe; - break; - } - } - if (closeIndex === -1) { - lineIndex += 1; - continue; - } - - const innerStart = openIndex + 1; - // Skip leading blank lines inside the fence. - let tableStart = innerStart; - while (tableStart < closeIndex && (lines[tableStart] ?? "").trim() === "") { - tableStart += 1; - } - const parsed = tryParseTableLines(lines, tableStart); - if (parsed !== null) { - // Trailing blank lines after the table before the closing fence are ok. - let afterTable = parsed.endIndex + 1; - while (afterTable < closeIndex && (lines[afterTable] ?? "").trim() === "") { - afterTable += 1; - } - if (afterTable === closeIndex) { - const start = lineStarts[openIndex] ?? 0; - const exclusiveEnd = lineEndExclusive(text, lineStarts, lines, closeIndex); - matches.push({ - start, - end: exclusiveEnd, - raw: text.slice(start, exclusiveEnd), - headers: parsed.headers, - rows: parsed.rows, - }); - lineIndex = closeIndex + 1; - continue; - } - } - lineIndex = closeIndex + 1; - continue; - } - - const parsed = tryParseTableLines(lines, lineIndex); - if (parsed === null) { - lineIndex += 1; - continue; - } - - const start = lineStarts[lineIndex] ?? 0; - const exclusiveEnd = lineEndExclusive(text, lineStarts, lines, parsed.endIndex); - matches.push({ - start, - end: exclusiveEnd, - raw: text.slice(start, exclusiveEnd), - headers: parsed.headers, - rows: parsed.rows, - }); - lineIndex = parsed.endIndex + 1; - } - - return matches; -} - -/** True when `text` contains at least one GFM pipe table. */ -export function hasMarkdownTables(text: string): boolean { - return extractMarkdownTables(text).length > 0; -} - -/** Exclusive end offset for `lineIndex`, including its trailing newline when present. */ -function lineEndExclusive( - text: string, - lineStarts: ReadonlyArray, - lines: ReadonlyArray, - lineIndex: number, -): number { - const start = lineStarts[lineIndex] ?? text.length; - const line = lines[lineIndex] ?? ""; - let exclusiveEnd = start + line.length; - if (exclusiveEnd < text.length && (text[exclusiveEnd] === "\n" || text[exclusiveEnd] === "\r")) { - exclusiveEnd = - text[exclusiveEnd] === "\r" && text[exclusiveEnd + 1] === "\n" - ? exclusiveEnd + 2 - : exclusiveEnd + 1; - } - return exclusiveEnd; -} - -/** Wrap cell text to maxWidth, preferring spaces then hyphens over hard cuts. */ -export function wrapCellText(text: string, maxWidth: number): string[] { - const width = Math.max(1, maxWidth); - const normalized = text.replace(/\s+/g, " ").trim(); - if (normalized.length === 0) return [""]; - if (normalized.length <= width) return [normalized]; - - const lines: string[] = []; - let remaining = normalized; - while (remaining.length > width) { - let breakAt = remaining.lastIndexOf(" ", width); - if (breakAt <= 0) { - // Soft-break filenames / dotted identifiers on '-' or '_' / '.'. - const hyphen = remaining.lastIndexOf("-", width); - const under = remaining.lastIndexOf("_", width); - const dot = remaining.lastIndexOf(".", width); - breakAt = Math.max(hyphen, under, dot); - if (breakAt <= 0) breakAt = width; - else breakAt += 1; // keep the separator on the left line - } - lines.push(remaining.slice(0, breakAt).trimEnd()); - remaining = remaining.slice(breakAt).trimStart(); - } - if (remaining.length > 0) lines.push(remaining); - return lines.length > 0 ? lines : [""]; -} - -/** Longest token that prefers not to wrap (whole cell if no spaces). */ -function preferredMinCellWidth(cell: string, maxColWidth: number): number { - const normalized = cell.replace(/\s+/g, " ").trim(); - if (normalized.length === 0) return 1; - if (!/\s/.test(normalized)) { - return Math.min(maxColWidth, normalized.length); - } - let longest = 1; - for (const word of normalized.split(" ")) { - longest = Math.max(longest, Math.min(maxColWidth, word.length)); - } - return longest; -} - -function padCell(text: string, width: number, align: "left" | "right" = "left"): string { - const clipped = text.length > width ? text.slice(0, width) : text; - if (align === "right") { - return clipped.padStart(width, " "); - } - return clipped.padEnd(width, " "); -} - -function looksNumeric(value: string): boolean { - if (value.trim() === "") return false; - // Numbers, optional thousands separators, decimals, leading sign. - return /^-?[\d,]+(?:\.\d+)?%?$/.test(value.trim()); -} - -function columnAlignments( - headers: ReadonlyArray, - rows: ReadonlyArray>, -): Array<"left" | "right"> { - return headers.map((_, col) => { - const values = rows.map((row) => row[col] ?? "").filter((value) => value.trim() !== ""); - if (values.length === 0) return "left"; - return values.every(looksNumeric) ? "right" : "left"; - }); -} - -/** Total monospace width of a table line for the given column widths. */ -export function tableLineWidth(widths: ReadonlyArray): number { - if (widths.length === 0) return 0; - // `| ${cell} | ${cell} |` → sum(width + 2) + (n + 1) pipe chars = sum(widths) + 3n + 1 - return widths.reduce((sum, width) => sum + width, 0) + 3 * widths.length + 1; -} - -/** - * Shrink column widths so the full table line stays within maxTableWidth. - * Prefer shrinking prose columns (spaces) before identifier columns. - */ -export function fitColumnWidthsToTableWidth( - widths: ReadonlyArray, - maxTableWidth: number, - minWidths?: ReadonlyArray, -): number[] { - const next = widths.map((width) => Math.max(1, width)); - if (next.length === 0) return next; - const mins = - minWidths?.map((width, index) => Math.max(1, Math.min(next[index] ?? 1, width))) ?? - next.map(() => 1); - // Minimum usable line width with mins (or 1s). - const minLine = tableLineWidth(mins); - const budget = Math.max(minLine, maxTableWidth); - - while (tableLineWidth(next) > budget) { - // Prefer columns that are above their preferred min, then the widest. - let victim = -1; - for (let index = 0; index < next.length; index += 1) { - const width = next[index] ?? 1; - const floor = mins[index] ?? 1; - if (width <= floor) continue; - if ( - victim === -1 || - width > (next[victim] ?? 0) || - (width === (next[victim] ?? 0) && floor < (mins[victim] ?? 1)) - ) { - victim = index; - } - } - if (victim === -1) { - // Forced below preferred mins to meet budget. - let longest = 0; - for (let index = 1; index < next.length; index += 1) { - if ((next[index] ?? 0) > (next[longest] ?? 0)) longest = index; - } - if ((next[longest] ?? 1) <= 1) break; - next[longest] = (next[longest] ?? 1) - 1; - continue; - } - next[victim] = (next[victim] ?? 1) - 1; - } - return next; -} - -function computeColumnWidths( - headers: ReadonlyArray, - rows: ReadonlyArray>, - maxColWidth: number, - maxTableWidth = DEFAULT_MAX_TABLE_WIDTH, -): number[] { - const columnCount = headers.length; - const widths = Array.from({ length: columnCount }, () => 1); - const minWidths = Array.from({ length: columnCount }, () => 1); - - const consider = (cell: string, col: number) => { - minWidths[col] = Math.max(minWidths[col] ?? 1, preferredMinCellWidth(cell, maxColWidth)); - for (const line of wrapCellText(cell, maxColWidth)) { - widths[col] = Math.min(maxColWidth, Math.max(widths[col] ?? 1, line.length)); - } - }; - - for (let col = 0; col < columnCount; col += 1) { - consider(headers[col] ?? "", col); - } - for (const row of rows) { - for (let col = 0; col < columnCount; col += 1) { - consider(row[col] ?? "", col); - } - } - // Ideal width is at least the preferred min (e.g. full filename). - for (let col = 0; col < columnCount; col += 1) { - widths[col] = Math.max(widths[col] ?? 1, minWidths[col] ?? 1); - } - return fitColumnWidthsToTableWidth(widths, maxTableWidth, minWidths); -} - -function mysqlBorder(widths: ReadonlyArray, junction: "+" = "+"): string { - return `${junction}${widths.map((width) => "-".repeat(width + 2)).join(junction)}${junction}`; -} - -/** - * Classic MySQL CLI box table (`+---+`, `|`, separator after header and each row group). - */ -export function renderMysqlTable( - headers: ReadonlyArray, - rows: ReadonlyArray>, - maxColWidth = DEFAULT_MAX_COL_WIDTH, - maxTableWidth = DEFAULT_MAX_TABLE_WIDTH, -): string { - if (headers.length === 0) return ""; - const widths = computeColumnWidths(headers, rows, maxColWidth, maxTableWidth); - const alignments = columnAlignments(headers, rows); - // Wrap against the fitted width per column (not the pre-fit maxColWidth). - const wrapWidths = widths.map((width) => Math.min(maxColWidth, width)); - const top = mysqlBorder(widths, "+"); - const mid = mysqlBorder(widths, "+"); - const out: string[] = [top]; - out.push(...buildRowLinesFitted(headers, widths, alignments, wrapWidths)); - out.push(mid); - for (const row of rows) { - out.push(...buildRowLinesFitted(row, widths, alignments, wrapWidths)); - out.push(mid); - } - // Last mid is the bottom border — already correct with +---+ style. - return out.join("\n"); -} - -function buildRowLinesFitted( - cells: ReadonlyArray, - widths: ReadonlyArray, - alignments: ReadonlyArray<"left" | "right">, - wrapWidths: ReadonlyArray, -): string[] { - const wrapped = cells.map((cell, index) => wrapCellText(cell, wrapWidths[index] ?? 1)); - const height = Math.max(1, ...wrapped.map((lines) => lines.length)); - const lines: string[] = []; - for (let rowLine = 0; rowLine < height; rowLine += 1) { - const parts = widths.map((width, col) => { - const text = wrapped[col]?.[rowLine] ?? ""; - return ` ${padCell(text, width, alignments[col] ?? "left")} `; - }); - lines.push(`|${parts.join("|")}|`); - } - return lines; -} - -function roundedTop(widths: ReadonlyArray): string { - return `.${widths.map((width) => "-".repeat(width + 2)).join(".")}.`; -} - -function roundedBottom(widths: ReadonlyArray): string { - return `'${widths.map((width) => "-".repeat(width + 2)).join("'")}'`; -} - -function roundedSeparator(widths: ReadonlyArray): string { - return `:${widths.map((width) => "-".repeat(width + 2)).join("+")}:`; -} - -/** - * Rounded ASCII table matching the Discord-friendly style: - * top `.---.`, separators `:---+---:` after every row, bottom `'---'`. - */ -export function renderRoundedTable( - headers: ReadonlyArray, - rows: ReadonlyArray>, - maxColWidth = DEFAULT_MAX_COL_WIDTH, - maxTableWidth = DEFAULT_MAX_TABLE_WIDTH, -): string { - if (headers.length === 0) return ""; - const widths = computeColumnWidths(headers, rows, maxColWidth, maxTableWidth); - const alignments = columnAlignments(headers, rows); - const wrapWidths = widths.map((width) => Math.min(maxColWidth, width)); - const out: string[] = [roundedTop(widths)]; - out.push(...buildRowLinesFitted(headers, widths, alignments, wrapWidths)); - out.push(roundedSeparator(widths)); - for (let index = 0; index < rows.length; index += 1) { - out.push(...buildRowLinesFitted(rows[index] ?? [], widths, alignments, wrapWidths)); - if (index < rows.length - 1) { - out.push(roundedSeparator(widths)); - } - } - out.push(roundedBottom(widths)); - return out.join("\n"); -} - -function fenceTable(body: string): string { - return `\`\`\`\n${body}\n\`\`\``; -} - -function renderTableBody( - style: AsciiTableStyle, - headers: ReadonlyArray, - rows: ReadonlyArray>, - maxColWidth: number, - maxTableWidth: number, -): string { - return style === "mysql" - ? renderMysqlTable(headers, rows, maxColWidth, maxTableWidth) - : renderRoundedTable(headers, rows, maxColWidth, maxTableWidth); -} - -/** True when any cell would wrap at maxColWidth (long text → prefer one-row tables). */ -export function tableHasLongCells( - headers: ReadonlyArray, - rows: ReadonlyArray>, - maxColWidth: number, -): boolean { - for (const cell of headers) { - if (cell.length > maxColWidth) return true; - } - for (const row of rows) { - for (const cell of row) { - if (cell.length > maxColWidth) return true; - } - } - return false; -} - -/** - * Render a markdown table as one or more fenced ASCII tables for Discord. - * Prefer a **single** table with all rows (long cells wrap in place). - * Only split into multiple tables when the fenced body exceeds messageLimit; - * never split merely because cells are long. - * A single row that still cannot fit becomes a .txt attachment body. - */ -export function splitTableIntoDiscordBodies( - headers: ReadonlyArray, - rows: ReadonlyArray>, - options?: { - readonly style?: AsciiTableStyle; - readonly maxColWidth?: number; - readonly maxTableWidth?: number; - readonly messageLimit?: number; - }, -): { - readonly fencedChunks: ReadonlyArray; - /** Unfenced bodies that could not fit even as a single-row table. */ - readonly oversizedBodies: ReadonlyArray; -} { - const style = options?.style ?? "rounded"; - const maxColWidth = options?.maxColWidth ?? DEFAULT_MAX_COL_WIDTH; - const maxTableWidth = options?.maxTableWidth ?? DEFAULT_MAX_TABLE_WIDTH; - const messageLimit = options?.messageLimit ?? DEFAULT_MESSAGE_LIMIT; - - if (headers.length === 0 || rows.length === 0) { - return { fencedChunks: [], oversizedBodies: [] }; - } - - // Keep all rows in one table when possible; packRowsIntoGroups only splits - // if the full fenced table exceeds messageLimit. - const groups = packRowsIntoGroups(headers, rows, style, maxColWidth, maxTableWidth, messageLimit); - - const tableBodies: string[] = []; - const oversizedBodies: string[] = []; - - for (const group of groups) { - const body = renderTableBody(style, headers, group, maxColWidth, maxTableWidth); - if (fenceTable(body).length <= messageLimit) { - tableBodies.push(body); - continue; - } - // Group still too big (usually a single huge row). Attach unfenced body. - if (group.length === 1) { - oversizedBodies.push(body); - continue; - } - // Fall back to per-row only when a multi-row pack still overflows (edge case). - for (const row of group) { - const rowBody = renderTableBody(style, headers, [row], maxColWidth, maxTableWidth); - if (fenceTable(rowBody).length <= messageLimit) { - tableBodies.push(rowBody); - } else { - oversizedBodies.push(rowBody); - } - } - } - - // Pack any size-split tables into as few fences as possible. - const fencedChunks = packTableBodiesIntoFences(tableBodies, messageLimit); - return { fencedChunks, oversizedBodies }; -} - -/** - * Join unfenced ASCII tables into fenced code blocks under messageLimit. - * Prefer one fence containing several tables over adjacent fences. - */ -export function packTableBodiesIntoFences( - bodies: ReadonlyArray, - messageLimit: number, -): string[] { - const fencedChunks: string[] = []; - let pack: string[] = []; - - const flush = () => { - if (pack.length === 0) return; - fencedChunks.push(fenceTable(pack.join("\n\n"))); - pack = []; - }; - - for (const body of bodies) { - const solo = fenceTable(body); - if (solo.length > messageLimit) { - // Caller should have filtered these; skip rather than emit an oversize fence. - flush(); - continue; - } - if (pack.length === 0) { - pack = [body]; - continue; - } - const combined = fenceTable([...pack, body].join("\n\n")); - if (combined.length > messageLimit) { - flush(); - pack = [body]; - continue; - } - pack.push(body); - } - flush(); - return fencedChunks; -} - -/** - * Join multiple fenced chunks for Discord without adjacent-fence glitches. - * A zero-width space on its own line forces Discord to close/reopen snippets cleanly. - */ -export function joinFencedTableChunks(chunks: ReadonlyArray): string { - if (chunks.length === 0) return ""; - if (chunks.length === 1) return chunks[0] ?? ""; - // ZWSP between fences: Discord often drops subsequent code blocks when they - // sit back-to-back with only blank lines between them. - return chunks.join("\n\n\u200B\n\n"); -} - -function packRowsIntoGroups( - headers: ReadonlyArray, - rows: ReadonlyArray>, - style: AsciiTableStyle, - maxColWidth: number, - maxTableWidth: number, - messageLimit: number, -): Array>> { - const groups: Array>> = []; - let current: Array> = []; - - for (const row of rows) { - const candidate = [...current, row]; - const fenced = fenceTable( - renderTableBody(style, headers, candidate, maxColWidth, maxTableWidth), - ); - if (current.length > 0 && fenced.length > messageLimit) { - groups.push(current); - current = [row]; - continue; - } - current = candidate; - } - if (current.length > 0) groups.push(current); - return groups; -} - -/** - * Replace markdown pipe tables with fenced ASCII tables. - * Prefer one table; only rows/tables that still exceed the Discord limit become - * .txt attachments. Attachment names follow document reading order. - */ -export function rewriteMarkdownTablesForDiscord( - text: string, - options?: { - readonly style?: AsciiTableStyle; - readonly maxColWidth?: number; - readonly maxTableWidth?: number; - /** Soft limit for a single fenced table; over this attaches as .txt. */ - readonly messageLimit?: number; - }, -): RewriteMarkdownTablesResult { - const style = options?.style ?? "rounded"; - const maxColWidth = options?.maxColWidth ?? DEFAULT_MAX_COL_WIDTH; - const maxTableWidth = options?.maxTableWidth ?? DEFAULT_MAX_TABLE_WIDTH; - const messageLimit = options?.messageLimit ?? DEFAULT_MESSAGE_LIMIT; - const matches = extractMarkdownTables(text); - if (matches.length === 0) { - return { text, attachments: [] }; - } - - // Collect attachments in document order; name by reading order (not reverse-replace order). - const attachmentsByKey = new Map(); - let out = text; - // Replace from the end so earlier offsets stay valid. - for (let index = matches.length - 1; index >= 0; index -= 1) { - const match = matches[index]!; - const { fencedChunks, oversizedBodies } = splitTableIntoDiscordBodies( - match.headers, - match.rows, - { style, maxColWidth, maxTableWidth, messageLimit }, - ); - - const parts: string[] = []; - if (fencedChunks.length > 0) { - parts.push(joinFencedTableChunks(fencedChunks)); - } - for (let bodyIndex = 0; bodyIndex < oversizedBodies.length; bodyIndex += 1) { - const body = oversizedBodies[bodyIndex]!; - const name = oversizedTableAttachmentName({ - matchIndex: index, - bodyIndex, - matchCount: matches.length, - oversizedBodyCount: oversizedBodies.length, - hasFencedChunks: fencedChunks.length > 0, - }); - // Last write wins only if duplicate names; keys are unique per match/body. - attachmentsByKey.set(`${index}:${bodyIndex}:${name}`, { name, body }); - parts.push(`_(Table attached as \`${name}\`)_`); - } - - const replacement = parts.join("\n\n"); - out = `${out.slice(0, match.start)}${replacement}${out.slice(match.end)}`; - } - - // Reading order: earlier match index first, then body index within the match. - const attachments = [...attachmentsByKey.entries()] - .toSorted(([left], [right]) => left.localeCompare(right, undefined, { numeric: true })) - .map(([, attachment]) => attachment); - - return { text: out, attachments }; -} - -/** Stable attachment names in document reading order (table-1 = first table in message). */ -export function oversizedTableAttachmentName(input: { - readonly matchIndex: number; - readonly bodyIndex: number; - readonly matchCount: number; - readonly oversizedBodyCount: number; - readonly hasFencedChunks: boolean; -}): string { - const { matchIndex, bodyIndex, matchCount, oversizedBodyCount, hasFencedChunks } = input; - if (matchCount === 1 && oversizedBodyCount === 1 && !hasFencedChunks) { - return "table.txt"; - } - if (matchCount === 1) { - return `table-${bodyIndex + 1}.txt`; - } - if (oversizedBodyCount === 1) { - return `table-${matchIndex + 1}.txt`; - } - return `table-${matchIndex + 1}-${bodyIndex + 1}.txt`; -} - -/** - * Chunk Discord content without splitting inside fenced code blocks (including ASCII tables) - * and without splitting mid-line of a table row when possible. - */ -export function chunkDiscordContentPreservingTables( - content: string, - limit = DEFAULT_MESSAGE_LIMIT, -): string[] { - const trimmed = content.trimEnd(); - if (trimmed.length === 0) return [""]; - if (trimmed.length <= limit) return [trimmed]; - - const segments = splitPreservingFences(trimmed); - const chunks: string[] = []; - let current = ""; - - const flush = () => { - if (current.trim().length > 0) { - chunks.push(current.trimEnd()); - } - current = ""; - }; - - for (const segment of segments) { - if (segment.length > limit) { - // Oversized segment (should be rare after table attachment). Split on lines. - flush(); - let remaining = segment; - while (remaining.length > limit) { - let splitAt = remaining.lastIndexOf("\n", limit); - if (splitAt < Math.floor(limit * 0.5)) splitAt = limit; - chunks.push(remaining.slice(0, splitAt).trimEnd()); - remaining = remaining.slice(splitAt).replace(/^\n/, ""); - } - if (remaining.trim().length > 0) current = remaining; - continue; - } - - const separator = current.length > 0 ? "\n" : ""; - if (current.length + separator.length + segment.length <= limit) { - current = current.length > 0 ? `${current}\n${segment}` : segment; - continue; - } - flush(); - current = segment; - } - flush(); - return chunks.length > 0 ? chunks : [""]; -} - -/** Split text into fence blocks and non-fence blocks (each non-fence further by blank lines). */ -function splitPreservingFences(text: string): string[] { - const lines = text.split(/\r?\n/); - const segments: string[] = []; - let buffer: string[] = []; - let inFence = false; - - const flushBuffer = () => { - if (buffer.length === 0) return; - const block = buffer.join("\n"); - if (inFence) { - segments.push(block); - } else { - // Split non-fence prose on blank lines for better chunk boundaries. - const paragraphs = block.split(/\n{2,}/); - for (const paragraph of paragraphs) { - if (paragraph.length > 0) segments.push(paragraph); - } - } - buffer = []; - }; - - for (const line of lines) { - if (/^\s*```/.test(line)) { - if (!inFence) { - flushBuffer(); - inFence = true; - buffer.push(line); - } else { - buffer.push(line); - flushBuffer(); - inFence = false; - } - continue; - } - buffer.push(line); - } - flushBuffer(); - return segments; -} diff --git a/apps/discord-bot/src/presentation/markdownTables.test.ts b/apps/discord-bot/src/presentation/markdownTables.test.ts new file mode 100644 index 00000000000..01df56a5e25 --- /dev/null +++ b/apps/discord-bot/src/presentation/markdownTables.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + extractMarkdownTables, + hasMarkdownTables, + isSeparatorRow, + renderTableAsBullets, + rewriteMarkdownTablesForDiscord, + splitTableCells, +} from "./markdownTables.ts"; + +describe("splitTableCells", () => { + it("splits on unescaped pipes and trims cells", () => { + expect(splitTableCells("| Doc | What it is |")).toEqual(["Doc", "What it is"]); + expect(splitTableCells("Doc | What it is")).toEqual(["Doc", "What it is"]); + }); + + it("keeps escaped pipes inside a cell", () => { + expect(splitTableCells("| a \\| b | c |")).toEqual(["a | b", "c"]); + }); +}); + +describe("isSeparatorRow", () => { + it("accepts GFM separator cells", () => { + expect(isSeparatorRow(["---", ":---", "---:", ":---:"])).toBe(true); + expect(isSeparatorRow(["Doc", "What"])).toBe(false); + }); +}); + +describe("extractMarkdownTables", () => { + it("extracts a short unfenced table", () => { + const text = `| Doc | What it actually is | +|---|---| +| effect-cluster-worker-migration.md | Mixed topology notes. | +| durable-print-roundtrip.md | EasyLife workflow. |`; + + const matches = extractMarkdownTables(text); + expect(matches).toHaveLength(1); + expect(matches[0]?.headers).toEqual(["Doc", "What it actually is"]); + expect(matches[0]?.rows).toEqual([ + ["effect-cluster-worker-migration.md", "Mixed topology notes."], + ["durable-print-roundtrip.md", "EasyLife workflow."], + ]); + }); + + it("extracts a table wrapped in a code fence", () => { + const text = `\`\`\`md +| Doc | What | +|---|---| +| a.md | first | +\`\`\``; + const matches = extractMarkdownTables(text); + expect(matches).toHaveLength(1); + expect(matches[0]?.headers).toEqual(["Doc", "What"]); + expect(matches[0]?.rows).toEqual([["a.md", "first"]]); + expect(matches[0]?.raw.startsWith("```")).toBe(true); + }); + + it("returns empty when there is no complete table", () => { + expect(extractMarkdownTables("just a paragraph\nand another")).toEqual([]); + expect(extractMarkdownTables("| not a table without separator |")).toEqual([]); + }); + + it("requires at least two columns", () => { + const text = `| Note | +|---| +| only |`; + expect(extractMarkdownTables(text)).toEqual([]); + }); +}); + +describe("renderTableAsBullets", () => { + it("formats two-column tables as labeled bullets", () => { + expect( + renderTableAsBullets( + ["Piece", "Behavior"], + [ + ["Event Hub meta", "enqueuedTimeUtc + sequenceNumber"], + ["Logging", "Warning with order id"], + ], + ), + ).toBe( + [ + "- **Event Hub meta:** enqueuedTimeUtc + sequenceNumber", + "- **Logging:** Warning with order id", + ].join("\n"), + ); + }); + + it("formats wider tables with header labels per field", () => { + expect( + renderTableAsBullets( + ["Name", "Amount", "Unit"], + [ + ["a", "10", "kg"], + ["b", "2", "g"], + ], + ), + ).toBe( + [ + "- **Name:** a · **Amount:** 10 · **Unit:** kg", + "- **Name:** b · **Amount:** 2 · **Unit:** g", + ].join("\n"), + ); + }); +}); + +describe("rewriteMarkdownTablesForDiscord", () => { + it("rewrites a short table to bullets and drops pipe syntax", () => { + const input = `| Doc | What | +|---|---| +| a.md | first |`; + const result = rewriteMarkdownTablesForDiscord(input); + expect(result).toBe("- **a.md:** first"); + expect(result).not.toContain("|"); + expect(result).not.toContain("---"); + }); + + it("preserves surrounding text", () => { + const input = `Before. + +| A | B | +|---|---| +| 1 | 2 | + +After.`; + const result = rewriteMarkdownTablesForDiscord(input); + expect(result.startsWith("Before.")).toBe(true); + expect(result.endsWith("After.")).toBe(true); + expect(result).toContain("- **1:** 2"); + expect(result).not.toContain("|---|"); + }); + + it("is a passthrough when there is no table", () => { + const input = "No tables here, just prose."; + expect(rewriteMarkdownTablesForDiscord(input)).toBe(input); + }); + + it("replaces fenced markdown tables", () => { + const input = `\`\`\`md +| Doc | What | +|---|---| +| a.md | first | +\`\`\``; + const result = rewriteMarkdownTablesForDiscord(input); + expect(result).not.toContain("```"); + expect(result).toBe("- **a.md:** first"); + }); + + it("hasMarkdownTables tracks complete tables only", () => { + expect(hasMarkdownTables("| Doc | What |\n|---|---|\n| a | b |")).toBe(true); + expect(hasMarkdownTables("no table")).toBe(false); + }); +}); diff --git a/apps/discord-bot/src/presentation/markdownTables.ts b/apps/discord-bot/src/presentation/markdownTables.ts new file mode 100644 index 00000000000..28bed7f90fe --- /dev/null +++ b/apps/discord-bot/src/presentation/markdownTables.ts @@ -0,0 +1,275 @@ +/** + * Detect GFM pipe tables and rewrite them for Discord delivery. + * Discord does not render markdown tables; raw pipes are unreadable. + * Convert complete tables to bullet lists (content kept, grid dropped). + */ + +export interface TableMatch { + /** Inclusive start offset of the matched source span. */ + readonly start: number; + /** Exclusive end offset of the matched source span. */ + readonly end: number; + /** Original matched text (may include surrounding code fences). */ + readonly raw: string; + readonly headers: ReadonlyArray; + readonly rows: ReadonlyArray>; +} + +/** Split a markdown table row into cells on unescaped `|`. */ +export function splitTableCells(line: string): string[] { + let body = line.trim(); + if (body.startsWith("|")) body = body.slice(1); + if (body.endsWith("|")) body = body.slice(0, -1); + + const cells: string[] = []; + let current = ""; + let escaped = false; + for (const char of body) { + if (escaped) { + current += char; + escaped = false; + continue; + } + if (char === "\\") { + escaped = true; + continue; + } + if (char === "|") { + cells.push(unescapeTableCell(current.trim())); + current = ""; + continue; + } + current += char; + } + cells.push(unescapeTableCell(current.trim())); + return cells; +} + +function unescapeTableCell(value: string): string { + return value + .replace(/\\([\\|`*_{}[\]()#+\-.!])/g, "$1") + .replace(/\s+/g, " ") + .trim(); +} + +/** True when every cell looks like a GFM separator segment (`---`, `:---:`, etc.). */ +export function isSeparatorRow(cells: ReadonlyArray): boolean { + if (cells.length === 0) return false; + return cells.every((cell) => /^:?-{1,}:?$/.test(cell.replace(/\s+/g, ""))); +} + +function isTableRowLine(line: string): boolean { + const trimmed = line.trim(); + if (trimmed.length === 0) return false; + if (!trimmed.includes("|")) return false; + if (trimmed.startsWith("```")) return false; + return true; +} + +function normalizeRow(cells: ReadonlyArray, columnCount: number): string[] { + const row = cells.slice(0, columnCount).map((cell) => cell); + while (row.length < columnCount) row.push(""); + return row; +} + +function tryParseTableLines( + lines: ReadonlyArray, + startIndex: number, +): { + readonly endIndex: number; + readonly headers: string[]; + readonly rows: string[][]; +} | null { + if (startIndex + 1 >= lines.length) return null; + const headerLine = lines[startIndex] ?? ""; + const separatorLine = lines[startIndex + 1] ?? ""; + if (!isTableRowLine(headerLine) || !isTableRowLine(separatorLine)) return null; + + const headers = splitTableCells(headerLine); + const separatorCells = splitTableCells(separatorLine); + if (headers.length === 0 || !isSeparatorRow(separatorCells)) return null; + // Require ≥2 columns so bash/prose like `| note` + `|---` is not treated as a table. + if (headers.length < 2 || separatorCells.length < 2) return null; + if (Math.abs(headers.length - separatorCells.length) > 1) return null; + + const columnCount = Math.max(headers.length, separatorCells.length); + const normalizedHeaders = normalizeRow(headers, columnCount); + const rows: string[][] = []; + let endIndex = startIndex + 1; + + for (let index = startIndex + 2; index < lines.length; index += 1) { + const line = lines[index] ?? ""; + if (!isTableRowLine(line)) break; + const cells = splitTableCells(line); + if (isSeparatorRow(cells)) break; + rows.push(normalizeRow(cells, columnCount)); + endIndex = index; + } + + if (rows.length === 0) return null; + return { endIndex, headers: normalizedHeaders, rows }; +} + +/** Exclusive end offset for `lineIndex`, including its trailing newline when present. */ +function lineEndExclusive( + text: string, + lineStarts: ReadonlyArray, + lines: ReadonlyArray, + lineIndex: number, +): number { + const start = lineStarts[lineIndex] ?? text.length; + const line = lines[lineIndex] ?? ""; + let exclusiveEnd = start + line.length; + if (exclusiveEnd < text.length && (text[exclusiveEnd] === "\n" || text[exclusiveEnd] === "\r")) { + exclusiveEnd = + text[exclusiveEnd] === "\r" && text[exclusiveEnd + 1] === "\n" + ? exclusiveEnd + 2 + : exclusiveEnd + 1; + } + return exclusiveEnd; +} + +/** + * Find GFM pipe tables in `text`. + * Also matches tables wrapped in fenced code blocks (``` / ```bash / etc.). + */ +export function extractMarkdownTables(text: string): TableMatch[] { + const matches: TableMatch[] = []; + const lineStarts: number[] = [0]; + for (let index = 0; index < text.length; index += 1) { + if (text[index] === "\n") lineStarts.push(index + 1); + } + const lines = text.split(/\r?\n/); + + let lineIndex = 0; + while (lineIndex < lines.length) { + const line = lines[lineIndex] ?? ""; + const fenceOpen = line.match(/^(\s*)```([\w+-]*)\s*$/); + if (fenceOpen) { + const openIndex = lineIndex; + let closeIndex = -1; + for (let probe = lineIndex + 1; probe < lines.length; probe += 1) { + if (/^\s*```\s*$/.test(lines[probe] ?? "")) { + closeIndex = probe; + break; + } + } + if (closeIndex === -1) { + lineIndex += 1; + continue; + } + + const innerStart = openIndex + 1; + let tableStart = innerStart; + while (tableStart < closeIndex && (lines[tableStart] ?? "").trim() === "") { + tableStart += 1; + } + const parsed = tryParseTableLines(lines, tableStart); + if (parsed !== null) { + let afterTable = parsed.endIndex + 1; + while (afterTable < closeIndex && (lines[afterTable] ?? "").trim() === "") { + afterTable += 1; + } + if (afterTable === closeIndex) { + const start = lineStarts[openIndex] ?? 0; + const exclusiveEnd = lineEndExclusive(text, lineStarts, lines, closeIndex); + matches.push({ + start, + end: exclusiveEnd, + raw: text.slice(start, exclusiveEnd), + headers: parsed.headers, + rows: parsed.rows, + }); + lineIndex = closeIndex + 1; + continue; + } + } + lineIndex = closeIndex + 1; + continue; + } + + const parsed = tryParseTableLines(lines, lineIndex); + if (parsed === null) { + lineIndex += 1; + continue; + } + + const start = lineStarts[lineIndex] ?? 0; + const exclusiveEnd = lineEndExclusive(text, lineStarts, lines, parsed.endIndex); + matches.push({ + start, + end: exclusiveEnd, + raw: text.slice(start, exclusiveEnd), + headers: parsed.headers, + rows: parsed.rows, + }); + lineIndex = parsed.endIndex + 1; + } + + return matches; +} + +/** True when `text` contains at least one GFM pipe table. */ +export function hasMarkdownTables(text: string): boolean { + return extractMarkdownTables(text).length > 0; +} + +/** + * Render a GFM table as Discord-readable bullets. + * Two-column tables use `- **left:** right`. Wider tables use labeled fields per row. + */ +export function renderTableAsBullets( + headers: ReadonlyArray, + rows: ReadonlyArray>, +): string { + if (rows.length === 0) { + return headers + .map((header) => header.trim()) + .filter((header) => header.length > 0) + .map((header) => `- ${header}`) + .join("\n"); + } + + if (headers.length === 2) { + return rows + .map((row) => { + const key = (row[0] ?? "").trim() || (headers[0] ?? "").trim() || "Item"; + const value = (row[1] ?? "").trim(); + if (value === "") return `- **${key}**`; + return `- **${key}:** ${value}`; + }) + .join("\n"); + } + + return rows + .map((row) => { + const parts: string[] = []; + for (let index = 0; index < headers.length; index += 1) { + const header = (headers[index] ?? "").trim() || `Col ${index + 1}`; + const cell = (row[index] ?? "").trim(); + if (cell === "") continue; + parts.push(`**${header}:** ${cell}`); + } + if (parts.length === 0) return null; + return `- ${parts.join(" · ")}`; + }) + .filter((line): line is string => line !== null) + .join("\n"); +} + +/** + * Replace GFM pipe tables with bullet lists for Discord channel content. + * Incomplete mid-stream tables (no separator / rows yet) are left alone. + */ +export function rewriteMarkdownTablesForDiscord(text: string): string { + const matches = extractMarkdownTables(text); + if (matches.length === 0) return text; + + let out = text; + for (let index = matches.length - 1; index >= 0; index -= 1) { + const match = matches[index]!; + const replacement = renderTableAsBullets(match.headers, match.rows); + out = `${out.slice(0, match.start)}${replacement}${out.slice(match.end)}`; + } + return out; +}