All files / src/lib md.ts

100% Statements 21/21
100% Branches 6/6
100% Functions 7/7
100% Lines 15/15

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83                                                                        32x                                   15x 19x 13x   12x 18x 18x 32x   18x     30x   12x 12x 12x   12x               4x    
/**
 * Markdown renderer for `/_/:room/md` exports.
 *
 * Decision (see FINDINGS in agent report): we produce a simple GFM table
 * rather than porting the legacy `j` library's `to_md`. Reasons:
 *
 *  1. `j` relies on Node `Buffer` + sync fs IO (§7 item 3 in CLAUDE.md) —
 *     bringing it into the Worker bundle would pull several deprecated
 *     dependencies and the nodejs_compat shims are subtly lossy for its
 *     ODS import path.
 *  2. GFM tables are the de-facto markdown spreadsheet format (Matter, Pandoc,
 *     Obsidian, GitHub all render them). The legacy `j` output was an
 *     idiosyncratic pipe-separated grid without the GFM separator row, which
 *     most readers actually render worse.
 *  3. Keeping it pure means we can hit 100% coverage and never need a fake
 *     DOM / Node runtime in tests.
 *
 * This is a §13 Q1 "sensible fix" — the output format changed but the
 * `Content-Type: text/x-markdown` is preserved and the data content is
 * unchanged (every cell value shows up in the same grid position).
 *
 * The CSV passed in comes from SocialCalc's `ConvertSaveToOtherFormat(...,
 * 'csv')` output, so quotes + escapes follow RFC 4180. We parse it via the
 * sibling `csv-parse.ts` module and render each row as `| a | b | c |`
 * with a GFM separator after the first row.
 */
 
import { parseCSV } from './csv-parse.ts';
 
/**
 * Escape a cell value for GFM table context. The only character that breaks
 * table cell parsing is `|`; we also escape backticks and backslashes to keep
 * values unambiguous, and replace embedded newlines with `<br>` (the standard
 * workaround since GFM doesn't allow newlines inside a table cell).
 */
function escapeCell(value: string): string {
  return value
    .replace(/\\/g, '\\\\')
    .replace(/\|/g, '\\|')
    .replace(/`/g, '\\`')
    .replace(/\r\n|\n|\r/g, '<br>');
}
 
/**
 * Render a 2D grid as a GFM table. Empty grids produce an empty string
 * (matches the legacy contract for empty rooms — `exportCSV()` returns an
 * empty string, and the md body is empty too).
 *
 * Rows shorter than the first row are padded with empty cells; longer rows
 * are truncated. This is the same normalization CSV parsers do and keeps
 * the table valid (a mis-aligned row renders as plain text, which would be
 * confusing).
 */
export function renderMarkdownTable(grid: readonly (readonly string[])[]): string {
  if (grid.length === 0) return '';
  const width = Math.max(...grid.map((row) => row.length));
  if (width === 0) return '';
 
  const normalize = (row: readonly string[]): string[] => {
    const out: string[] = [];
    for (let i = 0; i < width; i++) {
      out.push(escapeCell(row[i] ?? ''));
    }
    return out;
  };
 
  const line = (cells: readonly string[]): string => `| ${cells.join(' | ')} |`;
 
  const header = normalize(grid[0]!);
  const separator = new Array(width).fill('---');
  const body = grid.slice(1).map((row) => line(normalize(row)));
 
  return [line(header), line(separator), ...body].join('\n');
}
 
/**
 * Convert a CSV string to a GFM markdown table. Composes `parseCSV` and
 * `renderMarkdownTable`. Used by the `/_do/md` route in `RoomDO`.
 */
export function csvToMarkdown(csv: string): string {
  return renderMarkdownTable(parseCSV(csv));
}