Skip to content

Markdown Table Generator — JavaScript 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 JavaScript implementation — the same logic the interactive tool runs, in a shareable, citable form.

/**
 * Markdown Table Generator - pure logic, JavaScript polyglot showcase port.
 *
 * Language:    JavaScript (ESM)
 * Origin:      CosmoDev polyglot showcase port of the `markdown-table` tool.
 * Ported from: src/lib/markdown-table.ts (the canonical, live TypeScript lib).
 *
 * Parsing and rendering here are deterministic and depend only on their inputs -
 * no React, no DOM. This file is display source - part of CosmoDev's polyglot
 * tool pages, where the same pure logic is shown side-by-side across languages.
 */

/**
 * The accepted input delimiters.
 * @typedef {'|' | ',' | '\t' | ';' | ' '} Delimiter
 */

/**
 * Per-column alignment, carried by the GFM separator row.
 * @typedef {'left' | 'center' | 'right' | 'none'} Align
 */

// Delimiter candidates considered during auto-detection, in priority order.
// Structural delimiters (pipe, tab) outrank punctuation (`,` / `;`) outrank space.
const CANDIDATES = ['|', '\t', ';', ',', ' '];
const WEIGHT = { '|': 3, '\t': 3, ';': 2, ',': 2, ' ': 1 };

/**
 * 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.
 *
 * @param {string} line
 * @param {Delimiter} delimiter
 * @returns {string[]}
 */
function splitLine(line, delimiter) {
  if (delimiter === '|') {
    let l = line.trim();
    if (l.startsWith('|')) l = l.slice(1);
    if (l.endsWith('|')) l = l.slice(0, -1);
    // A fully-empty line collapses to a single empty cell, not zero cells.
    return l.length === 0 ? [''] : l.split('|').map((c) => c.trim());
  }
  if (delimiter === ' ') {
    // parseTable only ever passes non-empty (post-trim) lines here, so this
    // always yields ≥ 1 token - no empty-line guard needed.
    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 remaining line is split by `delimiter`.
 *
 * @param {string} input
 * @param {Delimiter} delimiter
 * @returns {string[][]}
 */
export function parseTable(input, delimiter) {
  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* of
 * whitespace, not individual space characters.
 *
 * @param {string} line
 * @param {Delimiter} delimiter
 * @returns {number}
 */
function countOccurrences(line, delimiter) {
  if (delimiter === ' ') {
    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;
}

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

  let best = ',';
  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;
    // Population variance across lines → lower means more consistent.
    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 (CRLF or LF) to a single space,
 * then escape literal `|` so it does not terminate the cell.
 *
 * @param {string} cell
 * @returns {string}
 */
function escapeCell(cell) {
  return cell.replace(/\r?\n/g, ' ').replace(/\|/g, '\\|');
}

/**
 * Pad a cell to `width` honoring alignment. Left/none aligns left, right aligns
 * right, center splits the slack (floor on the left side).
 *
 * @param {string} cell
 * @param {number} width
 * @param {Align} align
 * @returns {string}
 */
function pad(cell, width, align) {
  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'
}

/**
 * Render a separator cell (`---`, `:--`, `--:`, `:-:`) of at least 3 dashes.
 *
 * @param {Align} align
 * @param {number} width
 * @returns {string}
 */
function sepCell(align, width) {
  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 (computed from the escaped text), literal `|` is
 * escaped, and the separator row carries the per-column alignment.
 *
 * Returns '' for an empty grid.
 *
 * @param {string[][]} rows
 * @param {{ header: boolean, align?: Align[] }} opts
 * @returns {string}
 */
export function toMarkdown(rows, opts) {
  if (rows.length === 0) return '';

  const cols = rows.reduce((m, r) => Math.max(m, r.length), 0);
  // Escape every cell and normalize each row to the column count.
  const grid = rows.map((r) => {
    const out = r.map(escapeCell);
    while (out.length < cols) out.push('');
    return out;
  });

  // Per-column alignment: missing entries default to 'none'; surplus entries ignored.
  const aligns = Array.from({ length: cols }, (_, i) => opts.align?.[i] ?? 'none');
  // Per-column width: at least 3 (GFM separator minimum), grown to fit the widest escaped cell.
  const widths = Array.from({ length: cols }, (_, c) =>
    Math.max(3, ...grid.map((r) => r[c].length)),
  );

  // Frame one row of cells with the GFM pipe scaffolding.
  const line = (cells) =>
    '| ' + cells.map((c, i) => pad(c, widths[i], aligns[i])).join(' | ') + ' |';
  const separator =
    '| ' + aligns.map((a, i) => sepCell(a, widths[i])).join(' | ') + ' |';

  // When there is no header, synthesize a blank header row so the table is still valid GFM.
  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 ''.
 *
 * @param {string[][]} rows
 * @returns {string[][]}
 */
export function transpose(rows) {
  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] ?? ''));
}

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 →