Make Table

Command: make-table

Description: Convert JSON or CSV data into an HTML table with optional column customization.

Arguments

Notes

The simplest use is to turn a simple array of JSON objects or a set of CSV data into an HTML table.

make-table

For CSV, it turns the data into a simple table.

For JSON, it forms a table with one row per array element, with one column for each property (it assumes all objects have the same properties). The column headers will be the property names.

For more control, you can specify the columns you want to display, along with their header title. The format of the argument name is col_[property name]:

make-table -col_first_name:"First Name" -col_last_name:"Last Name"

You can make each row clickable by passing a Liquid template as the target argument. The template should produce the URL that the row should link to. The template is passed the element as a variable called row

make-table -target:"http://myblog.com/{{ row.id }}.html"

Every table cell will get a class value of col-[property name]. Additionally, columns that have only numeric values will get a class of numeric.

If a target value is provided, the table row will get a class of clickable.

<tr class="clickable" onclick="window.open('/movies/dn.html', '_blank')">
  <td class="col-title">Dr. No</td>
  <td class="col-year numeric">1962</td>
  <td class="col-actor">Sean Connery</td>
</tr>

The alternate is to hide columns you don't want to display, either explicitly by name:

make-table -hide:secret_info,even_secreter_info

Or hide by pattern. For example, to hide any column that starts with an underscore:

make-table -hide-pattern:^_

This command pairs well with the jsonata command. That command can transform JSON into an simpler structure before running make-table.

jsonata -expr:"$.{ 'Title': title, 'Date':date.utc, slug }"
make-table -target:"/posts/{{ row.slug }}.html"

The header-regex argument is in the sed format for specifying regex find and replace operations. These operations will be performed on the column header values before they are output.

For example, this will replace underscores with spaces and capitalize the first letter of each (resulting) word. (Just like with sed, you can separate multiple operations with semicolons.)

make-table -header-regex:"s/_/ /g; s/\b./\u&/g"

Code Notes

The code here is a mess. A lot of the helper functions were vibe-coded. It needs some clean-up.

Additionally, I would like to find a way to link the table rows without JavaScript, so it's semantically correct. However, an A tag containing TD tags is invalid, and will be removed during parse. I'm thinking about just linking every individual cell.

Also, I don't love building the styles in here. Not sure what to do about that.

Source Code

import { Liquid } from "liquidjs";
import { detectMimeType, getDom } from "../helpers.js";

async function makeTable(working, command, p) {
  // Get all arguments upfront
  const target = command.getArg("target,clickTarget"); // Note: clickTarget was an old name for this arg
  const hide = command.getArg("hide");
  const hidePattern = command.getArg("hide-pattern");
  const headerRegex = command.getArg("header-regex");

  // Detect the input and turn it into a array of Maps
  let data = [];
  if (detectMimeType(working.text).includes("json")) {
    const json = JSON.parse(working.text);
    data = json.map((o) => new Map(Object.entries(o)));
  } else if (detectMimeType(working.text).includes("csv")) {
    data = parseCSVtoMaps(working.text);
  }

  // Get the column arguments
  const columnArgPrefix = "col_";
  let columnArgs = command.arguments
    .filter((a) => a.key.startsWith(columnArgPrefix))
    .map((a) => a.key.replace(columnArgPrefix, "").trim());
  if (columnArgs.length === 0 && data.length > 0) {
    // No column args, so use all keys from first row
    columnArgs = Array.from(data[0].keys());
  }

  // Parse hidden columns
  const hidePatternRegex = hidePattern ? new RegExp(hidePattern) : null;
  const hiddenColumns = hide
    ? new Set(hide.split(",").map((s) => s.trim()))
    : new Set();

  // Get the column titles (excluding hidden columns)
  const columnTitles = new Map();
  for (const col of columnArgs) {
    if (!hiddenColumns.has(col) && !(hidePatternRegex && hidePatternRegex.test(col))) {
      columnTitles.set(col, command.getArg(columnArgPrefix + col) ?? col);
    }
  }

  // Apply header-regex transform to column titles
  if (headerRegex) {
    const ops = headerRegex.split(";").map(parseSedReplace);
    for (const [col, title] of columnTitles) {
      const transformed = ops.reduce((t, { pattern, replacement, flags }) => {
        try {
          return t.replace(new RegExp(pattern, flags), replacement);
        } catch (e) {
          throw new Error(`Invalid regex in sed expression: ${e.message}`);
        }
      }, title);
      columnTitles.set(col, transformed);
    }
  }

  const html = await mapsToTable(data, columnTitles, target);
  const css = ``;

  return html.outerHTML + css;
}


