Moved generic table tool with dependencies from sk-az-tools.

This commit is contained in:
2026-03-06 18:24:40 +01:00
parent e81b3f0933
commit 662f08114c
8 changed files with 736 additions and 0 deletions

124
src/cli.ts Normal file
View File

@@ -0,0 +1,124 @@
#!/usr/bin/env node
// SPDX-License-Identifier: MIT
import { parseArgs } from "node:util";
import {
normalizeOutputFormat,
outputFiltered,
parseHeaderSpec,
readCsvFromStdin,
readJsonFromStdin,
renderOutput,
} from "./cli/utils.ts";
type CliValues = {
help?: boolean;
from?: string;
query?: string;
header?: string;
output?: string;
[key: string]: string | boolean | undefined;
};
function usage(): string {
return `Usage: sk-tools <command> [options]
Commands:
table Render stdin data as Markdown table
Global options (all commands):
--query, -q <jmespath>
--output, -o <format> table|t|alignedtable|at|prettytable|pt|tsv
--help, -h
Use: sk-tools --help <command>
or: sk-tools <command> --help`;
}
function usageTable(): string {
return `Usage: sk-tools table [--from|-F <json|csv|tsv>] [--header|-H <definition|auto|a|original|o>] [global options]
Options:
--from, -F <json|csv|tsv> Input format on stdin (default: json)
--header, -H <value> Header mode: auto|a (default), original|o, or "col1, col2" or "key1: Label 1, key2: Label 2"`;
}
function usageCommand(command: string): string {
switch (command) {
case "table":
return usageTable();
default:
return `Unknown command: ${command}\n\n${usage()}`;
}
}
async function runTableCommand(values: CliValues): Promise<unknown> {
const from = (values.from ?? "json").toString().trim().toLowerCase();
if (from === "json") {
return readJsonFromStdin();
}
if (from === "csv") {
return readCsvFromStdin(",");
}
if (from === "tsv") {
return readCsvFromStdin("\t");
}
throw new Error(`Invalid --from '${values.from}'. Allowed: json, csv, tsv`);
}
async function main(): Promise<void> {
const argv = process.argv.slice(2);
const command = argv[0];
if (!command) {
console.log(usage());
process.exit(0);
}
if (command === "-h" || command === "--help") {
const helpCommand = argv[1];
console.log(helpCommand ? usageCommand(helpCommand) : usage());
process.exit(0);
}
const { values } = parseArgs({
args: argv.slice(1),
options: {
help: { type: "boolean", short: "h" },
from: { type: "string", short: "F" },
query: { type: "string", short: "q" },
header: { type: "string", short: "H" },
output: { type: "string", short: "o" },
},
strict: true,
allowPositionals: false,
});
const typedValues = values as CliValues;
if (typedValues.help) {
console.log(usageCommand(command));
process.exit(0);
}
if (command !== "table") {
throw new Error(`Unknown command: ${command}`);
}
const outputFormat = normalizeOutputFormat(typedValues.output);
const result = await runTableCommand(typedValues);
const filtered = outputFiltered(result, typedValues.query);
const headerSpec = parseHeaderSpec(typedValues.header);
renderOutput(command, filtered, outputFormat, headerSpec);
}
main().catch((err: unknown) => {
const error = err as Error;
console.error(`Error: ${error.message}`);
console.error(usage());
process.exit(1);
});

230
src/cli/utils.ts Normal file
View File

