diff --git a/README.md b/README.md index 714374f..017646d 100644 --- a/README.md +++ b/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` + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..11dddbc --- /dev/null +++ b/package-lock.json @@ -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" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..eef68b0 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..9cf71ee --- /dev/null +++ b/src/cli.ts @@ -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 [options] + +Commands: + table Render stdin data as Markdown table + +Global options (all commands): + --query, -q + --output, -o table|t|alignedtable|at|prettytable|pt|tsv + --help, -h + +Use: sk-tools --help +or: sk-tools --help`; +} + +function usageTable(): string { + return `Usage: sk-tools table [--from|-F ] [--header|-H ] [global options] + +Options: + --from, -F Input format on stdin (default: json) + --header, -H 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 { + 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 { + 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); +}); diff --git a/src/cli/utils.ts b/src/cli/utils.ts new file mode 100644 index 0000000..80eb71c --- /dev/null +++ b/src/cli/utils.ts @@ -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 }; + +type OutputFormat = "json" | "table" | "alignedtable" | "prettytable" | "tsv"; + +type Scalar = string | number | boolean | null | undefined; +type ScalarRow = Record; + +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 = {}; + 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>; + if (Array.isArray(value)) { + rows = value.map((item) => + item && typeof item === "object" && !Array.isArray(item) + ? item as Record + : { value: item }, + ); + } else if (value && typeof value === "object") { + rows = [value as Record]; + } 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 { + const input = await new Promise((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 { + 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((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)); + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..8ccdcd1 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: MIT + +export { toMarkdownTable } from "./markdown.ts"; +export { + outputFiltered, + parseHeaderSpec, + normalizeOutputFormat, + readJsonFromStdin, + readCsvFromStdin, + renderOutput, +} from "./cli/utils.ts"; diff --git a/src/markdown.ts b/src/markdown.ts new file mode 100644 index 0000000..bcba7ab --- /dev/null +++ b/src/markdown.ts @@ -0,0 +1,138 @@ +// SPDX-License-Identifier: MIT + +type Scalar = string | number | boolean | null | undefined; +type ScalarRow = Record; + +type HeaderSpec = + | { mode: "default" } + | { mode: "auto" } + | { mode: "original" } + | { mode: "list"; labels: string[] } + | { mode: "map"; map: Record }; + +function formatCell(value: unknown): string { + const text = value == null + ? "" + : String(value); + return text.replaceAll("|", "\\|").replaceAll("\n", "
"); +} + +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>; + if (Array.isArray(value)) { + rows = value.map((item) => + item && typeof item === "object" && !Array.isArray(item) + ? item as Record + : { value: item }, + ); + } else if (value && typeof value === "object") { + rows = [value as Record]; + } 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"); +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..a260eb0 --- /dev/null +++ b/tsconfig.json @@ -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"] +}