Skip to content

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

// Package markdowntable is the Go twin of CosmoDev's src/lib/markdown-table.ts
// (dual source: the web lib is TypeScript, the CLI lib is Go — kept in lock-step).
// Pure + deterministic, never panics. The table-driven tests in
// markdown-table_test.go share vectors with src/lib/markdown-table.test.ts so the
// two implementations are held to the same contract.
//
// The package mirrors the TS lib exactly: parseTable splits a delimited blob into a
// 2-D grid of trimmed cells, detectDelimiter heuristically scores candidate
// separators, toMarkdown renders a grid as a padded GFM table, and transpose swaps
// rows with columns.
package markdowntable

import (
	"regexp"
	"strings"
	"unicode/utf8"
)

// Delimiter is the field separator used to split a row into cells. It mirrors the
// TS Delimiter union ('|' | ',' | '\t' | ';' | ' '). Space is special-cased: it
// splits on runs of whitespace rather than a single literal character.
type Delimiter byte

const (
	Pipe      Delimiter = '|'
	Comma     Delimiter = ','
	Tab       Delimiter = '\t'
	Semicolon Delimiter = ';'
	Space     Delimiter = ' '
)

// Align is the horizontal alignment of a column. It mirrors the TS Align union.
// The zero value AlignNone matches the TS default 'none' (left-rendered, no marker).
type Align int

const (
	AlignNone   Align = iota // left-rendered, plain separator (TS 'none')
	AlignLeft                // TS 'left'  → ':---'
	AlignCenter              // TS 'center'→ ':-:'
	AlignRight               // TS 'right'→ '---:'
)

// ToMarkdownOptions configures ToMarkdown. It mirrors ToMarkdownOptions in the TS
// lib. Align is a slice (nil → every column defaults to AlignNone), and entries
// beyond the column count are ignored, exactly like the TS `align?` field.
type ToMarkdownOptions struct {
	Header bool
	Align  []Align
}

// candidates is the ordered set of separators detectDelimiter scores. The order
// matters: when two candidates score equally the earlier one wins (strict >
// comparison), so ';' outranks ',' — see the "breaks ties in candidate order"
// vector shared with the TS suite.
var candidates = []Delimiter{Pipe, Tab, Semicolon, Comma, Space}

// weight ranks structural separators above punctuation above space. Mirrors WEIGHT
// in the TS lib: pipe/tab = 3, semicolon/comma = 2, space = 1.
var weight = map[Delimiter]float64{Pipe: 3, Tab: 3, Semicolon: 2, Comma: 2, Space: 1}

var newlineRe = regexp.MustCompile(`\r?\n`)

// nonEmptyLines splits input on \r?\n, trims each line, and drops blank lines. It is
// the shared front-end of parseTable and detectDelimiter (both use the identical
// `.split(/\r?\n/).map(trim).filter(non-empty)` pipeline in the TS lib).
func nonEmptyLines(input string) []string {
	raw := newlineRe.Split(input, -1)
	lines := make([]string, 0, len(raw))
	for _, l := range raw {
		t := strings.TrimSpace(l)
		if len(t) > 0 {
			lines = append(lines, t)
		}
	}
	return lines
}

// splitLine splits a single (non-empty, trimmed) line by delimiter, trimming each
// resulting cell. It mirrors splitLine() in the TS lib:
//   - Pipe strips one leading/trailing pipe (so `| a | b |` works) then splits.
//   - Space splits on runs of whitespace.
//   - Comma/Tab/Semicolon split on the literal character.
func splitLine(line string, d Delimiter) []string {
	switch d {
	case Pipe:
		l := strings.TrimSpace(line)
		l = strings.TrimPrefix(l, "|")
		l = strings.TrimSuffix(l, "|")
		if l == "" {
			return []string{""}
		}
		parts := strings.Split(l, "|")
		for i, c := range parts {
			parts[i] = strings.TrimSpace(c)
		}
		return parts
	case Space:
		// parseTable only passes non-empty (post-trim) lines, so Fields always
		// yields ≥1 token — no empty-line guard needed here, matching the TS lib.
		return strings.Fields(strings.TrimSpace(line))
	default: // Comma, Tab, Semicolon
		parts := strings.Split(line, string(d))
		for i, c := range parts {
			parts[i] = strings.TrimSpace(c)
		}
		return parts
	}
}

// ParseTable parses input into a 2-D grid of trimmed cells. Blank lines are
// skipped and each non-empty line is split by delimiter. It is the Go twin of
// parseTable() in src/lib/markdown-table.ts.
func ParseTable(input string, d Delimiter) [][]string {
	lines := nonEmptyLines(input)
	grid := make([][]string, len(lines))
	for i, line := range lines {
		grid[i] = splitLine(line, d)
	}
	return grid
}

// countOccurrences counts delimiter occurrences in a line. For Space it counts
// whitespace runs (tokens − 1), not literal characters — mirroring countOccurrences
// in the TS lib. The line is assumed non-empty and post-trim (detectDelimiter only
// passes such lines).
func countOccurrences(line string, d Delimiter) int {
	if d == Space {
		return len(strings.Fields(strings.TrimSpace(line))) - 1
	}
	return strings.Count(line, string(d))
}