// Meta

makeTable.title = "Make Table";
makeTable.description =
  "Convert JSON or CSV data into an HTML table with optional column customization.";
makeTable.args = [
  {
    name: "col_*",
    type: "string",
    description: "Column titles (e.g., col_name:Full Name)",
  },
  {
    name: "hide",
    type: "string",
    description: "Comma-separated list of column keys to hide from display",
  },
  {
    name: "hide-pattern",
    type: "string",
    description: "Regex pattern to match column keys to hide from display",
  },
  {
    name: "header-regex",
    type: "string",
    description: "Sed-style regex to transform column header titles (e.g., s/_/ /g)",
  },
  {
    name: "target",
    type: "string",
    description: "URL template for clickable rows (uses Liquid templating)",
  },
];
makeTable.allowedContentTypes = ["json", "csv"];


// Helpers

function getDefaultTableCss() {
  return `

  table {
  border-collapse: collapse;
}
td,
th {
  text-align: left;
  padding: 0.5rem;
  border-bottom: solid 1px rgb(240,240,240);
  padding-right: 1rem;
}
th {
  border-bottom-width: 3px;
}
tr:last-of-type td {
  border-bottom: none;
}
td.numeric,
th.numeric {
  text-align: right;
}
  tr.clickable {
  cursor: pointer;
}
tr.clickable:hover {
  background-color: rgb(250,250,250);
}
`;
}


function parseCSVtoMaps(
  input,
  { delimiter = ",", trim = false, skipEmptyRows = true } = {}
) {
  if (typeof input !== "string") input = String(input ?? "");

  // Strip BOM if present
  if (input.charCodeAt(0) === 0xfeff) input = input.slice(1);

  // Normalize newlines
  input = input.replace(/\r\n/g, "\n").replace(/\r/g, "\n");

  const rows = [];
  let row = [];
  let field = "";

  let i = 0;
  const len = input.length;
  const D = delimiter;
  const NL = "\n";

  let inQuotes = false;
  while (i < len) {
    const ch = input[i];

    if (inQuotes) {
      if (ch === '"') {
        const next = input[i + 1];
        if (next === '"') {
          field += '"'; // Escaped quote
          i += 2;
          continue;
        } else {
          inQuotes = false;
          i++;
          continue;
        }
      } else {
        field += ch;
        i++;
        continue;
      }
    } else {
      if (ch === '"') {
        inQuotes = true;
        i++;
        continue;
      }
      if (ch === D) {
        row.push(trim ? field.trim() : field);
        field = "";
        i++;
        continue;
      }
      if (ch === NL) {
        row.push(trim ? field.trim() : field);
        field = "";
        if (!(skipEmptyRows && row.every((v) => v === ""))) {
          rows.push(row);
        }
        row = [];
        i++;
        continue;
      }
      field += ch;
      i++;
    }
  }

  // Flush last field/row
  row.push(trim ? field.trim() : field);
  if (!(skipEmptyRows && row.every((v) => v === ""))) {
    rows.push(row);
  }

  if (rows.length === 0) return [];

  // First row is headers
  const headers = rows[0];
  const result = [];

  for (let r = 1; r < rows.length; r++) {
    const record = new Map();
    const cur = rows[r];
    const n = Math.max(headers.length, cur.length);
    for (let c = 0; c < n; c++) {
      const key = headers[c] ?? `__col${c + 1}`;
      record.set(key, cur[c] ?? "");
    }
    result.push(record);
  }

  return result;
}

/**
 * Create an HTMLTableElement from an array of Maps.
 * - rows: Array> (each Map is a row; keys = column names)
 * - fieldsMap (optional): Map
 *     * If provided, ONLY these keys are displayed, in insertion order.
 *     *  text uses the mapped title (value).
 * - For every TH/TD:
 *     * adds class "col-"
 *     * adds class "numeric" if that column is numeric across all rows
 *
 * @param {Map[]} rows
 * @param {Map} [fieldsMap]
 * @returns {HTMLTableElement}
 */
