@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inceptionstack/roundhouse",
3
- "version": "0.3.21",
3
+ "version": "0.3.22",
4
4
  "type": "module",
5
5
  "description": "Multi-platform chat gateway that routes messages through a configured AI agent",
6
6
  "license": "MIT",
@@ -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
  }