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/declarations.d.ts b/declarations.d.ts new file mode 100644 index 0000000..91eb7a2 --- /dev/null +++ b/declarations.d.ts @@ -0,0 +1,9 @@ +declare module "split-text-to-chunks" { + interface Split { + width: (text: string, max: number) => number + (text: string, columns: number): string + } + + const split: Split + export default split +} diff --git a/fixtures/inputs.js b/fixtures/inputs.js deleted file mode 100644 index b841cbb..0000000 --- a/fixtures/inputs.js +++ /dev/null @@ -1,197 +0,0 @@ -'use strict' - -const { EOL } = require('os') - -module.exports = { - standard: { - input: [ - { - name: 'trilogy', - repo: '[citycide/trilogy](//github.com/citycide/trilogy)', - desc: 'No-hassle SQLite with type-casting schema models and support for native & pure JS backends.' - }, - { - name: 'strat', - repo: '[citycide/strat](//github.com/citycide/strat)', - desc: 'Functional-ish JavaScript string formatting, with inspirations from Python.' - }, - { - name: 'tablemark', - repo: '[citycide/tablemark](//github.com/citycide/tablemark)', - desc: 'Generate markdown tables from JSON data.' - } - ], - expected: [ - '| Name | Repo | Desc |', - '| --------- | ----------------------------------------------------- | ------------------------------------------------------------------------------------------- |', - '| trilogy | [citycide/trilogy](//github.com/citycide/trilogy) | No-hassle SQLite with type-casting schema models and support for native & pure JS backends. |', - '| strat | [citycide/strat](//github.com/citycide/strat) | Functional-ish JavaScript string formatting, with inspirations from Python. |', - '| tablemark | [citycide/tablemark](//github.com/citycide/tablemark) | Generate markdown tables from JSON data. |' - ].join(EOL) + EOL - }, - alignments: { - input: [ - { name: 'Bob', age: 21, isCool: false }, - { name: 'Sarah', age: 22, isCool: true }, - { name: 'Lee', age: 23, isCool: true } - ], - options: { - columns: [ - { align: 'left' }, - { align: 'right' }, - { align: 'center' } - ] - }, - expected: [ - '| Name | Age | Is cool |', - '| :---- | ----: | :-----: |', - '| Bob | 21 | false |', - '| Sarah | 22 | true |', - '| Lee | 23 | true |' - ].join(EOL) + EOL - }, - columns: { - input: [ - { name: 'Bob', age: 21, isCool: false }, - { name: 'Sarah', age: 22, isCool: true }, - { name: 'Lee', age: 23, isCool: true } - ], - options: { - columns: [ - { name: 'word' }, - { name: 'number' }, - { name: 'boolean' } - ] - }, - expected: [ - '| word | number | boolean |', - '| ----- | ------ | ------- |', - '| Bob | 21 | false |', - '| Sarah | 22 | true |', - '| Lee | 23 | true |' - ].join(EOL) + EOL - }, - casing: { - input: [ - { name: 'Bob', age: 21, isCool: false }, - { name: 'Sarah', age: 22, isCool: true }, - { name: 'Lee', age: 23, isCool: true } - ], - options: { - caseHeaders: false - }, - expected: [ - '| name | age | isCool |', - '| ----- | ----- | ------ |', - '| Bob | 21 | false |', - '| Sarah | 22 | true |', - '| Lee | 23 | true |' - ].join(EOL) + EOL - }, - coerce: { - input: [ - { name: 'Bob', age: 21, isCool: false }, - { name: 'Sarah', age: 22, isCool: true }, - { name: 'Lee', age: 23, isCool: true } - ], - options: { - stringify (v) { - if (v === true) return 'Yes' - if (v === false) return 'No' - return String(v) - } - }, - expected: [ - '| Name | Age | Is cool |', - '| ----- | ----- | ------- |', - '| Bob | 21 | No |', - '| Sarah | 22 | Yes |', - '| Lee | 23 | Yes |' - ].join(EOL) + EOL - }, - wrap: { - input: [ - { name: 'Benjamin', age: 21, isCool: false }, - { name: 'Sarah', age: 22, isCool: true }, - { name: 'Lee', age: 23, isCool: true } - ], - options: { - wrap: { width: 5 } - }, - expected: [ - // headers wrap, soft wrap - '| Name | Age | Is |', - ' cool ', - '| ----- | ----- | ----- |', - // hard wrap - '| Benja | 21 | false |', - ' min ', - // no wrap - '| Sarah | 22 | true |', - '| Lee | 23 | true |' - ].join(EOL) + EOL - }, - newlines: { - input: [ - { name: 'Benjamin\nor Ben', age: 21, isCool: false }, - { name: 'Sarah', age: 22, isCool: true }, - { name: 'Lee', age: 23, isCool: true } - ], - expected: [ - '| Name | Age | Is cool |', - '| -------- | ----- | ------- |', - '| Benjamin | 21 | false |', - ' or Ben ', - '| Sarah | 22 | true |', - '| Lee | 23 | true |' - ].join(EOL) + EOL - }, - wrapAndNewlines: { - input: [ - { name: 'Benjamin or\nBen', age: 21, isCool: false }, - { name: 'Sarah', age: 22, isCool: true }, - { name: 'Lee', age: 23, isCool: true } - ], - options: { - wrap: { width: 8 } - }, - expected: [ - '| Name | Age | Is cool |', - '| -------- | ----- | ------- |', - '| Benjamin | 21 | false |', - ' or ', - ' Ben ', - '| Sarah | 22 | true |', - '| Lee | 23 | true |' - ].join(EOL) + EOL - }, - gutters: { - input: [ - { name: 'Benjamin', age: 21, isCool: false }, - { name: 'Sarah', age: 22, isCool: true }, - { name: 'Lee', age: 23, isCool: true } - ], - options: { - wrap: { width: 5, gutters: true } - }, - expected: [ - '| Name | Age | Is |', - '| | | cool |', - '| ----- | ----- | ----- |', - '| Benja | 21 | false |', - '| min | | |', - '| Sarah | 22 | true |', - '| Lee | 23 | true |' - ].join(EOL) + EOL - }, - pipes: { - input: [ - { content: 'yes | no' } - ], - expected: [ - '| Content |', - '| --------- |', - '| yes \\| no |' - ].join(EOL) + EOL - } -} diff --git a/index.d.ts b/index.d.ts deleted file mode 100644 index 9af597d..0000000 --- a/index.d.ts +++ /dev/null @@ -1,25 +0,0 @@ -type InputData = (Iterable | T)[] - -type ColumnDescriptor = string | { - align?: 'LEFT' | 'CENTER' | 'RIGHT' - name: string -} - -interface StringifyFunction { - (value: any): string -} - -interface WrapOptions { - gutter?: boolean, - width?: number -} - -interface TablemarkOptions { - caseHeaders?: boolean, - columns?: ColumnDescriptor[], - stringify?: StringifyFunction, - wrap?: Partial -} - -declare function tablemark (input: InputData, options?: TablemarkOptions): string -export = tablemark diff --git a/index.js b/index.js deleted file mode 100644 index f782c20..0000000 --- a/index.js +++ /dev/null @@ -1,157 +0,0 @@ -'use strict' - -const { EOL } = require('os') -const { sentenceCase } = require('sentence-case') -const split = require('split-text-to-chunks') - -const { width } = split -const columnsWidthMin = 5 -const alignmentOptions = new Set(['LEFT', 'CENTER', 'RIGHT']) -const pipeRegex = /\|/g - -const pad = (alignment, width, content) => { - if (!alignment || alignment === 'LEFT') { - return content.padEnd(width) - } - - if (alignment === 'RIGHT') { - return content.padStart(width) - } - - // center alignment - const remainder = (width - content.length) % 2 - const sides = (width - content.length - remainder) / 2 - - return ' '.repeat(sides) + content + ' '.repeat(sides + remainder) -} - -const toString = v => { - if (typeof v === 'undefined') return '' - return String(v).replace(pipeRegex, '\\|') -} - -const line = (columns, gutters) => - (gutters ? '| ' : ' ') + - columns.join((gutters ? ' | ' : ' ')) + - (gutters ? ' |' : ' ') + EOL - -const row = (alignments, widths, columns, gutters) => { - const width = columns.length - const values = new Array(width) - const first = new Array(width) - let height = 1 - - for (let h = 0; h < width; h++) { - const cells = values[h] = split(columns[h], widths[h]) - if (cells.length > height) height = cells.length - first[h] = pad(alignments[h], widths[h], cells[0]) - } - - if (height === 1) return line(first, true) - - const lines = new Array(height) - lines[0] = line(first, true) - - for (let v = 1; v < height; v++) { - lines[v] = new Array(width) - } - - for (let h = 0; h < width; h++) { - const cells = values[h] - let v = 1 - - for (; v < cells.length; v++) { - lines[v][h] = pad(alignments[h], widths[h], cells[v]) - } - - for (; v < height; v++) { - lines[v][h] = ' '.repeat(widths[h]) - } - } - - for (let h = 1; h < height; h++) { - lines[h] = line(lines[h], gutters) - } - - return lines.join('') -} - -module.exports = (input, options) => { - if (!Array.isArray(input)) { - throw new TypeError(`Expected an Array, got ${typeof input}`) - } - - options = Object.assign({ - stringify: toString - }, options, { - wrap: Object.assign({ - width: Infinity, - gutters: false - }, options && options.wrap) - }) - - const { stringify } = options - const { gutters, width: columnsMaxWidth } = options.wrap - - const keys = Object.keys(input[0]) - - const titles = keys.map((key, i) => { - if (Array.isArray(options.columns) && options.columns[i]) { - if (typeof options.columns[i] === 'string') { - return options.columns[i] - } else if (options.columns[i].name) { - return options.columns[i].name - } - } - - if (options.caseHeaders === false) return key - - return sentenceCase(key) - }) - - const widths = input.reduce( - (sizes, item) => keys.map( - (key, i) => Math.max(width(stringify(item[key]), columnsMaxWidth), sizes[i]) - ), - titles.map(t => Math.max(columnsWidthMin, width(t, columnsMaxWidth))) - ) - - const alignments = keys.map((key, i) => { - if ( - Array.isArray(options.columns) && - options.columns[i] && - options.columns[i].align - ) { - const align = String(options.columns[i].align).toUpperCase() - - if (!alignmentOptions.has(align)) { - throw new TypeError(`Unknown alignment, got ${options.columns[i].align}`) - } - - return align - } - }) - - let table = '' - - // header line - table += row(alignments, widths, titles, gutters) - - // header separator - table += line(alignments.map( - (align, i) => ( - (align === 'LEFT' || align === 'CENTER' ? ':' : '-') + - '-'.repeat(widths[i] - 2) + - (align === 'RIGHT' || align === 'CENTER' ? ':' : '-') - ) - ), true) - - // table body - table += input.map( - (item, i) => row(alignments, widths, keys.map( - key => stringify(item[key]) - ), gutters) - ).join('') - - return table -} diff --git a/package.json b/package.json index 2a8d7a6..c5e2b6b 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,9 @@ "engines": { "node": ">=14.16" }, + "type": "module", + "exports": "./dist/index.js", + "sideEffects": false, "keywords": [ "markdown", "table", @@ -18,26 +21,30 @@ "convert", "generate", "ghfm", - "tableify" + "tableify", + "typescript" ], "files": [ - "index.d.ts", - "index.js" + "dist" ], "scripts": { - "lint": "standard | snazzy", + "lint": "ts-standardx src && cd tests && ts-standardx .", + "build": "tsc", + "pretest": "npm run build", "test": "ava", "changelog": "changelog", "prepublishOnly": "npm run lint && npm test" }, "devDependencies": { "@citycide/changelog": "^2.0.0", - "ava": "^3.8.1", - "snazzy": "^8.0.0", - "standard": "^14.3.3" + "@types/node": "^14.17.27", + "ava": "^3.15.0", + "ts-node": "^10.3.0", + "ts-standardx": "^0.8.4", + "typescript": "^4.4.4" }, "dependencies": { - "sentence-case": "^3.0.3", + "sentence-case": "^3.0.4", "split-text-to-chunks": "^1.0.0" } } diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..c17dd68 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,67 @@ +import type { InputData, TablemarkOptions } from "./types.js" + +import { + getColumnAlignments, + getColumnTitles, + getColumnWidths, + line, + normalizeOptions, + row +} from "./utilities.js" + +export const alignmentOptions = { + left: "LEFT", + center: "CENTER", + right: "RIGHT" +} as const + +export default (input: InputData, options: TablemarkOptions = {}): string => { + if (typeof input[Symbol.iterator] !== "function") { + throw new TypeError(`Expected an iterable, got ${typeof input}`) + } + + const config = normalizeOptions(options) + + const keys = Object.keys(input[0]) + const titles = getColumnTitles(keys, config) + const widths = getColumnWidths(input, keys, titles, config) + const alignments = getColumnAlignments(keys, config) + + let table = "" + + // header line + table += row(alignments, widths, titles, config) + + // header separator + table += line( + alignments.map( + (align, i) => + (align === alignmentOptions.left || align === alignmentOptions.center + ? ":" + : "-") + + "-".repeat(widths[i] - 2) + + (align === alignmentOptions.right || align === alignmentOptions.center + ? ":" + : "-") + ), + config, + true + ) + + // table body + table += input + .map((item, _) => + row( + alignments, + widths, + keys.map(key => config.toCellText(item[key])), + config + ) + ) + .join("") + + return table +} + +export { toCellText } from "./utilities.js" +export * from "./types.js" diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..ad73987 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,75 @@ +import type { alignmentOptions } from "./index.js" + +export interface LooseObject { + [key: string]: unknown +} + +export type InputData = T[] + +export type Alignment = + | keyof typeof alignmentOptions + | Uppercase + +export interface ColumnDescriptor { + /** + * Alignment to use for this column. + */ + align?: Alignment + + /** + * Text to use as the column title. + */ + name?: string +} + +export type ToCellText = (value: unknown) => string + +export interface TablemarkOptions { + /** + * Whether to sentence-case header titles derived from input object + * keys. + */ + caseHeaders?: boolean + + /** + * Array for configuring column alignment, where each element sets + * options for its corresponding item in the input data. + * + * Each element can be either an object, in which case the `name` + * and `align` properties control the display of the column, or + * a `string` to be used as the column title. + */ + columns?: Array + + /** + * Function used to convert input values to `string`s suitable + * for display in the output table. By default all values are + * converted using `String()` and `|` characters are escaped. + */ + toCellText?: ToCellText + + /** + * Whether to add `|` characters when wrapping within rows. + */ + wrapWithGutters?: boolean + + /** + * Width at which to wrap the content of columns. The default is + * `Infinity` meaning no wrapping will be performed. + */ + wrapWidth?: number + + /** + * Text to use as the line ending, `\n` by default. + */ + lineEnding?: string +} + +export type TablemarkOptionsNormalized = Omit< + { + [key in keyof TablemarkOptions]-?: TablemarkOptions[key] + }, + "columns" +> & { + columns: ColumnDescriptor[] +} diff --git a/src/utilities.ts b/src/utilities.ts new file mode 100644 index 0000000..a5acf58 --- /dev/null +++ b/src/utilities.ts @@ -0,0 +1,198 @@ +import { sentenceCase } from "sentence-case" +import split from "split-text-to-chunks" + +import { alignmentOptions } from "./index.js" + +import type { + Alignment, + InputData, + ToCellText, + TablemarkOptions, + TablemarkOptionsNormalized +} from "./types.js" + +const columnsWidthMin = 5 +const pipeRegex = /\|/g + +const alignmentSet: Set> = new Set([ + "LEFT", + "CENTER", + "RIGHT" +]) + +export const pad = ( + alignment: Alignment, + width: number, + content: string +): string => { + if (alignment == null || alignment === alignmentOptions.left) { + return content.padEnd(width) + } + + if (alignment === alignmentOptions.right) { + return content.padStart(width) + } + + // center alignment + const remainder = Math.max(0, (width - content.length) % 2) + const sides = Math.max(0, (width - content.length - remainder) / 2) + + return " ".repeat(sides) + content + " ".repeat(sides + remainder) +} + +export const toCellText: ToCellText = v => { + if (typeof v === "undefined") return "" + return String(v).replace(pipeRegex, "\\|") +} + +export const line = ( + columns: readonly string[], + config: TablemarkOptionsNormalized, + forceGutters = false +): string => { + const gutters = forceGutters ? true : config.wrapWithGutters + + return ( + (gutters ? "| " : " ") + + columns.join(gutters ? " | " : " ") + + (gutters ? " |" : " ") + + config.lineEnding + ) +} + +export const row = ( + alignments: readonly Alignment[], + widths: readonly number[], + columns: readonly string[], + config: TablemarkOptionsNormalized +): string => { + const width = columns.length + const values = new Array(width) + const first = new Array(width) + let height = 1 + + for (let h = 0; h < width; h++) { + const cells = (values[h] = split(columns[h], widths[h])) + if (cells.length > height) height = cells.length + first[h] = pad(alignments[h], widths[h], cells[0]) + } + + if (height === 1) { + return line(first, config, true) + } + + const lines = new Array(height) + lines[0] = line(first, config, true) + + for (let v = 1; v < height; v++) { + lines[v] = new Array(width) + } + + for (let h = 0; h < width; h++) { + const cells = values[h] + let v = 1 + + for (; v < cells.length; v++) { + lines[v][h] = pad(alignments[h], widths[h], cells[v]) + } + + for (; v < height; v++) { + lines[v][h] = " ".repeat(widths[h]) + } + } + + for (let h = 1; h < height; h++) { + lines[h] = line(lines[h], config) + } + + return lines.join("") +} + +export const normalizeOptions = ( + options: TablemarkOptions +): TablemarkOptionsNormalized => { + const defaults: TablemarkOptionsNormalized = { + toCellText: toCellText, + caseHeaders: true, + columns: [], + lineEnding: "\n", + wrapWidth: Infinity, + wrapWithGutters: false + } + + Object.assign(defaults, options) + + defaults.columns = + options?.columns?.map(descriptor => { + if (typeof descriptor === "string") { + return { name: descriptor } + } + + const align = + (descriptor.align?.toUpperCase() as Uppercase) ?? + alignmentOptions.left + + if (!alignmentSet.has(align)) { + throw new TypeError(`Unknown alignment, got ${descriptor.align}`) + } + + return { + align, + name: descriptor.name + } + }) ?? [] + + return defaults +} + +export const getColumnTitles = ( + keys: readonly string[], + config: TablemarkOptionsNormalized +): string[] => { + return keys.map((key, i) => { + if (Array.isArray(config.columns)) { + const customTitle = config.columns[i]?.name + + if (customTitle != null) { + return customTitle + } + } + + if (!config.caseHeaders) { + return key + } + + return sentenceCase(key) + }) +} + +export const getColumnWidths = ( + input: InputData, + keys: readonly string[], + titles: readonly string[], + config: TablemarkOptionsNormalized +): number[] => { + return input.reduce( + (sizes, item) => + keys.map((key, i) => + Math.max( + split.width(config.toCellText(item[key]), config.wrapWidth), + sizes[i] + ) + ), + titles.map(t => Math.max(columnsWidthMin, split.width(t, config.wrapWidth))) + ) +} + +export const getColumnAlignments = ( + keys: readonly string[], + config: TablemarkOptionsNormalized +): Alignment[] => { + return keys.map((_, i) => { + if (typeof config.columns[i]?.align === "string") { + return config.columns[i].align as Alignment + } + + return alignmentOptions.left + }) +} diff --git a/test.js b/test.js deleted file mode 100644 index 00b4254..0000000 --- a/test.js +++ /dev/null @@ -1,56 +0,0 @@ -'use strict' - -const test = require('ava') - -const fn = require('./') -const cases = require('./fixtures/inputs') - -test('outputs the expected markdown', t => { - const result = fn(cases.standard.input) - t.is(result, cases.standard.expected) -}) - -test('works when provided alignment options', t => { - const result = fn(cases.alignments.input, cases.alignments.options) - t.is(result, cases.alignments.expected) -}) - -test('replaces column names when provided', t => { - const result = fn(cases.columns.input, cases.columns.options) - t.is(result, cases.columns.expected) -}) - -test('can override sentence casing', t => { - const result = fn(cases.casing.input, cases.casing.options) - t.is(result, cases.casing.expected) -}) - -test('can use custom stringify function', t => { - const result = fn(cases.coerce.input, cases.coerce.options) - t.is(result, cases.coerce.expected) -}) - -test('text wrapping', t => { - const result = fn(cases.wrap.input, cases.wrap.options) - t.is(result, cases.wrap.expected) -}) - -test('newlines', t => { - const result = fn(cases.newlines.input, cases.newlines.options) - t.is(result, cases.newlines.expected) -}) - -test('text wrapping and newlines combined', t => { - const result = fn(cases.wrapAndNewlines.input, cases.wrapAndNewlines.options) - t.is(result, cases.wrapAndNewlines.expected) -}) - -test('gutters', t => { - const result = fn(cases.gutters.input, cases.gutters.options) - t.is(result, cases.gutters.expected) -}) - -test('pipes in content', t => { - const result = fn(cases.pipes.input, cases.pipes.options) - t.is(result, cases.pipes.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/tests/cases.ts b/tests/cases.ts new file mode 100644 index 0000000..0d451b6 --- /dev/null +++ b/tests/cases.ts @@ -0,0 +1,57 @@ +import test from "ava" + +import tablemark from "../dist/index.js" +import cases from "./fixtures/inputs.js" + +test("outputs the expected markdown", t => { + const result = tablemark(cases.standard.input) + t.is(result, cases.standard.expected) +}) + +test("works when provided alignment options", t => { + const result = tablemark(cases.alignments.input, cases.alignments.options) + t.is(result, cases.alignments.expected) +}) + +test("replaces column names when provided", t => { + const result = tablemark(cases.columns.input, cases.columns.options) + t.is(result, cases.columns.expected) +}) + +test("can override sentence casing", t => { + const result = tablemark(cases.casing.input, cases.casing.options) + t.is(result, cases.casing.expected) +}) + +test("can use custom stringify function", t => { + const result = tablemark(cases.coerce.input, cases.coerce.options) + t.is(result, cases.coerce.expected) +}) + +test("text wrapping", t => { + const result = tablemark(cases.wrap.input, cases.wrap.options) + t.is(result, cases.wrap.expected) +}) + +test("newlines", t => { + const result = tablemark(cases.newlines.input, cases.newlines.options) + t.is(result, cases.newlines.expected) +}) + +test("text wrapping and newlines combined", t => { + const result = tablemark( + cases.wrapAndNewlines.input, + cases.wrapAndNewlines.options + ) + t.is(result, cases.wrapAndNewlines.expected) +}) + +test("gutters", t => { + const result = tablemark(cases.gutters.input, cases.gutters.options) + t.is(result, cases.gutters.expected) +}) + +test("pipes in content", t => { + const result = tablemark(cases.pipes.input, cases.pipes.options) + t.is(result, cases.pipes.expected) +}) diff --git a/tests/fixtures/inputs.ts b/tests/fixtures/inputs.ts new file mode 100644 index 0000000..37bdde8 --- /dev/null +++ b/tests/fixtures/inputs.ts @@ -0,0 +1,202 @@ +import type { TablemarkOptions } from "../../src/types.js" + +const EOL = "\n" + +interface TestCase { + input: Array<{ [key: string]: unknown }> + expected: string + options?: TablemarkOptions +} + +const testCases: Record = { + standard: { + input: [ + { + name: "trilogy", + repo: "[citycide/trilogy](//github.com/citycide/trilogy)", + desc: "No-hassle SQLite with type-casting schema models and support for native & pure JS backends." + }, + { + name: "strat", + repo: "[citycide/strat](//github.com/citycide/strat)", + desc: "Functional-ish JavaScript string formatting, with inspirations from Python." + }, + { + name: "tablemark", + repo: "[citycide/tablemark](//github.com/citycide/tablemark)", + desc: "Generate markdown tables from JSON data." + } + ], + expected: + [ + "| Name | Repo | Desc |", + "| :-------- | :---------------------------------------------------- | :------------------------------------------------------------------------------------------ |", + "| trilogy | [citycide/trilogy](//github.com/citycide/trilogy) | No-hassle SQLite with type-casting schema models and support for native & pure JS backends. |", + "| strat | [citycide/strat](//github.com/citycide/strat) | Functional-ish JavaScript string formatting, with inspirations from Python. |", + "| tablemark | [citycide/tablemark](//github.com/citycide/tablemark) | Generate markdown tables from JSON data. |" + ].join(EOL) + EOL + }, + alignments: { + input: [ + { name: "Bob", age: 21, isCool: false }, + { name: "Sarah", age: 22, isCool: true }, + { name: "Lee", age: 23, isCool: true } + ], + options: { + columns: [{ align: "left" }, { align: "right" }, { align: "center" }] + }, + expected: + [ + "| Name | Age | Is cool |", + "| :---- | ----: | :-----: |", + "| Bob | 21 | false |", + "| Sarah | 22 | true |", + "| Lee | 23 | true |" + ].join(EOL) + EOL + }, + columns: { + input: [ + { name: "Bob", age: 21, isCool: false }, + { name: "Sarah", age: 22, isCool: true }, + { name: "Lee", age: 23, isCool: true } + ], + options: { + columns: [{ name: "word" }, { name: "number" }, { name: "boolean" }] + }, + expected: + [ + "| word | number | boolean |", + "| :---- | :----- | :------ |", + "| Bob | 21 | false |", + "| Sarah | 22 | true |", + "| Lee | 23 | true |" + ].join(EOL) + EOL + }, + casing: { + input: [ + { name: "Bob", age: 21, isCool: false }, + { name: "Sarah", age: 22, isCool: true }, + { name: "Lee", age: 23, isCool: true } + ], + options: { + caseHeaders: false + }, + expected: + [ + "| name | age | isCool |", + "| :---- | :---- | :----- |", + "| Bob | 21 | false |", + "| Sarah | 22 | true |", + "| Lee | 23 | true |" + ].join(EOL) + EOL + }, + coerce: { + input: [ + { name: "Bob", age: 21, isCool: false }, + { name: "Sarah", age: 22, isCool: true }, + { name: "Lee", age: 23, isCool: true } + ], + options: { + toCellText(v) { + if (v === true) return "Yes" + if (v === false) return "No" + return String(v) + } + }, + expected: + [ + "| Name | Age | Is cool |", + "| :---- | :---- | :------ |", + "| Bob | 21 | No |", + "| Sarah | 22 | Yes |", + "| Lee | 23 | Yes |" + ].join(EOL) + EOL + }, + wrap: { + input: [ + { name: "Benjamin", age: 21, isCool: false }, + { name: "Sarah", age: 22, isCool: true }, + { name: "Lee", age: 23, isCool: true } + ], + options: { + wrapWidth: 5 + }, + expected: + [ + // headers wrap, soft wrap + "| Name | Age | Is |", + " cool ", + "| :---- | :---- | :---- |", + // hard wrap + "| Benja | 21 | false |", + " min ", + // no wrap + "| Sarah | 22 | true |", + "| Lee | 23 | true |" + ].join(EOL) + EOL + }, + newlines: { + input: [ + { name: "Benjamin\nor Ben", age: 21, isCool: false }, + { name: "Sarah", age: 22, isCool: true }, + { name: "Lee", age: 23, isCool: true } + ], + expected: + [ + "| Name | Age | Is cool |", + "| :------- | :---- | :------ |", + "| Benjamin | 21 | false |", + " or Ben ", + "| Sarah | 22 | true |", + "| Lee | 23 | true |" + ].join(EOL) + EOL + }, + wrapAndNewlines: { + input: [ + { name: "Benjamin or\nBen", age: 21, isCool: false }, + { name: "Sarah", age: 22, isCool: true }, + { name: "Lee", age: 23, isCool: true } + ], + options: { + wrapWidth: 8 + }, + expected: + [ + "| Name | Age | Is cool |", + "| :------- | :---- | :------ |", + "| Benjamin | 21 | false |", + " or ", + " Ben ", + "| Sarah | 22 | true |", + "| Lee | 23 | true |" + ].join(EOL) + EOL + }, + gutters: { + input: [ + { name: "Benjamin", age: 21, isCool: false }, + { name: "Sarah", age: 22, isCool: true }, + { name: "Lee", age: 23, isCool: true } + ], + options: { + wrapWidth: 5, + wrapWithGutters: true + }, + expected: + [ + "| Name | Age | Is |", + "| | | cool |", + "| :---- | :---- | :---- |", + "| Benja | 21 | false |", + "| min | | |", + "| Sarah | 22 | true |", + "| Lee | 23 | true |" + ].join(EOL) + EOL + }, + pipes: { + input: [{ content: "yes | no" }], + expected: + ["| Content |", "| :-------- |", "| yes \\| no |"].join(EOL) + EOL + } +} + +export default testCases 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/tests/utilities.ts b/tests/utilities.ts new file mode 100644 index 0000000..06e040e --- /dev/null +++ b/tests/utilities.ts @@ -0,0 +1,27 @@ +import test from "ava" + +import { alignmentOptions } from "../dist/index.js" +import * as utilites from "../dist/utilities.js" + +test("pad: left alignment", t => { + t.is(utilites.pad(alignmentOptions.left, 2, "foo"), "foo") + t.is(utilites.pad(alignmentOptions.left, 6, "foo"), "foo ") +}) + +test("pad: right alignment", t => { + t.is(utilites.pad(alignmentOptions.right, 2, "foo"), "foo") + t.is(utilites.pad(alignmentOptions.right, 6, "foo"), " foo") +}) + +test("pad: center alignment", t => { + t.is(utilites.pad(alignmentOptions.center, 2, "foo"), "foo") + t.is(utilites.pad(alignmentOptions.center, 6, "foo"), " foo ") + t.is(utilites.pad(alignmentOptions.center, 7, "foo"), " foo ") + t.is(utilites.pad(alignmentOptions.center, 8, "hi"), " hi ") +}) + +test("toCellText: renders its argument as a string suitable for a table cell", t => { + t.is(utilites.toCellText(undefined), "") + t.is(utilites.toCellText(3), "3") + t.is(utilites.toCellText("|"), "\\|") +}) diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..bac1dce --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "module": "es2020", + "target": "es2020", + "moduleResolution": "node", + "strict": true, + "declaration": true, + "outDir": "dist" + }, + "include": [ + "src", + "declarations.d.ts" + ] +}