async function mapsToTable(rows, fieldsMap, target) {
  const dom = await getDom();
  const table = dom.createElement("table");
  const thead = table.createTHead();
  const tbody = table.createTBody();

  const hasRows = Array.isArray(rows) && rows.length > 0;

  const engine = new Liquid({
    cache: true,
  });

  // Determine columns
  const columns =
    fieldsMap instanceof Map
      ? Array.from(fieldsMap.keys())
      : computeColumns(rows || []);

  // Column metadata
  const colMeta = columns.map((key) => ({
    key,
    title:
      fieldsMap instanceof Map && fieldsMap.has(key)
        ? String(fieldsMap.get(key))
        : String(key),
    className: "col-" + toClassName(key),
    numeric: hasRows && rows.every((m) => isNumericLike(m.get(key))),
  }));

  // Header
  const trh = thead.insertRow();
  for (const col of colMeta) {
    const th = dom.createElement("th");
    th.textContent = col.title;
    th.classList.add(col.className);
    if (col.numeric) th.classList.add("numeric");
    trh.appendChild(th);
  }

  // Body
  if (hasRows) {
    for (const row of rows) {
      const tr = tbody.insertRow();

      if (target) {

        tr.classList.add("clickable");

        const clickTarget = engine.parseAndRenderSync(target, {
          row: Object.fromEntries(row),
        });
        tr.setAttribute("onclick", `window.open('${clickTarget}', '_blank')`);
      }

      for (const col of colMeta) {
        const td = dom.createElement("td");
        const v = row.get(col.key);
        td.textContent = v == null ? "" : String(v);
        td.classList.add(col.className);
        if (col.numeric) td.classList.add("numeric");
        tr.appendChild(td);
      }
    }
  }

  return table;

  // --- helpers ---
  function computeColumns(rs) {
    const seen = new Set();
    const ordered = [];
    if (rs.length) {
      for (const k of rs[0].keys()) {
        seen.add(k);
        ordered.push(k);
      }
    }
    for (const m of rs) {
      for (const k of m.keys()) {
        if (!seen.has(k)) {
          seen.add(k);
          ordered.push(k);
        }
      }
    }
    return ordered;
  }

  function toClassName(key) {
    let s = String(key)
      .trim()
      .toLowerCase()
      .replace(/[^a-z0-9]+/g, "-")
      .replace(/^-+|-+$/g, "")
      .replace(/-{2,}/g, "-");
    if (/^[0-9]/.test(s)) {
      s = "c-" + s; // class can't start with a digit
    }
    return s || "col";
  }

  // NEW: numeric if number OR a string that fully converts to a finite number
  // Replace your existing isNumericLike with this:
  function isNumericLike(x) {
    if (typeof x === "number") return Number.isFinite(x);
    if (typeof x !== "string") return false;

    let s = x.trim();
    if (s === "") return false;

    // Allow $ anywhere (e.g., "$1,234", "$ 1,234", "$-1,234"); remove and re-trim.
    s = s.replace(/\$/g, "").trim();

    // Validate either plain digits or properly grouped thousands with commas.
    // Optional leading sign; optional decimal part.
    const commaPattern = /^[+-]?\d{1,3}(?:,\d{3})*(?:\.\d+)?$/;
    const plainPattern = /^[+-]?\d+(?:\.\d+)?$/;

    if (!(commaPattern.test(s) || plainPattern.test(s))) return false;

    // Normalize commas and evaluate
    const n = Number(s.replace(/,/g, ""));
    return Number.isFinite(n);
  }
}

function parseSedReplace(sed) {
  if (sed.length < 2 || sed[0] !== "s") throw new Error(`Invalid sed expression: ${sed}`);
  const delim = sed[1];
  const parts = sed.slice(2).split(delim);
  if (parts.length < 3) throw new Error(`Invalid sed expression: ${sed}`);
  return { pattern: parts[0], replacement: parts[1], flags: parts[2] };
}

export default makeTable;