Skip to content

Markdown Table Generator — TypeScript source

Turn pipe, CSV, tab, semicolon, or space-separated data into a clean GitHub-Flavored Markdown table. Auto-detects the delimiter, pads columns, escapes pipes, and supports per-column alignment - all in your browser.

This is the TypeScript implementation — the same logic the interactive tool runs, in a shareable, citable form.

// Pure logic for the Markdown Table Generator. No React, no DOM - the unit-test
// surface. Deterministic: parsing and rendering depend only on their inputs.

export type Delimiter = '|' | ',' | '\t' | ';' | ' ';
export type Align = 'left' | 'center' | 'right' | 'none';

export interface ToMarkdownOptions {
  /** When true, row 0 is rendered as the table header. When false, a blank
   *  header row is synthesized so the output is still valid GFM. */
  header: boolean;
  /** Per-column alignment; entries beyond the column count are ignored, missing
   *  entries default to 'none'. */
  align?: Align[];
}

/**
 * Split a single line by `delimiter`, trimming each resulting cell.
 * - `|` strips one leading/trailing pipe (so `| a | b |` works) then splits.
 * - ` ` splits on runs of whitespace.
 * - `,` `\t` `;` split on the literal character.
 */
function splitLine(line: string, delimiter: Delimiter): string[] {
  if (delimiter === '|') {
    let l = line.trim();
    if (l.startsWith('|')) l = l.slice(1);
    if (l.endsWith('|')) l = l.slice(0, -1);
    return l.length === 0 ? [''] : l.split('|').map((c) => c.trim());
  }
  if (delimiter === ' ') {
    // parseTable only passes non-empty (post-trim) lines, so this always
    // yields ≥1 token - no empty-line guard needed here.
    return line.trim().split(/\s+/);
  }
  return line.split(delimiter).map((c) => c.trim());
}

/**
 * Parse `input` into a 2-D grid of trimmed cells. Blank lines are skipped.
 * Each non-empty line is split by `delimiter`.
 */
export function parseTable(input: string, delimiter: Delimiter): string[][] {
  const lines = input.split(/\r?\n/).map((l) => l.trim()).filter((l) => l.length > 0);
  return lines.map((line) => splitLine(line, delimiter));
}

/** Count the delimiter occurrences in a line (whitespace counts runs, not chars). */
function countOccurrences(line: string, delimiter: Delimiter): number {
  if (delimiter === ' ') {
    // detectDelimiter only passes non-empty (post-trim) lines, so this split
    // always yields ≥1 token - no empty-line guard needed here.
    return line.trim().split(/\s+/).length - 1;
  }
  let n = 0;
  for (let i = 0; i < line.length; i++) if (line[i] === delimiter) n++;
  return n;
}

const CANDIDATES: Delimiter[] = ['|', '\t', ';', ',', ' '];
// Structural delimiters (pipe, tab) outrank punctuation (`,`,`;`) outrank space.
const WEIGHT: Record<Delimiter, number> = { '|': 3, '\t': 3, ';': 2, ',': 2, ' ': 1 };

/**
 * Heuristic delimiter detection. Scores each candidate by frequency ×
 * cross-line consistency × weight, and returns the best. Falls back to `,`
 * when nothing scores (e.g. a single column or empty input).
 */
export function detectDelimiter(sample: string): Delimiter {
  const lines = sample.split(/\r?\n/).map((l) => l.trim()).filter((l) => l.length > 0);
  if (lines.length === 0) return ',';

  let best: Delimiter = ',';
  let bestScore = 0;
  for (const d of CANDIDATES) {
    const counts = lines.map((l) => countOccurrences(l, d));
    const avg = counts.reduce((a, b) => a + b, 0) / counts.length;
    if (avg === 0) continue;
    const variance = counts.reduce((a, c) => a + (c - avg) ** 2, 0) / counts.length;
    const consistency = 1 / (1 + variance);
    const score = avg * consistency * WEIGHT[d];
    if (score > bestScore) {
      bestScore = score;
      best = d;
    }
  }
  return best;
}

/** Escape a cell for GFM: collapse newlines to spaces, escape literal `|` as `\|`. */
function escapeCell(cell: string): string {
  return cell.replace(/\r?\n/g, ' ').replace(/\|/g, '\\|');
}

/** Pad a cell to `width` honoring alignment. */
function pad(cell: string, width: number, align: Align): string {
  const diff = width - cell.length;
  if (diff <= 0) return cell;
  if (align === 'right') return ' '.repeat(diff) + cell;
  if (align === 'center') {
    const left = Math.floor(diff / 2);
    return ' '.repeat(left) + cell + ' '.repeat(diff - left);
  }
  return cell + ' '.repeat(diff); // 'left' | 'none' → left-aligned
}

/** Render a separator cell (`---`, `:--`, `--:`, `:-:`) of `width` dashes. */
function sepCell(align: Align, width: number): string {
  const w = Math.max(3, width);
  switch (align) {
    case 'center': return ':' + '-'.repeat(w - 2) + ':';
    case 'right': return '-'.repeat(w - 1) + ':';
    case 'left': return ':' + '-'.repeat(w - 1);
    default: return '-'.repeat(w);
  }
}

/**
 * Render a 2-D grid as a GitHub-Flavored Markdown table. Cells are padded to
 * equal column widths, literal `|` is escaped, and a separator row carries the
 * per-column alignment. Returns '' for an empty grid.
 */
