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 →