@inceptionstack/roundhouse 0.3.21 → 0.3.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/telegram-format.ts +85 -0
package/package.json
CHANGED
package/src/telegram-format.ts
CHANGED
|
@@ -93,6 +93,75 @@ export function truncateHtmlSafe(html: string, limit: number): string {
|
|
|
93
93
|
return html.slice(0, safeEnd) + "...";
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
/**
|
|
97
|
+
* Convert a markdown table into a <pre>-wrapped, column-aligned monospace table.
|
|
98
|
+
* Parses the header row, skips the separator row, and pads all columns to uniform width.
|
|
99
|
+
*/
|
|
100
|
+
function formatTable(tableMd: string): string {
|
|
101
|
+
const lines = tableMd.trim().split("\n");
|
|
102
|
+
if (lines.length < 2) return `<pre>${escapeHtml(tableMd)}</pre>`;
|
|
103
|
+
|
|
104
|
+
// Parse rows: split by | and trim each cell
|
|
105
|
+
const parseRow = (line: string): string[] =>
|
|
106
|
+
line.replace(/^\|/, "").replace(/\|$/, "").split("|").map(c => c.trim());
|
|
107
|
+
|
|
108
|
+
const headerCells = parseRow(lines[0]);
|
|
109
|
+
// lines[1] is the separator row (|---|---|) — skip it
|
|
110
|
+
const dataRows = lines.slice(2).map(parseRow);
|
|
111
|
+
const colCount = headerCells.length;
|
|
112
|
+
|
|
113
|
+
// Normalize rows to exactly colCount columns
|
|
114
|
+
const normalize = (cells: string[]): string[] =>
|
|
115
|
+
Array.from({ length: colCount }, (_, i) => cells[i] ?? "");
|
|
116
|
+
|
|
117
|
+
const rawHeader = normalize(headerCells);
|
|
118
|
+
const rawDataRows = dataRows.map(normalize);
|
|
119
|
+
const allRows = [rawHeader, ...rawDataRows];
|
|
120
|
+
|
|
121
|
+
// Visual length of a string (grapheme count via Intl.Segmenter)
|
|
122
|
+
const segmenter = new Intl.Segmenter();
|
|
123
|
+
const visualLen = (s: string): number => [...segmenter.segment(s)].length;
|
|
124
|
+
|
|
125
|
+
// Calculate max *visual* width for each column (on unescaped text,
|
|
126
|
+
// since Telegram renders entities back to their visual form in <pre>)
|
|
127
|
+
const colWidths: number[] = [];
|
|
128
|
+
for (let c = 0; c < colCount; c++) {
|
|
129
|
+
let max = 0;
|
|
130
|
+
for (const row of allRows) {
|
|
131
|
+
max = Math.max(max, visualLen(row[c]));
|
|
132
|
+
}
|
|
133
|
+
colWidths.push(max);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Pad an escaped cell so it visually aligns to `width` rendered characters.
|
|
137
|
+
// We add (targetVisualWidth - actualVisualWidth) spaces, since spaces are
|
|
138
|
+
// 1 char both in HTML source and visually.
|
|
139
|
+
const padCell = (rawText: string, width: number): string => {
|
|
140
|
+
const escaped = escapeHtml(rawText);
|
|
141
|
+
const vLen = visualLen(rawText);
|
|
142
|
+
return escaped + " ".repeat(Math.max(0, width - vLen));
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
// Build formatted rows
|
|
146
|
+
const formatRow = (cells: string[]): string =>
|
|
147
|
+
"│ " + cells.map((cell, i) => padCell(cell, colWidths[i])).join(" │ ") + " │";
|
|
148
|
+
|
|
149
|
+
const separator = "├─" + colWidths.map(w => "─".repeat(w)).join("─┼─") + "─┤";
|
|
150
|
+
const topBorder = "┌─" + colWidths.map(w => "─".repeat(w)).join("─┬─") + "─┐";
|
|
151
|
+
const bottomBorder = "└─" + colWidths.map(w => "─".repeat(w)).join("─┴─") + "─┘";
|
|
152
|
+
|
|
153
|
+
// Cells are escaped inside padCell; box-drawing chars are HTML-safe.
|
|
154
|
+
const result = [
|
|
155
|
+
topBorder,
|
|
156
|
+
formatRow(rawHeader),
|
|
157
|
+
separator,
|
|
158
|
+
...rawDataRows.map(formatRow),
|
|
159
|
+
bottomBorder,
|
|
160
|
+
].join("\n");
|
|
161
|
+
|
|
162
|
+
return `<pre>${result}</pre>`;
|
|
163
|
+
}
|
|
164
|
+
|
|
96
165
|
/**
|
|
97
166
|
* Convert markdown text to Telegram-compatible HTML.
|
|
98
167
|
* Handles code blocks first (to avoid processing markdown inside them),
|
|
@@ -105,6 +174,7 @@ export function markdownToTelegramHtml(md: string): string {
|
|
|
105
174
|
const RE = (kind: string) => new RegExp(`\\x00${sentinel}_${kind}_(\\d+)\\x00`, "g");
|
|
106
175
|
|
|
107
176
|
// Extract fenced code blocks first to protect their contents
|
|
177
|
+
// (must happen before table extraction to avoid nested <pre> tags)
|
|
108
178
|
const codeBlocks: string[] = [];
|
|
109
179
|
let processed = md.replace(/```(\w*)\n?([\s\S]*?)```/g, (_match, _lang, code) => {
|
|
110
180
|
const idx = codeBlocks.length;
|
|
@@ -112,6 +182,20 @@ export function markdownToTelegramHtml(md: string): string {
|
|
|
112
182
|
return S("CB", idx);
|
|
113
183
|
});
|
|
114
184
|
|
|
185
|
+
// Extract markdown tables (now safe — code blocks are already sentinelled out)
|
|
186
|
+
const tables: string[] = [];
|
|
187
|
+
processed = processed.replace(
|
|
188
|
+
/(?:^|\n)(\|.+\|\n\|[-| :]+\|\n(?:\|.+\|(?:\n|$))+)/g,
|
|
189
|
+
(match) => {
|
|
190
|
+
const idx = tables.length;
|
|
191
|
+
const leadingNewline = match.startsWith("\n") ? "\n" : "";
|
|
192
|
+
const trailingNewline = match.endsWith("\n") ? "\n" : "";
|
|
193
|
+
const tableContent = match.replace(/^\n/, "").replace(/\n$/, "");
|
|
194
|
+
tables.push(formatTable(tableContent));
|
|
195
|
+
return leadingNewline + S("TB", idx) + trailingNewline;
|
|
196
|
+
},
|
|
197
|
+
);
|
|
198
|
+
|
|
115
199
|
// Extract inline code to protect contents
|
|
116
200
|
const inlineCodes: string[] = [];
|
|
117
201
|
processed = processed.replace(/`([^`\n]+)`/g, (_match, code) => {
|
|
@@ -166,6 +250,7 @@ export function markdownToTelegramHtml(md: string): string {
|
|
|
166
250
|
processed = processed.replace(RE("LK"), (_match, idx) => links[parseInt(idx, 10)]);
|
|
167
251
|
processed = processed.replace(RE("IC"), (_match, idx) => inlineCodes[parseInt(idx, 10)]);
|
|
168
252
|
processed = processed.replace(RE("CB"), (_match, idx) => codeBlocks[parseInt(idx, 10)]);
|
|
253
|
+
processed = processed.replace(RE("TB"), (_match, idx) => tables[parseInt(idx, 10)]);
|
|
169
254
|
|
|
170
255
|
return processed;
|
|
171
256
|
}
|