Moved generic table tool with dependencies from sk-az-tools.
This commit is contained in:
32
README.md
32
README.md
@@ -1,2 +1,34 @@
|
||||
# SK Tools
|
||||
|
||||
Generic utilities shared across Slawek's tool packages.
|
||||
|
||||
## Scope
|
||||
|
||||
This package hosts non-domain-specific utilities that can be reused by CLI and library packages.
|
||||
|
||||
Current exported areas:
|
||||
|
||||
- Markdown table rendering (`toMarkdownTable`)
|
||||
- Output helpers for CLI tools (`renderOutput`, `normalizeOutputFormat`, `parseHeaderSpec`)
|
||||
- Input readers for stdin (`readJsonFromStdin`, `readCsvFromStdin`)
|
||||
- JMESPath output filtering (`outputFiltered`)
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @slawek/sk-tools
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Exports
|
||||
|
||||
- `@slawek/sk-tools`
|
||||
- `@slawek/sk-tools/markdown`
|
||||
- `@slawek/sk-tools/cli/utils`
|
||||
|
||||
|
||||
138
package-lock.json
generated
Normal file
138
package-lock.json
generated
Normal file
@@ -0,0 +1,138 @@
|
||||
{
|
||||
"name": "@slawek/sk-tools",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@slawek/sk-tools",
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"d3-dsv": "^3.0.1",
|
||||
"jmespath": "^0.16.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/d3-dsv": "^3.0.7",
|
||||
"@types/jmespath": "^0.15.2",
|
||||
"@types/node": "^24.0.0",
|
||||
"typescript": "^5.8.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-dsv": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz",
|
||||
"integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/jmespath": {
|
||||
"version": "0.15.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/jmespath/-/jmespath-0.15.2.tgz",
|
||||
"integrity": "sha512-pegh49FtNsC389Flyo9y8AfkVIZn9MMPE9yJrO9svhq6Fks2MwymULWjZqySuxmctd3ZH4/n7Mr98D+1Qo5vGA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "24.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.0.tgz",
|
||||
"integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
|
||||
"integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-dsv": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz",
|
||||
"integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"commander": "7",
|
||||
"iconv-lite": "0.6",
|
||||
"rw": "1"
|
||||
},
|
||||
"bin": {
|
||||
"csv2json": "bin/dsv2json.js",
|
||||
"csv2tsv": "bin/dsv2dsv.js",
|
||||
"dsv2dsv": "bin/dsv2dsv.js",
|
||||
"dsv2json": "bin/dsv2json.js",
|
||||
"json2csv": "bin/json2dsv.js",
|
||||
"json2dsv": "bin/json2dsv.js",
|
||||
"json2tsv": "bin/json2dsv.js",
|
||||
"tsv2csv": "bin/dsv2dsv.js",
|
||||
"tsv2json": "bin/dsv2json.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jmespath": {
|
||||
"version": "0.16.0",
|
||||
"resolved": "https://registry.npmjs.org/jmespath/-/jmespath-0.16.0.tgz",
|
||||
"integrity": "sha512-9FzQjJ7MATs1tSpnco1K6ayiYE3figslrXA72G2HQ/n76RzvYlofyi5QM+iX4YRs/pu3yzxlVQSST23+dMDknw==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">= 0.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/rw": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz",
|
||||
"integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.16.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
||||
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
43
package.json
Normal file
43
package.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "@slawek/sk-tools",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
],
|
||||
"scripts": {
|
||||
"clean": "rm -rf dist",
|
||||
"build": "npm run clean && tsc && chmod +x dist/cli.js",
|
||||
"build:watch": "tsc --watch",
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.0.0"
|
||||
},
|
||||
"description": "A set of generic NodeJS utilities shared by Slawek tools.",
|
||||
"dependencies": {
|
||||
"d3-dsv": "^3.0.1",
|
||||
"jmespath": "^0.16.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/d3-dsv": "^3.0.7",
|
||||
"@types/jmespath": "^0.15.2",
|
||||
"@types/node": "^24.0.0",
|
||||
"typescript": "^5.8.2"
|
||||
},
|
||||
"author": {
|
||||
"name": "Sławomir Koszewski",
|
||||
"email": "slawek@koszewscy.waw.pl"
|
||||
},
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"sk-tools": "./dist/cli.js"
|
||||
},
|
||||
"exports": {
|
||||
".": "./dist/index.js",
|
||||
"./markdown": "./dist/markdown.js",
|
||||
"./cli/utils": "./dist/cli/utils.js"
|
||||
}
|
||||
}
|
||||
124
src/cli.ts
Normal file
124
src/cli.ts
Normal 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
230
src/cli/utils.ts
Normal 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
11
src/index.ts
Normal 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
138
src/markdown.ts
Normal 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");
|
||||
}
|
||||
20
tsconfig.json
Normal file
20
tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2024",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": ["ES2024"],
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"rewriteRelativeImportExtensions": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user