From 1c45f8635a68749748f169516589eca5514f2f7a Mon Sep 17 00:00:00 2001 From: Bo Lingen Date: Sat, 1 Jan 2022 15:03:33 -0600 Subject: [PATCH] feat: rewrite in TypeScript / ESM * expand test suite * replace `args` with `cmd-ts` * make stdin an explicit parameter BREAKING CHANGE: to pass input as stdin, `-` must be provided as the input file parameter. BREAKING CHANGE: the `--columns` option long name is now `--column`. --- .eslintrc.cjs | 38 ++++ .gitignore | 2 + ava.config.js | 10 + cli.js | 51 ----- index.js | 58 ------ package.json | 29 +-- readme.md | 69 ++++--- src/cli.ts | 152 +++++++++++++++ src/util.ts | 59 ++++++ test.js | 33 ---- tests/.eslintrc.cjs | 15 ++ {fixtures => tests/fixtures}/input.json | 0 {fixtures => tests/fixtures}/input.ndjson | 0 tests/tests.ts | 224 ++++++++++++++++++++++ tests/tsconfig.json | 6 + tsconfig.json | 17 ++ 16 files changed, 588 insertions(+), 175 deletions(-) create mode 100644 .eslintrc.cjs create mode 100644 ava.config.js delete mode 100644 cli.js delete mode 100644 index.js create mode 100644 src/cli.ts create mode 100644 src/util.ts delete mode 100644 test.js create mode 100644 tests/.eslintrc.cjs rename {fixtures => tests/fixtures}/input.json (100%) rename {fixtures => tests/fixtures}/input.ndjson (100%) create mode 100644 tests/tests.ts create mode 100644 tests/tsconfig.json create mode 100644 tsconfig.json diff --git a/.eslintrc.cjs b/.eslintrc.cjs new file mode 100644 index 0000000..9fae717 --- /dev/null +++ b/.eslintrc.cjs @@ -0,0 +1,38 @@ +"use strict" + +const [, , error] = ["off", "warn", "error"] + +module.exports = { + extends: ["./node_modules/ts-standardx/.eslintrc.js"], + ignorePatterns: ["dist"], + rules: { + "no-unused-vars": [ + error, + { + argsIgnorePattern: "^_", + caughtErrorsIgnorePattern: "^_", + varsIgnorePattern: "^_" + } + ], + quotes: [error, "double"], + + "prettier/prettier": [ + error, + { + semi: false, + singleQuote: false, + trailingComma: "none", + bracketSameLine: true, + arrowParens: "avoid" + } + ] + }, + overrides: [ + { + files: ["**/*.{ts,tsx}"], + rules: { + "@typescript-eslint/quotes": [error, "double"] + } + } + ] +} diff --git a/.gitignore b/.gitignore index 3585ab8..3af49db 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,5 @@ package-lock.json yarn.lock node_modules + +/dist diff --git a/ava.config.js b/ava.config.js new file mode 100644 index 0000000..3da3170 --- /dev/null +++ b/ava.config.js @@ -0,0 +1,10 @@ +export default { + extensions: { + ts: "module" + }, + nonSemVerExperiments: { + configurableModuleFormat: true, + nextGenConfig: true + }, + nodeArguments: ["--loader=ts-node/esm"] +} diff --git a/cli.js b/cli.js deleted file mode 100644 index cee1853..0000000 --- a/cli.js +++ /dev/null @@ -1,51 +0,0 @@ -#!/usr/bin/env node -'use strict' - -const args = require('args') -const convert = require('./') -const stdin = require('get-stdin')() - -args.option('columns', 'List of column names, defaults to object keys.', []) -args.option('align', 'List of alignment types, applied in order to columns.', []) -args.option(['N', 'no-case-headers'], 'Disable automatic sentence casing of derived key names', false) - -const flags = args.parse(process.argv, { - name: 'tablemark', - usageFilter: usage => - usage.replace( - '[options] [command]', - ' > [options]' - ) -}) - -const options = {} - -if (flags.columns.length > 0) { - options.columns = flags.columns.map((column, i) => - ({ name: column, align: flags.align[i] }) - ) -} else if (flags.align.length > 0) { - options.columns = flags.align.map(align => ({ align })) -} - -const ignores = new Set(['columns', 'align', 'N']) - -for (const key of Object.keys(flags)) { - if (ignores.has(key)) continue - - if (key === 'noCaseHeaders') { - options.caseHeaders = !flags[key] - continue - } - - options[key] = flags[key] -} - -stdin.then(input => { - if (!args.sub[0] && !input && process.stdin.isTTY) { - return args.showHelp() - } - - // write results to stdout - process.stdout.write(convert(args.sub[0], input, options) + '\n') -}) diff --git a/index.js b/index.js deleted file mode 100644 index 6a67cfa..0000000 --- a/index.js +++ /dev/null @@ -1,58 +0,0 @@ -'use strict' - -const { readFileSync } = require('fs') -const tablemark = require('tablemark') -const isValidPath = require('is-valid-path') - -const jsonIsArrayRegex = /^\s*\[/ -const isEmptyRegex = /^\s*$/ - -const read = input => { - let contents - - try { - contents = readFileSync(input) - } catch (e) { - throw new ReferenceError( - `Error reading file at ${input} :: ${e.message}` - ) - } - - return contents -} - -const parse = input => { - if (jsonIsArrayRegex.test(input)) { - return parseJson(input) - } - - return input - .split('\n') - .filter(line => !isEmptyRegex.test(line)) - .map(parseJson) -} - -const parseJson = input => { - try { - return JSON.parse(input) - } catch (e) { - throw new TypeError( - `Could not parse input as JSON :: ${e.message}` - ) - } -} - -module.exports = (path, input, options) => { - options = Object.assign({}, options) - - if (path && !isValidPath(path)) { - throw new TypeError('Invalid file path') - } - - const json = path ? read(path) : input - const data = parse(String(json)) - - if (data.length === 0) return '' - - return tablemark(data, options) -} diff --git a/package.json b/package.json index 198baa5..ed16488 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,8 @@ "engines": { "node": ">=14.18" }, + "type": "module", + "exports": "./dist/index.js", "keywords": [ "cli", "cli-app", @@ -20,26 +22,31 @@ "generate" ], "bin": { - "tablemark": "cli.js" + "tablemark": "./dist/cli.js" }, "files": [ - "index.js", - "cli.js" + "dist" ], "scripts": { - "lint": "standard | snazzy", + "changelog": "changelog", + "lint": "ts-standardx && cd tests && ts-standardx . --disable-gitignore", + "build": "tsc", + "pretest": "npm run build", "test": "ava", "prepublishOnly": "npm run lint && npm test" }, "dependencies": { - "args": "^5.0.1", - "get-stdin": "^7.0.0", - "is-valid-path": "^0.1.1", - "tablemark": "^2.0.0" + "cmd-ts": "^0.9.0", + "get-stdin": "^9.0.0", + "tablemark": "^3.0.0" }, "devDependencies": { - "ava": "^2.2.0", - "snazzy": "^8.0.0", - "standard": "^13.0.2" + "@citycide/changelog": "^2.0.0", + "@types/node": "^14.18.2", + "ava": "^3.15.0", + "execa": "^6.0.0", + "ts-node": "^10.4.0", + "ts-standardx": "^0.8.4", + "typescript": "^4.5.4" } } diff --git a/readme.md b/readme.md index 38b9595..2ad72a0 100644 --- a/readme.md +++ b/readme.md @@ -2,58 +2,83 @@ > Generate markdown tables from JSON data at the command line. -Parse JSON input data into a markdown table from the command line, +Render JSON input data as a markdown table from the command line, powered by the [`tablemark`](https://github.com/citycide/tablemark) module. ## features This utility supports: -- JSON file input from a provided path -- data piped from `stdin` -- NDJSON formatted data ([newline delimited JSON](http://ndjson.org/)). +* JSON file input from a provided path +* data piped from `stdin` +* NDJSON formatted data ([newline delimited JSON](http://ndjson.org/)) ## installation ```sh yarn global add tablemark-cli + +# or + +npm install --global tablemark-cli ``` ## usage ```sh -Usage: tablemark > [options] - -Commands: +tablemark 3.0.0 +> Generate markdown tables from JSON data at the command line. - help Display help +ARGUMENTS: + - Path to input file containing JSON data (use - for stdin) -Options: +OPTIONS: + --column , -c= - Custom column name, can be used multiple times (default: infer from object keys) + --align , -a= - Custom alignments, can be used multiple times, applied in order to columns (default: left) + --line-ending, -e - End-of-line string (default: \n) [optional] + --wrap-width, -w - Width at which to hard wrap cell content [default: Infinity] - -a, --align List of alignment types, applied in order to columns. (defaults to []) - -c, --columns List of column names, defaults to object keys. (defaults to []) - -h, --help Output usage information - -v, --version Output the version number +FLAGS: + --no-case-headers, -N - Disable automatic sentence casing of inferred column names [default: false] + --wrap-with-gutters, -G - Add '|' characters to wrapped rows [default: false] + --help, -h - show help + --version, -v - print the version ``` -To use the `align` and `column` options, you can use the `-a` or -`-c` flags multiple times, like this: +To apply the `align` and `column` options to multiple columns, supply the flag +multiple times, like this: -````console -tablemark input.json > output.md -a left -a center -```` +```sh +tablemark input.json > output.md -a left -a center -a right +``` -... which will align the first two columns left and center respectively. +... which will align the first three columns left, center, and right respectively. ## stdin +In bash-like shells: + ```sh -tablemark < input.json > output.md +# stdin -> stdout +echo '{ "one": 1 }' | tablemark - + +# redirect input file content into stdin, then to a file +tablemark - < input.json > output.md +``` + +In PowerShell: + +```powershell +# stdin -> stdout +'{ "one": 1 }' | tablemark - + +# redirect input file content into stdin, then to a file +cat input.json | tablemark - > output.md ``` ## ndjson -NDJSON is a form of JSON that delimits multiple JSON objects by newlines: +[NDJSON](http://ndjson.org) is a form of JSON that delimits multiple JSON objects by newlines: ```js {"name":"trilogy","repo":"[citycide/trilogy](https://github.com/citycide/trilogy)","desc":"No-hassle SQLite with type-casting schema models and support for native & pure JS backends."} @@ -70,7 +95,7 @@ tablemark input.ndjson > output.md ## see also -- [`tablemark`](https://github.com/citycide/tablemark) – the module used by this utility +* [`tablemark`](https://github.com/citycide/tablemark) – the module used by this utility ## contributing diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..791a397 --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,152 @@ +#!/usr/bin/env node + +import { readFileSync } from "fs" +import { dirname, resolve } from "path" +import { fileURLToPath } from "url" + +import { + Alignment, + alignmentOptions, + InputData, + TablemarkOptions +} from "tablemark" + +import getStdin from "get-stdin" + +import { + array, + Type, + binary, + command, + flag, + multioption, + number, + option, + positional, + string, + run +} from "cmd-ts" + +import { convert, read, parse, zip } from "./util.js" + +interface PackageInfo { + description: string + version: string +} + +const getPackageInfo = (): PackageInfo => { + const pkgPath = dirname(fileURLToPath(import.meta.url)) + + try { + const pkg = readFileSync(resolve(pkgPath, "../package.json"), "utf8") + const { description, version } = JSON.parse(pkg) as { + description: string + version: string + } + + return { description, version } + } catch (e) { + return { description: "", version: "" } + } +} + +const alignmentList: Type = { + async from(input) { + return input.map(part => { + if (part === "") { + return "left" + } + + if (!Object.keys(alignmentOptions).includes(part.toLowerCase())) { + throw new Error(`Expected an Alignment, got "${part}"`) + } + + return part as Alignment + }) + } +} + +export const inputContent: Type = { + async from(input) { + const content = input === "-" ? await getStdin() : read(input) + + if (content === "" && process.stdin.isTTY) { + return [] + } + + return parse(content) + } +} + +const { description, version } = getPackageInfo() + +const cmd = command({ + name: "tablemark", + description, + version, + args: { + inputFile: positional({ + displayName: "input-file", + description: "Path to input file containing JSON data (use - for stdin)", + type: inputContent + }), + column: multioption({ + long: "column", + short: "c", + description: + "Custom column name, can be used multiple times (default: infer from object keys)", + type: array(string) + }), + align: multioption({ + long: "align", + short: "a", + description: + "Custom alignments, can be used multiple times, applied in order to columns (default: left)", + type: alignmentList + }), + noCaseHeaders: flag({ + long: "no-case-headers", + short: "N", + description: "Disable automatic sentence casing of inferred column names", + defaultValue: () => false, + defaultValueIsSerializable: true + }), + lineEnding: option({ + long: "line-ending", + short: "e", + description: "End-of-line string (default: \\n)", + type: string, + defaultValue: () => "\n" + }), + wrapWidth: option({ + long: "wrap-width", + short: "w", + description: "Width at which to hard wrap cell content", + type: number, + defaultValue: () => Infinity, + defaultValueIsSerializable: true + }), + wrapWithGutters: flag({ + long: "wrap-with-gutters", + short: "G", + description: "Add '|' characters to wrapped rows", + defaultValue: () => false, + defaultValueIsSerializable: true + }) + }, + handler: args => { + const options: TablemarkOptions = Object.assign({}, args, { + caseHeaders: !args.noCaseHeaders, + columns: [] + }) + + for (const [name, align] of zip(args.column, args.align)) { + options.columns!.push({ name, align }) + } + + // write results to stdout + process.stdout.write(convert(args.inputFile, options) + "\n") + } +}) + +await run(binary(cmd), process.argv) diff --git a/src/util.ts b/src/util.ts new file mode 100644 index 0000000..45a42a8 --- /dev/null +++ b/src/util.ts @@ -0,0 +1,59 @@ +import { readFileSync } from "fs" +import tablemark, { InputData, TablemarkOptions } from "tablemark" + +const jsonIsArrayRegex = /^\s*\[/ +const isEmptyRegex = /^\s*$/ + +export const zip = (listA: T[], listB: U[]): Array<[T, U]> => { + const maxLength = Math.max(listA.length, listB.length) + + return Array.from(new Array(maxLength), (_, index) => [ + listA[index], + listB[index] + ]) +} + +export const read = (input: string): string => { + try { + return readFileSync(input, { encoding: "utf8" }) + } catch (e) { + const detail = e instanceof Error ? ` :: ${e.message}` : "" + throw new ReferenceError(`Error reading file at ${input} ${detail}`.trim()) + } +} + +const parseJson = (input: string): InputData => { + try { + return JSON.parse(input) + } catch (e) { + const details = e instanceof Error ? ` :: ${e.message}` : "" + throw new TypeError( + `Could not parse input as JSON${details}, input:\n${input}`.trim() + ) + } +} + +export const parse = (input: string): InputData => { + if (jsonIsArrayRegex.test(input)) { + return parseJson(input) + } + + // handle ndjson (see http://ndjson.org) + return input + .split("\n") + .filter(line => !isEmptyRegex.test(line)) + .flatMap(parseJson) +} + +export const convert = ( + input?: InputData, + options?: TablemarkOptions +): string => { + options = Object.assign({}, options) + + if (input == null || input.length === 0) { + return "" + } + + return tablemark(input, options) +} diff --git a/test.js b/test.js deleted file mode 100644 index 965d62f..0000000 --- a/test.js +++ /dev/null @@ -1,33 +0,0 @@ -import fs from 'fs' -import test from 'ava' -import { EOL } from 'os' - -import fn from './' - -const expected = [ - '| Name | Repo | Desc |', - '| ------------- | ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |', - '| trilogy | [citycide/trilogy](https://github.com/citycide/trilogy) | No-hassle SQLite with type-casting schema models and support for native & pure JS backends. |', - '| strat | [citycide/strat](https://github.com/citycide/strat) | Functional-ish JavaScript string formatting, with inspirations from Python. |', - '| tablemark-cli | [citycide/tablemark-cli](https://github.com/citycide/tablemark-cli) | Generate markdown tables from JSON data at the command line. |' -].join(EOL) + EOL - -const inputPath = './fixtures/input.json' -const ndjsonInputPath = './fixtures/input.ndjson' - -// see the `tablemark` module for more tests - -test('outputs the expected markdown from path', t => { - const result = fn(inputPath) - t.is(result, expected) -}) - -test('outputs the expected markdown from stdin', t => { - const result = fn(null, fs.readFileSync(inputPath)) - t.is(result, expected) -}) - -test('outputs the expected markdown from ndjson', t => { - const result = fn(null, fs.readFileSync(ndjsonInputPath)) - t.is(result, expected) -}) diff --git a/tests/.eslintrc.cjs b/tests/.eslintrc.cjs new file mode 100644 index 0000000..2f8a6d4 --- /dev/null +++ b/tests/.eslintrc.cjs @@ -0,0 +1,15 @@ +"use strict" + +const { resolve } = require("path") + +module.exports = { + extends: [resolve(__dirname, "../.eslintrc.cjs")], + overrides: [ + { + files: ["**/*.{ts,tsx}"], + parserOptions: { + project: resolve(__dirname, "tsconfig.json") + } + } + ] +} diff --git a/fixtures/input.json b/tests/fixtures/input.json similarity index 100% rename from fixtures/input.json rename to tests/fixtures/input.json diff --git a/fixtures/input.ndjson b/tests/fixtures/input.ndjson similarity index 100% rename from fixtures/input.ndjson rename to tests/fixtures/input.ndjson diff --git a/tests/tests.ts b/tests/tests.ts new file mode 100644 index 0000000..23c16ea --- /dev/null +++ b/tests/tests.ts @@ -0,0 +1,224 @@ +import { readFileSync } from "fs" +import { dirname, resolve } from "path" +import { fileURLToPath } from "url" + +import test from "ava" +import { execaCommandSync } from "execa" + +interface ExecutionResult { + success: boolean + stdout: string + stderr: string +} + +const joinLines = (lines: string[], lineEnding = "\n"): string => + lines.join(lineEnding) + lineEnding + +const expected = joinLines([ + "| Name | Repo | Desc |", + "| :------------ | :------------------------------------------------------------------ | :------------------------------------------------------------------------------------------ |", + "| trilogy | [citycide/trilogy](https://github.com/citycide/trilogy) | No-hassle SQLite with type-casting schema models and support for native & pure JS backends. |", + "| strat | [citycide/strat](https://github.com/citycide/strat) | Functional-ish JavaScript string formatting, with inspirations from Python. |", + "| tablemark-cli | [citycide/tablemark-cli](https://github.com/citycide/tablemark-cli) | Generate markdown tables from JSON data at the command line. |" +]) + +const inputLongValue = JSON.stringify({ + "lots of ones": "1".repeat(50) +}) + +const inputThreeColumn = JSON.stringify({ + one: "one", + two: "two", + "three dog": "night" +}) + +const testDirectory = dirname(fileURLToPath(import.meta.url)) +const cliPath = resolve(testDirectory, "../dist/cli.js") +const inputPath = resolve(testDirectory, "./fixtures/input.json") +const ndjsonInputPath = resolve(testDirectory, "./fixtures/input.ndjson") + +const jsonContent = readFileSync(inputPath, "utf8") + +const execute = (argString: string, stdin?: string): ExecutionResult => { + const { stdout, stderr, failed } = execaCommandSync( + `node ${cliPath} ${argString}`.trim(), + { + encoding: "utf8", + input: stdin + } + ) + + return { + success: !failed, + stdout, + stderr + } +} + +test("renders JSON from file as a markdown table", async t => { + const { success, stdout } = execute(inputPath) + t.true(success) + t.is(stdout, expected) +}) + +test("renders NDJSON from file as a markdown table", async t => { + const { success, stdout } = execute(ndjsonInputPath) + t.true(success) + t.is(stdout, expected) +}) + +test("renders JSON content from stdin as a markdown table", async t => { + const { success, stdout } = execute("-", jsonContent) + t.true(success) + t.is(stdout, expected) +}) + +test("fails when input file path does not exist", async t => { + t.throws(() => execute("not-a-file.js"), { + message: /no such file or directory/ + }) +}) + +test("fails when input content is invalid", async t => { + t.throws(() => execute("-", "not json"), { + message: /Could not parse input as JSON/ + }) +}) + +test("long values are not wrapped by default", async t => { + const expected = joinLines([ + "| Lots of ones |", + "| :------------------------------------------------- |", + "| 11111111111111111111111111111111111111111111111111 |" + ]) + + const { success, stdout } = execute("-", inputLongValue) + + t.true(success) + t.is(stdout, expected) +}) + +test("long values are wrapped if `--wrap-width` is supplied", async t => { + const expected = joinLines([ + "| Lots of ones |", + "| :------------------------ |", + "| 1111111111111111111111111 |", + " 1111111111111111111111111 " + ]) + + const { success, stdout } = execute("- --wrap-width 25", inputLongValue) + + t.true(success) + t.is(stdout, expected) +}) + +test("gutters are included on wrapped rows when `--wrap-with-gutters` is supplied", async t => { + const expected = joinLines([ + "| Lots of ones |", + "| :------------------------ |", + "| 1111111111111111111111111 |", + "| 1111111111111111111111111 |" + ]) + + const { success, stdout } = execute( + "- --wrap-width 25 --wrap-with-gutters", + inputLongValue + ) + + t.true(success) + t.is(stdout, expected) +}) + +test("line ending can be customized using `--line-ending`", async t => { + const lineEnding = "~@~" + + const expected = joinLines( + [ + "| Lots of ones |", + "| :------------------------------------------------- |", + "| 11111111111111111111111111111111111111111111111111 |" + ], + lineEnding + ) + + const { success, stdout } = execute( + `- --line-ending ${lineEnding}`, + inputLongValue + ) + + t.true(success) + t.is(stdout, expected) +}) + +test("sentence casing can be disabled with `--no-case-headers`", async t => { + const expected = joinLines([ + "| one | two | three dog |", + "| :---- | :---- | :-------- |", + "| one | two | night |" + ]) + + const { success, stdout } = execute("- --no-case-headers", inputThreeColumn) + + t.true(success) + t.is(stdout, expected) +}) + +test("column alignment can be customized using `--align`", async t => { + const expected = joinLines([ + "| One | Two | Three dog |", + "| :---- | :---: | --------: |", + "| one | two | night |" + ]) + + const { success, stdout } = execute( + "- --align left --align center --align right", + inputThreeColumn + ) + + t.true(success) + t.is(stdout, expected) +}) + +test("column names can be customized using `--column`", async t => { + const expected = joinLines([ + "| AAA | BBB | CCCCC |", + "| :---- | :---- | :---- |", + "| one | two | night |" + ]) + + const { success, stdout } = execute( + "- --column AAA --column BBB --column CCCCC", + inputThreeColumn + ) + + t.true(success) + t.is(stdout, expected) +}) + +test("all options work together as expected", async t => { + const lineEnding = "~@~" + + const expected = joinLines( + [ + "| AAA | BBB | three |", + "| | | dog |", + "| :---- | :---: | ----: |", + "| one | two | night |" + ], + lineEnding + ) + + const columns = ["AAA", "BBB"].map(name => `-c ${name}`).join(" ") + + const alignments = ["left", "center", "right"] + .map(align => `-a ${align}`) + .join(" ") + + const { success, stdout } = execute( + `- ${columns} ${alignments} --wrap-width 3 --wrap-with-gutters --line-ending ${lineEnding} --no-case-headers`, + inputThreeColumn + ) + + t.true(success) + t.is(stdout, expected) +}) diff --git a/tests/tsconfig.json b/tests/tsconfig.json new file mode 100644 index 0000000..f75e3db --- /dev/null +++ b/tests/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../tsconfig.json", + "include": [ + "." + ] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..884411b --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "module": "es2022", + "target": "es2020", + "moduleResolution": "node", + "strict": true, + "declaration": true, + "outDir": "dist", + // emitted bin script must use LF line endings to be + // executed properly in all scenarios, see: + // https://github.com/citycide/tablemark-cli/issues/1 + "newLine": "lf" + }, + "include": [ + "src" + ] +}