export function toMarkdown(rows: string[][], opts: ToMarkdownOptions): string {
  if (rows.length === 0) return '';

  const cols = rows.reduce((m, r) => Math.max(m, r.length), 0);
  const grid = rows.map((r) => {
    const out = r.map(escapeCell);
    while (out.length < cols) out.push('');
    return out;
  });

  const aligns: Align[] = Array.from({ length: cols }, (_, i) => opts.align?.[i] ?? 'none');
  const widths = Array.from({ length: cols }, (_, c) =>
    Math.max(3, ...grid.map((r) => r[c].length)));

  const line = (cells: string[]) =>
    '| ' + cells.map((c, i) => pad(c, widths[i], aligns[i])).join(' | ') + ' |';
  const separator = '| ' + aligns.map((a, i) => sepCell(a, widths[i])).join(' | ') + ' |';

  const header = opts.header
    ? line(grid[0])
    : line(Array(cols).fill(''));
  const dataStart = opts.header ? 1 : 0;
  const dataRows = grid.slice(dataStart).map(line);

  return [header, separator, ...dataRows].join('\n');
}

/** Transpose a grid (rows ↔ columns). Jagged grids are filled with ''. */
export function transpose(rows: string[][]): string[][] {
  if (rows.length === 0) return [];
  const cols = rows.reduce((m, r) => Math.max(m, r.length), 0);
  return Array.from({ length: cols }, (_, c) => rows.map((r) => r[c] ?? ''));
}

// --- Builder-family surface: validation, import roundtrip, URL codecs ---

export type MarkdownTableIssueCode = 'ragged-row' | 'empty-header-cell';

export interface MarkdownTableIssue {
  code: MarkdownTableIssueCode;
  severity: 'warn';
  /** 0-based index of the offending row (ragged-row). */
  rowIndex?: number;
  /** 'have/need' cell counts (ragged-row) or a 1-based column number (empty-header-cell). */
  value?: string;
}

/**
 * Lint a parsed grid before rendering. Ragged rows (a cell count differing
 * from the widest row) are padded to '' by toMarkdown - usually a paste
 * artifact - so each one is flagged. Empty header cells render as blank
 * column titles and are only checked when row 0 is the header.
 */
export function validateMarkdownTable(
  rows: string[][],
  opts: { header: boolean },
): MarkdownTableIssue[] {
  const issues: MarkdownTableIssue[] = [];
  if (rows.length === 0) return issues;
  const cols = rows.reduce((m, r) => Math.max(m, r.length), 0);
  rows.forEach((row, rowIndex) => {
    if (row.length !== cols) {
      issues.push({ code: 'ragged-row', severity: 'warn', rowIndex, value: `${row.length}/${cols}` });
    }
  });
  if (opts.header) {
    for (let c = 0; c < cols; c++) {
      if ((rows[0][c] ?? '').trim() === '') {
        issues.push({ code: 'empty-header-cell', severity: 'warn', value: `#${c + 1}` });
      }
    }
  }
  return issues;
}

/** A GFM separator row: at least one cell, every cell bare dashes optionally ': '-wrapped. */
function isSeparatorRow(row: string[]): boolean {
  return row.length > 0 && row.every((cell) => /^:?-+:?$/.test(cell));
}

/**
 * Parse pasted GFM table text back into a grid (the import roundtrip). Pipe
 * rows are parsed by parseTable and the `---` separator row is dropped, so a
 * README table reloads as editable data. Returns null when the text contains
 * no pipe rows or only separator rows - nothing table-shaped.
 */
export function parseMarkdownTable(text: string): string[][] | null {
  if (!text.includes('|')) return null;
  const data = parseTable(text, '|').filter((row) => !isSeparatorRow(row));
  return data.length > 0 ? data : null;
}

/** Shareable tool state: the raw input plus the three render options. */
export interface TableConfig {
  input: string;
  delim: Delimiter | 'auto';
  header: boolean;
  align: Align;
}

const DELIM_VALUES = ['auto', '|', ',', '\t', ';', ' '] as const;
const ALIGN_VALUES = ['none', 'left', 'center', 'right'] as const;

// Same key scheme (`d` `h` `a` `t`) the tool has always shared, so pre-upgrade
// links keep resolving. The one list-shaped component (`t`) is encodeURIComponent'd
// before being stored; the rest are single-token scalars with no joining.
const enc = (s: string) => encodeURIComponent(s);
const dec = (s: string): string => {
  try {
    return decodeURIComponent(s);
  } catch {
    return s; // malformed escape - keep verbatim rather than throw
  }
};

export function toQuery(config: TableConfig): string {
  const p = new URLSearchParams();
  p.set('d', config.delim);
  p.set('h', config.header ? '1' : '0');
  if (config.align !== 'none') p.set('a', config.align);
  if (config.input.trim() !== '') p.set('t', enc(config.input));
  return p.toString();
}

export function fromQuery(params: URLSearchParams): TableConfig | null {
  const t = params.get('t');
  const d = params.get('d');
  if (t === null && d === null) return null;
  const config: TableConfig = { input: '', delim: 'auto', header: true, align: 'none' };
  if (t !== null) config.input = dec(t);
  if (d !== null && (DELIM_VALUES as readonly string[]).includes(d)) {
    config.delim = d as TableConfig['delim'];
  }
  const h = params.get('h');
  if (h === '0') config.header = false;
  if (h === '1') config.header = true;
  const a = params.get('a');
  if (a !== null && (ALIGN_VALUES as readonly string[]).includes(a)) {
    config.align = a as Align;
  }
  return config;
}

Also available in 13 other languages

Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →