feat(telegram): convert markdown tables to bullet points (#1495)
Tables render poorly in Telegram (pipes stripped, whitespace collapses). This adds a 'tableMode' option to markdownToIR that converts tables to nested bullet points, which render cleanly on mobile. - Add tableMode: 'flat' | 'bullets' to MarkdownParseOptions - Track table state during token rendering - Render tables as bullet points with first column as row labels - Apply bold styling to row labels for visual hierarchy - Enable tableMode: 'bullets' for Telegram formatter Closes #TBD
This commit is contained in:
91
src/markdown/ir.table-bullets.test.ts
Normal file
91
src/markdown/ir.table-bullets.test.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { markdownToIR } from "./ir.js";
|
||||||
|
|
||||||
|
describe("markdownToIR tableMode bullets", () => {
|
||||||
|
it("converts simple table to bullets", () => {
|
||||||
|
const md = `
|
||||||
|
| Name | Value |
|
||||||
|
|------|-------|
|
||||||
|
| A | 1 |
|
||||||
|
| B | 2 |
|
||||||
|
`.trim();
|
||||||
|
|
||||||
|
const ir = markdownToIR(md, { tableMode: "bullets" });
|
||||||
|
|
||||||
|
// Should contain bullet points with header:value format
|
||||||
|
expect(ir.text).toContain("• Value: 1");
|
||||||
|
expect(ir.text).toContain("• Value: 2");
|
||||||
|
// Should use first column as labels
|
||||||
|
expect(ir.text).toContain("A");
|
||||||
|
expect(ir.text).toContain("B");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles table with multiple columns", () => {
|
||||||
|
const md = `
|
||||||
|
| Feature | SQLite | Postgres |
|
||||||
|
|---------|--------|----------|
|
||||||
|
| Speed | Fast | Medium |
|
||||||
|
| Scale | Small | Large |
|
||||||
|
`.trim();
|
||||||
|
|
||||||
|
const ir = markdownToIR(md, { tableMode: "bullets" });
|
||||||
|
|
||||||
|
// First column becomes row label
|
||||||
|
expect(ir.text).toContain("Speed");
|
||||||
|
expect(ir.text).toContain("Scale");
|
||||||
|
// Other columns become bullet points
|
||||||
|
expect(ir.text).toContain("• SQLite: Fast");
|
||||||
|
expect(ir.text).toContain("• Postgres: Medium");
|
||||||
|
expect(ir.text).toContain("• SQLite: Small");
|
||||||
|
expect(ir.text).toContain("• Postgres: Large");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves flat mode as default", () => {
|
||||||
|
const md = `
|
||||||
|
| A | B |
|
||||||
|
|---|---|
|
||||||
|
| 1 | 2 |
|
||||||
|
`.trim();
|
||||||
|
|
||||||
|
const ir = markdownToIR(md); // default is flat
|
||||||
|
|
||||||
|
// Flat mode uses tabs
|
||||||
|
expect(ir.text).toContain("A");
|
||||||
|
expect(ir.text).toContain("B");
|
||||||
|
expect(ir.text).toContain("1");
|
||||||
|
expect(ir.text).toContain("2");
|
||||||
|
// Should not have bullet formatting
|
||||||
|
expect(ir.text).not.toContain("•");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles empty cells gracefully", () => {
|
||||||
|
const md = `
|
||||||
|
| Name | Value |
|
||||||
|
|------|-------|
|
||||||
|
| A | |
|
||||||
|
| B | 2 |
|
||||||
|
`.trim();
|
||||||
|
|
||||||
|
const ir = markdownToIR(md, { tableMode: "bullets" });
|
||||||
|
|
||||||
|
// Should handle empty cell without crashing
|
||||||
|
expect(ir.text).toContain("B");
|
||||||
|
expect(ir.text).toContain("• Value: 2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bolds row labels in bullets mode", () => {
|
||||||
|
const md = `
|
||||||
|
| Name | Value |
|
||||||
|
|------|-------|
|
||||||
|
| Row1 | Data1 |
|
||||||
|
`.trim();
|
||||||
|
|
||||||
|
const ir = markdownToIR(md, { tableMode: "bullets" });
|
||||||
|
|
||||||
|
// Should have bold style for row label
|
||||||
|
const hasRowLabelBold = ir.styles.some(
|
||||||
|
(s) => s.style === "bold" && ir.text.slice(s.start, s.end) === "Row1"
|
||||||
|
);
|
||||||
|
expect(hasRowLabelBold).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -12,6 +12,21 @@ type LinkState = {
|
|||||||
labelStart: number;
|
labelStart: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type TableCell = {
|
||||||
|
content: string;
|
||||||
|
isHeader: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TableRow = TableCell[];
|
||||||
|
|
||||||
|
type TableState = {
|
||||||
|
headers: string[];
|
||||||
|
rows: TableRow[];
|
||||||
|
currentRow: TableCell[];
|
||||||
|
currentCell: string;
|
||||||
|
inHeader: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
type RenderEnv = {
|
type RenderEnv = {
|
||||||
listStack: ListState[];
|
listStack: ListState[];
|
||||||
linkStack: LinkState[];
|
linkStack: LinkState[];
|
||||||
@@ -50,6 +65,8 @@ type OpenStyle = {
|
|||||||
start: number;
|
start: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type TableRenderMode = "flat" | "bullets";
|
||||||
|
|
||||||
type RenderState = {
|
type RenderState = {
|
||||||
text: string;
|
text: string;
|
||||||
styles: MarkdownStyleSpan[];
|
styles: MarkdownStyleSpan[];
|
||||||
@@ -59,6 +76,8 @@ type RenderState = {
|
|||||||
headingStyle: "none" | "bold";
|
headingStyle: "none" | "bold";
|
||||||
blockquotePrefix: string;
|
blockquotePrefix: string;
|
||||||
enableSpoilers: boolean;
|
enableSpoilers: boolean;
|
||||||
|
tableMode: TableRenderMode;
|
||||||
|
table: TableState | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MarkdownParseOptions = {
|
export type MarkdownParseOptions = {
|
||||||
@@ -67,6 +86,8 @@ export type MarkdownParseOptions = {
|
|||||||
headingStyle?: "none" | "bold";
|
headingStyle?: "none" | "bold";
|
||||||
blockquotePrefix?: string;
|
blockquotePrefix?: string;
|
||||||
autolink?: boolean;
|
autolink?: boolean;
|
||||||
|
/** How to render tables: "flat" (tabs/newlines) or "bullets" (nested bullet list). Default: "flat" */
|
||||||
|
tableMode?: TableRenderMode;
|
||||||
};
|
};
|
||||||
|
|
||||||
function createMarkdownIt(options: MarkdownParseOptions): MarkdownIt {
|
function createMarkdownIt(options: MarkdownParseOptions): MarkdownIt {
|
||||||
@@ -77,6 +98,7 @@ function createMarkdownIt(options: MarkdownParseOptions): MarkdownIt {
|
|||||||
typographer: false,
|
typographer: false,
|
||||||
});
|
});
|
||||||
md.enable("strikethrough");
|
md.enable("strikethrough");
|
||||||
|
md.enable("table");
|
||||||
if (options.autolink === false) {
|
if (options.autolink === false) {
|
||||||
md.disable("autolink");
|
md.disable("autolink");
|
||||||
}
|
}
|
||||||
@@ -146,6 +168,11 @@ function injectSpoilersIntoInline(tokens: MarkdownToken[]): MarkdownToken[] {
|
|||||||
|
|
||||||
function appendText(state: RenderState, value: string) {
|
function appendText(state: RenderState, value: string) {
|
||||||
if (!value) return;
|
if (!value) return;
|
||||||
|
// If we're inside a table cell in bullets mode, collect into cell buffer
|
||||||
|
if (state.table && state.tableMode === "bullets") {
|
||||||
|
state.table.currentCell += value;
|
||||||
|
return;
|
||||||
|
}
|
||||||
state.text += value;
|
state.text += value;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,7 +196,8 @@ function closeStyle(state: RenderState, style: MarkdownStyle) {
|
|||||||
|
|
||||||
function appendParagraphSeparator(state: RenderState) {
|
function appendParagraphSeparator(state: RenderState) {
|
||||||
if (state.env.listStack.length > 0) return;
|
if (state.env.listStack.length > 0) return;
|
||||||
appendText(state, "\n\n");
|
if (state.table) return; // Don't add paragraph separators inside tables
|
||||||
|
state.text += "\n\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
function appendListPrefix(state: RenderState) {
|
function appendListPrefix(state: RenderState) {
|
||||||
@@ -179,13 +207,18 @@ function appendListPrefix(state: RenderState) {
|
|||||||
top.index += 1;
|
top.index += 1;
|
||||||
const indent = " ".repeat(Math.max(0, stack.length - 1));
|
const indent = " ".repeat(Math.max(0, stack.length - 1));
|
||||||
const prefix = top.type === "ordered" ? `${top.index}. ` : "• ";
|
const prefix = top.type === "ordered" ? `${top.index}. ` : "• ";
|
||||||
appendText(state, `${indent}${prefix}`);
|
state.text += `${indent}${prefix}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderInlineCode(state: RenderState, content: string) {
|
function renderInlineCode(state: RenderState, content: string) {
|
||||||
if (!content) return;
|
if (!content) return;
|
||||||
|
// In bullets mode inside table, just add text without styling
|
||||||
|
if (state.table && state.tableMode === "bullets") {
|
||||||
|
state.table.currentCell += content;
|
||||||
|
return;
|
||||||
|
}
|
||||||
const start = state.text.length;
|
const start = state.text.length;
|
||||||
appendText(state, content);
|
state.text += content;
|
||||||
state.styles.push({ start, end: start + content.length, style: "code" });
|
state.styles.push({ start, end: start + content.length, style: "code" });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,10 +226,10 @@ function renderCodeBlock(state: RenderState, content: string) {
|
|||||||
let code = content ?? "";
|
let code = content ?? "";
|
||||||
if (!code.endsWith("\n")) code = `${code}\n`;
|
if (!code.endsWith("\n")) code = `${code}\n`;
|
||||||
const start = state.text.length;
|
const start = state.text.length;
|
||||||
appendText(state, code);
|
state.text += code;
|
||||||
state.styles.push({ start, end: start + code.length, style: "code_block" });
|
state.styles.push({ start, end: start + code.length, style: "code_block" });
|
||||||
if (state.env.listStack.length === 0) {
|
if (state.env.listStack.length === 0) {
|
||||||
appendText(state, "\n");
|
state.text += "\n";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,6 +247,89 @@ function handleLinkClose(state: RenderState) {
|
|||||||
state.links.push({ start, end, href });
|
state.links.push({ start, end, href });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function initTableState(): TableState {
|
||||||
|
return {
|
||||||
|
headers: [],
|
||||||
|
rows: [],
|
||||||
|
currentRow: [],
|
||||||
|
currentCell: "",
|
||||||
|
inHeader: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTableAsBullets(state: RenderState) {
|
||||||
|
if (!state.table) return;
|
||||||
|
const { headers, rows } = state.table;
|
||||||
|
|
||||||
|
// If no headers or rows, skip
|
||||||
|
if (headers.length === 0 && rows.length === 0) return;
|
||||||
|
|
||||||
|
// Determine if first column should be used as row labels
|
||||||
|
// (common pattern: first column is category/feature name)
|
||||||
|
const useFirstColAsLabel = headers.length > 1 && rows.length > 0;
|
||||||
|
|
||||||
|
if (useFirstColAsLabel) {
|
||||||
|
// Format: each row becomes a section with header as row[0], then key:value pairs
|
||||||
|
for (const row of rows) {
|
||||||
|
if (row.length === 0) continue;
|
||||||
|
|
||||||
|
const rowLabel = row[0]?.content?.trim() || "";
|
||||||
|
if (rowLabel) {
|
||||||
|
// Bold the row label
|
||||||
|
const start = state.text.length;
|
||||||
|
state.text += rowLabel;
|
||||||
|
state.styles.push({ start, end: state.text.length, style: "bold" });
|
||||||
|
state.text += "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add each column as a bullet point
|
||||||
|
for (let i = 1; i < row.length; i++) {
|
||||||
|
const header = headers[i]?.trim() || `Column ${i}`;
|
||||||
|
const value = row[i]?.content?.trim() || "";
|
||||||
|
if (value) {
|
||||||
|
state.text += `• ${header}: ${value}\n`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.text += "\n";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Simple table: just list headers and values
|
||||||
|
for (const row of rows) {
|
||||||
|
for (let i = 0; i < row.length; i++) {
|
||||||
|
const header = headers[i]?.trim() || "";
|
||||||
|
const value = row[i]?.content?.trim() || "";
|
||||||
|
if (header && value) {
|
||||||
|
state.text += `• ${header}: ${value}\n`;
|
||||||
|
} else if (value) {
|
||||||
|
state.text += `• ${value}\n`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.text += "\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTableAsFlat(state: RenderState) {
|
||||||
|
if (!state.table) return;
|
||||||
|
const { headers, rows } = state.table;
|
||||||
|
|
||||||
|
// Render headers
|
||||||
|
for (const header of headers) {
|
||||||
|
state.text += header.trim() + "\t";
|
||||||
|
}
|
||||||
|
if (headers.length > 0) {
|
||||||
|
state.text = state.text.trimEnd() + "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render rows
|
||||||
|
for (const row of rows) {
|
||||||
|
for (const cell of row) {
|
||||||
|
state.text += cell.content.trim() + "\t";
|
||||||
|
}
|
||||||
|
state.text = state.text.trimEnd() + "\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function renderTokens(tokens: MarkdownToken[], state: RenderState): void {
|
function renderTokens(tokens: MarkdownToken[], state: RenderState): void {
|
||||||
for (const token of tokens) {
|
for (const token of tokens) {
|
||||||
switch (token.type) {
|
switch (token.type) {
|
||||||
@@ -276,10 +392,10 @@ function renderTokens(tokens: MarkdownToken[], state: RenderState): void {
|
|||||||
appendParagraphSeparator(state);
|
appendParagraphSeparator(state);
|
||||||
break;
|
break;
|
||||||
case "blockquote_open":
|
case "blockquote_open":
|
||||||
if (state.blockquotePrefix) appendText(state, state.blockquotePrefix);
|
if (state.blockquotePrefix) state.text += state.blockquotePrefix;
|
||||||
break;
|
break;
|
||||||
case "blockquote_close":
|
case "blockquote_close":
|
||||||
appendText(state, "\n");
|
state.text += "\n";
|
||||||
break;
|
break;
|
||||||
case "bullet_list_open":
|
case "bullet_list_open":
|
||||||
state.env.listStack.push({ type: "bullet", index: 0 });
|
state.env.listStack.push({ type: "bullet", index: 0 });
|
||||||
@@ -299,7 +415,7 @@ function renderTokens(tokens: MarkdownToken[], state: RenderState): void {
|
|||||||
appendListPrefix(state);
|
appendListPrefix(state);
|
||||||
break;
|
break;
|
||||||
case "list_item_close":
|
case "list_item_close":
|
||||||
appendText(state, "\n");
|
state.text += "\n";
|
||||||
break;
|
break;
|
||||||
case "code_block":
|
case "code_block":
|
||||||
case "fence":
|
case "fence":
|
||||||
@@ -309,22 +425,74 @@ function renderTokens(tokens: MarkdownToken[], state: RenderState): void {
|
|||||||
case "html_inline":
|
case "html_inline":
|
||||||
appendText(state, token.content ?? "");
|
appendText(state, token.content ?? "");
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
// Table handling
|
||||||
case "table_open":
|
case "table_open":
|
||||||
|
if (state.tableMode === "bullets") {
|
||||||
|
state.table = initTableState();
|
||||||
|
}
|
||||||
|
break;
|
||||||
case "table_close":
|
case "table_close":
|
||||||
|
if (state.tableMode === "bullets" && state.table) {
|
||||||
|
renderTableAsBullets(state);
|
||||||
|
} else if (state.tableMode === "flat" && state.table) {
|
||||||
|
renderTableAsFlat(state);
|
||||||
|
}
|
||||||
|
state.table = null;
|
||||||
|
break;
|
||||||
case "thead_open":
|
case "thead_open":
|
||||||
|
if (state.table) {
|
||||||
|
state.table.inHeader = true;
|
||||||
|
}
|
||||||
|
break;
|
||||||
case "thead_close":
|
case "thead_close":
|
||||||
|
if (state.table) {
|
||||||
|
state.table.inHeader = false;
|
||||||
|
}
|
||||||
|
break;
|
||||||
case "tbody_open":
|
case "tbody_open":
|
||||||
case "tbody_close":
|
case "tbody_close":
|
||||||
break;
|
break;
|
||||||
|
case "tr_open":
|
||||||
|
if (state.table) {
|
||||||
|
state.table.currentRow = [];
|
||||||
|
}
|
||||||
|
break;
|
||||||
case "tr_close":
|
case "tr_close":
|
||||||
appendText(state, "\n");
|
if (state.table) {
|
||||||
|
if (state.table.inHeader) {
|
||||||
|
state.table.headers = state.table.currentRow.map((c) => c.content);
|
||||||
|
} else {
|
||||||
|
state.table.rows.push(state.table.currentRow);
|
||||||
|
}
|
||||||
|
state.table.currentRow = [];
|
||||||
|
} else if (state.tableMode === "flat") {
|
||||||
|
// Legacy flat mode without table state
|
||||||
|
state.text += "\n";
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "th_open":
|
||||||
|
case "td_open":
|
||||||
|
if (state.table) {
|
||||||
|
state.table.currentCell = "";
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case "th_close":
|
case "th_close":
|
||||||
case "td_close":
|
case "td_close":
|
||||||
appendText(state, "\t");
|
if (state.table) {
|
||||||
|
state.table.currentRow.push({
|
||||||
|
content: state.table.currentCell,
|
||||||
|
isHeader: token.type === "th_close",
|
||||||
|
});
|
||||||
|
state.table.currentCell = "";
|
||||||
|
} else if (state.tableMode === "flat") {
|
||||||
|
// Legacy flat mode without table state
|
||||||
|
state.text += "\t";
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "hr":
|
case "hr":
|
||||||
appendText(state, "\n");
|
state.text += "\n";
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
if (token.children) renderTokens(token.children, state);
|
if (token.children) renderTokens(token.children, state);
|
||||||
@@ -433,6 +601,8 @@ export function markdownToIR(markdown: string, options: MarkdownParseOptions = {
|
|||||||
applySpoilerTokens(tokens as MarkdownToken[]);
|
applySpoilerTokens(tokens as MarkdownToken[]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tableMode = options.tableMode ?? "flat";
|
||||||
|
|
||||||
const state: RenderState = {
|
const state: RenderState = {
|
||||||
text: "",
|
text: "",
|
||||||
styles: [],
|
styles: [],
|
||||||
@@ -442,6 +612,8 @@ export function markdownToIR(markdown: string, options: MarkdownParseOptions = {
|
|||||||
headingStyle: options.headingStyle ?? "none",
|
headingStyle: options.headingStyle ?? "none",
|
||||||
blockquotePrefix: options.blockquotePrefix ?? "",
|
blockquotePrefix: options.blockquotePrefix ?? "",
|
||||||
enableSpoilers: options.enableSpoilers ?? false,
|
enableSpoilers: options.enableSpoilers ?? false,
|
||||||
|
tableMode,
|
||||||
|
table: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
renderTokens(tokens as MarkdownToken[], state);
|
renderTokens(tokens as MarkdownToken[], state);
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ export function markdownToTelegramHtml(markdown: string): string {
|
|||||||
linkify: true,
|
linkify: true,
|
||||||
headingStyle: "none",
|
headingStyle: "none",
|
||||||
blockquotePrefix: "",
|
blockquotePrefix: "",
|
||||||
|
tableMode: "bullets",
|
||||||
});
|
});
|
||||||
return renderTelegramHtml(ir);
|
return renderTelegramHtml(ir);
|
||||||
}
|
}
|
||||||
@@ -63,6 +64,7 @@ export function markdownToTelegramChunks(
|
|||||||
linkify: true,
|
linkify: true,
|
||||||
headingStyle: "none",
|
headingStyle: "none",
|
||||||
blockquotePrefix: "",
|
blockquotePrefix: "",
|
||||||
|
tableMode: "bullets",
|
||||||
});
|
});
|
||||||
const chunks = chunkMarkdownIR(ir, limit);
|
const chunks = chunkMarkdownIR(ir, limit);
|
||||||
return chunks.map((chunk) => ({
|
return chunks.map((chunk) => ({
|
||||||
|
|||||||
Reference in New Issue
Block a user