Make Table
Command: make-table
Description: Convert JSON or CSV data into an HTML table with optional column customization.
Arguments
-
col_*: Column titles (e.g., col_name:Full Name) -
hide: Comma-separated list of column keys to hide from display -
hide-pattern: Regex pattern to match column keys to hide from display -
header-regex: Sed-style regex to transform column header titles (e.g., s/_/ /g) -
target: URL template for clickable rows (uses Liquid templating)
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