@@ -0,0 +1,230 @@
// SPDX-License-Identifier: MIT
import jmespath from "jmespath";
import { dsvFormat } from "d3-dsv";
import { toMarkdownTable } from "../markdown.ts";
type HeaderSpec =
| { mode: "auto" }
| { mode: "original" }
| { mode: "list"; labels: string[] }
| { mode: "map"; map: Record<string, string> };
type OutputFormat = "json" | "table" | "alignedtable" | "prettytable" | "tsv";
type Scalar = string | number | boolean | null | undefined;
type ScalarRow = Record<string, Scalar>;
export function outputFiltered(object: unknown, query?: string): unknown {
return query
? jmespath.search(object, query)
: object;
}
export function parseHeaderSpec(headerValue?: string): HeaderSpec {
if (!headerValue) {
return { mode: "auto" };
}
const raw = headerValue.trim();
if (raw === "" || raw.toLowerCase() === "auto" || raw.toLowerCase() === "a") {
return { mode: "auto" };
}
if (raw.toLowerCase() === "original" || raw.toLowerCase() === "o") {
return { mode: "original" };
}
const parts = raw.split(",").map((p) => p.trim()).filter(Boolean);
const isMap = parts.some((p) => p.includes(":"));
if (!isMap) {
return { mode: "list", labels: parts };
}
const map: Record<string, string> = {};
for (const part of parts) {
const idx = part.indexOf(":");
if (idx < 0) {
throw new Error(`Invalid --header mapping segment: '${part}'`);
}
const key = part.slice(0, idx).trim();
const label = part.slice(idx + 1).trim();
if (!key || !label) {
throw new Error(`Invalid --header mapping segment: '${part}'`);
}
map[key] = label;
}
return { mode: "map", map };
}
export function normalizeOutputFormat(outputValue?: string): OutputFormat {
if (outputValue == null) {
return "json";
}
const raw = outputValue.toLowerCase();
if (raw === "json") {
throw new Error("JSON is the default output. Omit --output to use it.");
}
if (raw === "j") {
throw new Error("JSON is the default output. Omit --output to use it.");
}
if (raw === "table" || raw === "t") return "table";
if (raw === "alignedtable" || raw === "at") return "alignedtable";
if (raw === "prettytable" || raw === "pt") return "prettytable";
if (raw === "tsv") return "tsv";
throw new Error("--output must be one of: table|t, alignedtable|at, prettytable|pt, tsv");
}
function isScalar(value: unknown): value is Scalar {
return value == null || typeof value !== "object";
}
function getScalarRowsAndHeaders(value: unknown): { headers: string[]; rows: ScalarRow[] } {
let rows: Array<Record<string, unknown>>;
if (Array.isArray(value)) {
rows = value.map((item) =>
item && typeof item === "object" && !Array.isArray(item)
? item as Record<string, unknown>
: { value: item },
);
} else if (value && typeof value === "object") {
rows = [value as Record<string, unknown>];
} else {
rows = [{ value }];
}
if (rows.length === 0) {
return {
headers: ["result"],
rows: [{ result: "" }],
};
}
const headers = [...new Set(rows.flatMap((row) => Object.keys(row)))]
.filter((key) =>
rows.every((row) => isScalar(row[key])),
);
if (headers.length === 0) {
return {
headers: ["result"],
rows: [{ result: "" }],
};
}
const scalarRows: ScalarRow[] = rows.map((row) => {
const result: ScalarRow = {};
for (const [key, rowValue] of Object.entries(row)) {
if (isScalar(rowValue)) {
result[key] = rowValue;
}
}
return result;
});
return { headers, rows: scalarRows };
}
function toTsv(value: unknown): string {
const { headers, rows } = getScalarRowsAndHeaders(value);
const lines = rows.map((row) =>
headers
.map((header) => (row[header] == null ? "" : String(row[header]).replaceAll("\t", " ").replaceAll("\n", " ")))
.join("\t"),
);
return lines.join("\n");
}
export async function readJsonFromStdin(): Promise<unknown> {
const input = await new Promise<string>((resolve, reject) => {
let data = "";
process.stdin.setEncoding("utf8");
process.stdin.on("data", (chunk: string) => {
data += chunk;
});
process.stdin.on("end", () => {
resolve(data);
});
process.stdin.on("error", (err) => {
reject(err);
});
});
if (!input.trim()) {
throw new Error("No JSON input provided on stdin");
}
try {
return JSON.parse(input) as unknown;
} catch (err) {
throw new Error(`Invalid JSON input on stdin: ${(err as Error).message}`);
}
}
export async function readCsvFromStdin(separator: string): Promise<unknown> {
if (!separator) {
throw new Error("separator is required");
}
if (separator.length !== 1) {
throw new Error("separator must be a single character");
}
const input = await new Promise<string>((resolve, reject) => {
let data = "";
process.stdin.setEncoding("utf8");
process.stdin.on("data", (chunk: string) => {
data += chunk;
});
process.stdin.on("end", () => {
resolve(data);
});
process.stdin.on("error", (err) => {
reject(err);
});
});
if (!input.trim()) {
throw new Error("No separated values input provided on stdin");
}
try {
const parser = dsvFormat(separator);
const rows = parser.parse(input);
if (rows.columns.some((header: string) => header.trim() === "")) {
throw new Error("header row contains empty column name");
}
return rows;
} catch (err) {
throw new Error(`Invalid separated input on stdin: ${(err as Error).message}`);
}
}
export function renderOutput(
command: string,
output: unknown,
outputFormat: OutputFormat,
headerSpec: HeaderSpec,
): void {
if (outputFormat === "tsv") {
console.log(toTsv(output));
return;
}
if (command === "table") {
console.log(toMarkdownTable(
output,
outputFormat === "alignedtable" || outputFormat === "prettytable",
outputFormat === "prettytable",
headerSpec,
));
} else if (outputFormat === "alignedtable") {
console.log(toMarkdownTable(output, true, false, headerSpec));
} else if (outputFormat === "prettytable") {
console.log(toMarkdownTable(output, true, true, headerSpec));
} else if (outputFormat === "table") {
console.log(toMarkdownTable(output, false, false, headerSpec));
} else {
console.log(JSON.stringify(output, null, 2));
}
}

