Render Aligned Tables in the Terminal

Render terminal tables with column alignment, ignoring ANSI color codes in width calculations and adding a header separator.

TypescriptCli Tooling

Code

snippet
export type Alignment = "left" | "right" | "center";

export interface TableOptions {
  readonly headers?: readonly string[];
  readonly align?: readonly Alignment[];
}

const ANSI_PATTERN = /\u001B\[[0-9;]*m/g;

export function stripAnsi(value: string): string {
  return value.replace(ANSI_PATTERN, "");
}

export function renderTable(
  rows: readonly (readonly string[])[],
  options: TableOptions = {},
): string {
  if (!Array.isArray(rows)) throw new TypeError("rows must be an array");
  const headers = options.headers ?? [];
  if (!Array.isArray(headers)) throw new TypeError("headers must be an array");
  const allRows: readonly (readonly string[])[] = headers.length > 0 ? [headers, ...rows] : rows;
  if (allRows.length === 0) return "";
  const columnCount = Math.max(...allRows.map((row) => row.length));
  if (columnCount === 0) return "";
  for (const row of allRows) {
    if (!Array.isArray(row)) throw new TypeError("every row must be an array");
    for (const cell of row) {
      if (typeof cell !== "string") throw new TypeError("cells must be strings");
    }
  }
  const widths = Array.from({ length: columnCount }, (_, column) =>
    Math.max(...allRows.map((row) => stripAnsi(row[column] ?? "").length)),
  );
  const align = options.align ?? [];
  const renderRow = (row: readonly string[]): string =>
    Array.from({ length: columnCount }, (_, column) =>
      pad(row[column] ?? "", widths[column], align[column] ?? "left"),
    ).join(" | ");
  const separator = widths.map((width) => "-".repeat(width)).join("-+-");
  const lines = allRows.map(renderRow);
  if (headers.length > 0) lines.splice(1, 0, separator);
  return lines.join("\n");
}

function pad(value: string, width: number, alignment: Alignment): string {
  const missing = Math.max(0, width - stripAnsi(value).length);
  if (alignment === "right") return " ".repeat(missing) + value;
  if (alignment === "center") {
    const left = Math.floor(missing / 2);
    return " ".repeat(left) + value + " ".repeat(missing - left);
  }
  return value + " ".repeat(missing);
}

const table = renderTable(
  [
    ["\u001B[32mready\u001B[0m", "42 ms", "0.4%"],
    ["failed", "1204 ms", "12.5%"],
  ],
  { headers: ["status", "latency", "errors"], align: ["left", "right", "right"] },
);
console.log(table);
console.log(stripAnsi("\u001B[31mred\u001B[0m").length);