// DetectDelimiter heuristically detects the most likely delimiter in sample. It
// scores each candidate by frequency × cross-line consistency × weight and returns
// the best, falling back to Comma when nothing scores (single column / empty input).
// It is the Go twin of detectDelimiter() in src/lib/markdown-table.ts.
func DetectDelimiter(sample string) Delimiter {
	lines := nonEmptyLines(sample)
	if len(lines) == 0 {
		return Comma
	}

	best := Comma
	bestScore := 0.0
	for _, d := range candidates {
		counts := make([]float64, len(lines))
		sum := 0.0
		for i, l := range lines {
			c := float64(countOccurrences(l, d))
			counts[i] = c
			sum += c
		}
		avg := sum / float64(len(lines))
		if avg == 0 {
			continue
		}
		varSq := 0.0
		for _, c := range counts {
			varSq += (c - avg) * (c - avg)
		}
		variance := varSq / float64(len(lines))
		consistency := 1 / (1 + variance)
		score := avg * consistency * weight[d]
		// Strict >: on a tie the earlier candidate (already held) wins, so ';'
		// outranks ',' — matching the TS tie-break contract.
		if score > bestScore {
			bestScore = score
			best = d
		}
	}
	return best
}

// escapeCell escapes a cell for GFM: collapse newlines to spaces, then escape a
// literal pipe as `\|`. Mirrors escapeCell() in the TS lib.
func escapeCell(cell string) string {
	s := newlineRe.ReplaceAllString(cell, " ")
	return strings.ReplaceAll(s, "|", "\\|")
}

// pad pads cell to width honoring alignment. Width is measured in runes (the TS
// uses .length, which for the ASCII/bmp cells this tool handles agrees with the
// rune count). Mirrors pad() in the TS lib.
func pad(cell string, width int, a Align) string {
	diff := width - utf8.RuneCountInString(cell)
	if diff <= 0 {
		return cell
	}
	spaces := strings.Repeat(" ", diff)
	switch a {
	case AlignRight:
		return spaces + cell
	case AlignCenter:
		left := diff / 2 // integer division floors, matching Math.floor
		return strings.Repeat(" ", left) + cell + strings.Repeat(" ", diff-left)
	default: // AlignLeft, AlignNone → left-aligned
		return cell + spaces
	}
}

// sepCell renders a separator cell ('---', ':--', '--:', ':-:') of the given width,
// with a minimum of three dashes. Mirrors sepCell() in the TS lib.
func sepCell(a Align, width int) string {
	w := max(width, 3) // minimum three dashes, mirroring Math.max(3, width)
	switch a {
	case AlignCenter:
		return ":" + strings.Repeat("-", w-2) + ":"
	case AlignRight:
		return strings.Repeat("-", w-1) + ":"
	case AlignLeft:
		return ":" + strings.Repeat("-", w-1)
	default:
		return strings.Repeat("-", w)
	}
}

// ToMarkdown renders a 2-D grid as a GitHub-Flavored Markdown table. Cells are
// escaped, padded to equal column widths, and a separator row carries the
// per-column alignment. It returns "" for an empty grid. It is the Go twin of
// toMarkdown() in src/lib/markdown-table.ts.
func ToMarkdown(rows [][]string, opts ToMarkdownOptions) string {
	if len(rows) == 0 {
		return ""
	}

	cols := 0
	for _, r := range rows {
		cols = max(cols, len(r))
	}

	// Escape every cell and normalize each row to exactly `cols` cells.
	grid := make([][]string, len(rows))
	for i, r := range rows {
		out := make([]string, cols)
		for j := 0; j < cols; j++ {
			if j < len(r) {
				out[j] = escapeCell(r[j])
			} // else: leave "" (jagged rows / short rows padded with "")
		}
		grid[i] = out
	}

	aligns := make([]Align, cols)
	for i := range aligns {
		aligns[i] = AlignNone
		if opts.Align != nil && i < len(opts.Align) {
			aligns[i] = opts.Align[i]
		}
	}

	widths := make([]int, cols)
	for c := 0; c < cols; c++ {
		w := 3
		for _, r := range grid {
			w = max(w, utf8.RuneCountInString(r[c]))
		}
		widths[c] = w
	}

	line := func(cells []string) string {
		padded := make([]string, len(cells))
		for i, c := range cells {
			padded[i] = pad(c, widths[i], aligns[i])
		}
		return "| " + strings.Join(padded, " | ") + " |"
	}
	sepCells := make([]string, cols)
	for i, a := range aligns {
		sepCells[i] = sepCell(a, widths[i])
	}
	separator := "| " + strings.Join(sepCells, " | ") + " |"

	var header string
	var dataRows [][]string
	if opts.Header {
		header = line(grid[0])
		dataRows = grid[1:]
	} else {
		header = line(make([]string, cols)) // blank synthesized header row
		dataRows = grid
	}

	parts := make([]string, 0, 2+len(dataRows))
	parts = append(parts, header, separator)
	for _, r := range dataRows {
		parts = append(parts, line(r))
	}
	return strings.Join(parts, "\n")
}

// Transpose transposes a grid (rows ↔ columns). Jagged grids are filled with "".
// It is the Go twin of transpose() in src/lib/markdown-table.ts.
func Transpose(rows [][]string) [][]string {
	if len(rows) == 0 {
		return [][]string{}
	}
	cols := 0
	for _, r := range rows {
		cols = max(cols, len(r))
	}
	out := make([][]string, cols)
	for c := 0; c < cols; c++ {
		row := make([]string, len(rows))
		for i, r := range rows {
			if c < len(r) {
				row[i] = r[c]
			} // else: "" — jagged grids fill with empty strings
		}
		out[c] = row
	}
	return out
}

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 →