11
src/index.ts Normal file
View File

@@ -0,0 +1,11 @@
// SPDX-License-Identifier: MIT
export { toMarkdownTable } from "./markdown.ts";
export {
outputFiltered,
parseHeaderSpec,
normalizeOutputFormat,
readJsonFromStdin,
readCsvFromStdin,
renderOutput,
} from "./cli/utils.ts";

138
src/markdown.ts Normal file
View File

@@ -0,0 +1,138 @@
// SPDX-License-Identifier: MIT
type Scalar = string | number | boolean | null | undefined;
type ScalarRow = Record<string, Scalar>;
type HeaderSpec =
| { mode: "default" }
| { mode: "auto" }
| { mode: "original" }
| { mode: "list"; labels: string[] }
| { mode: "map"; map: Record<string, string> };
function formatCell(value: unknown): string {
const text = value == null
? ""
: String(value);
return text.replaceAll("|", "\\|").replaceAll("\n", "<br>");
}
function isGuid(value: unknown): value is string {
return typeof value === "string"
&& /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value);
}
function toAutoHeaderLabel(key: string): string {
const withSpaces = String(key)
.replace(/[_-]+/g, " ")
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
.replace(/\s+/g, " ")
.trim();
return withSpaces
.split(" ")
.filter(Boolean)
.map((part) => part[0].toUpperCase() + part.slice(1))
.join(" ");
}
function isScalar(value: unknown): value is Scalar {
return value == null || typeof value !== "object";
}
function getScalarRowsAndHeaders(value: unknown): { headers: string[]; rows: ScalarRow[] } {
let rows: Array<Record<string, unknown>>;
if (Array.isArray(value)) {
rows = value.map((item) =>
item && typeof item === "object" && !Array.isArray(item)
? item as Record<string, unknown>
: { value: item },
);
} else if (value && typeof value === "object") {
rows = [value as Record<string, unknown>];
} else {
rows = [{ value }];
}
if (rows.length === 0) {
return {
headers: ["result"],
rows: [{ result: "" }],
};
}
const headers = [...new Set(rows.flatMap((row) => Object.keys(row)))]
.filter((key) =>
rows.every((row) => isScalar(row[key])),
);
if (headers.length === 0) {
return {
headers: ["result"],
rows: [{ result: "" }],
};
}
const scalarRows: ScalarRow[] = rows.map((row) => {
const result: ScalarRow = {};
for (const [key, rowValue] of Object.entries(row)) {
if (isScalar(rowValue)) {
result[key] = rowValue;
}
}
return result;
});
return { headers, rows: scalarRows };
}
export function toMarkdownTable(
value: unknown,
pretty = false,
quoteGuids = false,
headerSpec: HeaderSpec = { mode: "default" },
): string {
const { headers, rows } = getScalarRowsAndHeaders(value);
const headerDefinitions = headers.map((key, idx) => {
let label = key;
if (headerSpec.mode === "auto") {
label = toAutoHeaderLabel(key);
} else if (headerSpec.mode === "list" && headerSpec.labels[idx]) {
label = headerSpec.labels[idx];
} else if (headerSpec.mode === "map" && headerSpec.map[key]) {
label = headerSpec.map[key];
}
return { key, label };
});
const renderCell = (raw: Scalar): string => {
const text = formatCell(raw);
return quoteGuids && isGuid(raw) ? `\`${text}\`` : text;
};
if (!pretty) {
const headerLine = `| ${headerDefinitions.map((h) => h.label).join(" | ")} |`;
const separatorLine = `| ${headerDefinitions.map(() => "---").join(" | ")} |`;
const rowLines = rows.map((row) =>
`| ${headerDefinitions.map((h) => formatCell(row[h.key])).join(" | ")} |`,
);
return [headerLine, separatorLine, ...rowLines].join("\n");
}
const widths = headerDefinitions.map((header, idx) =>
Math.max(
header.label.length,
...rows.map((row) => renderCell(row[headerDefinitions[idx].key]).length),
)
);
const renderRow = (values: string[]): string =>
`| ${values.map((v, idx) => v.padEnd(widths[idx], " ")).join(" | ")} |`;
const headerLine = renderRow(headerDefinitions.map((h) => h.label));
const separatorLine = `|-${widths.map((w) => "-".repeat(w)).join("-|-")}-|`;
const rowLines = rows.map((row) =>
renderRow(headerDefinitions.map((header) => renderCell(row[header.key]))),
);
return [headerLine, separatorLine, ...rowLines].join("\n");
}