diff --git a/index.js b/index.js index a388476..ff2a0ce 100644 --- a/index.js +++ b/index.js @@ -1,206 +1,249 @@ 'use strict' +var repeat = require('repeat-string') + module.exports = markdownTable +var trailingWhitespace = / +$/ + // Characters. var space = ' ' var lineFeed = '\n' var dash = '-' var colon = ':' -var lowercaseC = 'c' -var lowercaseL = 'l' -var lowercaseR = 'r' var verticalBar = '|' -var minCellSize = 3 +var x = 0 +var C = 67 +var L = 76 +var R = 82 +var c = 99 +var l = 108 +var r = 114 // Create a table from a matrix of strings. function markdownTable(table, options) { var settings = options || {} - var padding = settings.padding === false ? '' : space - var between = padding + verticalBar + padding - var start = settings.delimiterStart === false ? '' : verticalBar + padding - var end = settings.delimiterEnd === false ? '' : padding + verticalBar - var alignment = settings.align - var calculateStringLength = settings.stringLength || lengthNoop - var cellCount = 0 + var padding = settings.padding !== false + var start = settings.delimiterStart !== false + var end = settings.delimiterEnd !== false + var align = (settings.align || []).concat() + var alignDelimiters = settings.alignDelimiters !== false + var alignments = [] + var stringLength = settings.stringLength || defaultStringLength var rowIndex = -1 var rowLength = table.length + var cellMatrix = [] + var sizeMatrix = [] + var row = [] var sizes = [] - var align - var rule - var rows - var row + var longestCellByColumn = [] + var mostCellsPerRow = 0 var cells - var index - var position + var columnIndex + var columnLength + var largest var size - var value - var spacing + var cell + var lines + var line var before var after + var code - alignment = alignment ? alignment.concat() : [] - + // This is a superfluous loop if we don’t align delimiters, but otherwise we’d + // do superfluous work when aligning, so optimize for aligning. while (++rowIndex < rowLength) { - row = table[rowIndex] + cells = table[rowIndex] + columnIndex = -1 + columnLength = cells.length + row = [] + sizes = [] + + if (columnLength > mostCellsPerRow) { + mostCellsPerRow = columnLength + } - index = -1 + while (++columnIndex < columnLength) { + cell = serialize(cells[columnIndex]) - if (row.length > cellCount) { - cellCount = row.length - } + if (alignDelimiters === true) { + size = stringLength(cell) + sizes[columnIndex] = size - while (++index < cellCount) { - position = row[index] ? row[index].length : null + largest = longestCellByColumn[columnIndex] - if (!sizes[index]) { - sizes[index] = minCellSize + if (largest === undefined || size > largest) { + longestCellByColumn[columnIndex] = size + } } - if (position > sizes[index]) { - sizes[index] = position - } + row.push(cell) } - } - if (typeof alignment === 'string') { - alignment = pad(cellCount, alignment).split('') + cellMatrix[rowIndex] = row + sizeMatrix[rowIndex] = sizes } - // Make sure only valid alignments are used. - index = -1 - - while (++index < cellCount) { - align = alignment[index] + // Figure out which alignments to use. + columnIndex = -1 + columnLength = mostCellsPerRow - if (typeof align === 'string') { - align = align.charAt(0).toLowerCase() + if (typeof align === 'object' && 'length' in align) { + while (++columnIndex < columnLength) { + alignments[columnIndex] = toAlignment(align[columnIndex]) } + } else { + code = toAlignment(align) - if (align !== lowercaseL && align !== lowercaseR && align !== lowercaseC) { - align = '' + while (++columnIndex < columnLength) { + alignments[columnIndex] = code } - - alignment[index] = align } - rowIndex = -1 - rows = [] - - while (++rowIndex < rowLength) { - row = table[rowIndex] - - index = -1 - cells = [] + // Inject the alignment row. + columnIndex = -1 + columnLength = mostCellsPerRow + row = [] + sizes = [] - while (++index < cellCount) { - cells[index] = stringify(row[index]) + while (++columnIndex < columnLength) { + code = alignments[columnIndex] + before = '' + after = '' + + if (code === l) { + before = colon + } else if (code === r) { + after = colon + } else if (code === c) { + before = colon + after = colon } - rows[rowIndex] = cells - } + // There *must* be at least one hyphen-minus in each alignment cell. + size = alignDelimiters + ? Math.max( + 1, + longestCellByColumn[columnIndex] - before.length - after.length + ) + : 1 - sizes = [] - rowIndex = -1 - - while (++rowIndex < rowLength) { - cells = rows[rowIndex] + cell = before + repeat(dash, size) + after - index = -1 + if (alignDelimiters === true) { + size = before.length + size + after.length - while (++index < cellCount) { - value = cells[index] - - if (!sizes[index]) { - sizes[index] = minCellSize + if (size > longestCellByColumn[columnIndex]) { + longestCellByColumn[columnIndex] = size } - size = calculateStringLength(value) - - if (size > sizes[index]) { - sizes[index] = size - } + sizes[columnIndex] = size } + + row[columnIndex] = cell } + // Inject the alignment row. + cellMatrix.splice(1, 0, row) + sizeMatrix.splice(1, 0, sizes) + rowIndex = -1 + rowLength = cellMatrix.length + lines = [] while (++rowIndex < rowLength) { - cells = rows[rowIndex] + row = cellMatrix[rowIndex] + sizes = sizeMatrix[rowIndex] + columnIndex = -1 + columnLength = mostCellsPerRow + line = [] + + while (++columnIndex < columnLength) { + cell = row[columnIndex] || '' + before = '' + after = '' + + if (alignDelimiters === true) { + size = longestCellByColumn[columnIndex] - (sizes[columnIndex] || 0) + code = alignments[columnIndex] + + if (code === r) { + before = repeat(space, size) + } else if (code === c) { + if (size % 2 === 0) { + before = repeat(space, size / 2) + after = before + } else { + before = repeat(space, size / 2 + 0.5) + after = repeat(space, size / 2 - 0.5) + } + } else { + after = repeat(space, size) + } + } - index = -1 + if (start === true && columnIndex === 0) { + line.push(verticalBar) + } - if (settings.alignDelimiters !== false) { - while (++index < cellCount) { - value = cells[index] + if ( + padding === true && + // Don’t add the opening space if we’re not aligning and the cell is + // empty: there will be a closing space. + !(alignDelimiters === false && cell === '') && + (start === true || columnIndex !== 0) + ) { + line.push(space) + } - position = sizes[index] - (calculateStringLength(value) || 0) - spacing = pad(position) + if (alignDelimiters === true) { + line.push(before) + } - if (alignment[index] === lowercaseR) { - value = spacing + value - } else if (alignment[index] === lowercaseC) { - position /= 2 + line.push(cell) - if (position % 1 === 0) { - before = position - after = position - } else { - before = position + 0.5 - after = position - 0.5 - } + if (alignDelimiters === true) { + line.push(after) + } - value = pad(before) + value + pad(after) - } else { - value += spacing - } + if (padding === true) { + line.push(space) + } - cells[index] = value + if (end === true || columnIndex !== columnLength - 1) { + line.push(verticalBar) } } - rows[rowIndex] = cells.join(between) - } + line = line.join('') - index = -1 - rule = [] - - while (++index < cellCount) { - // When `pad` is false, make the rule the same size as the first row. - if (settings.alignDelimiters === false) { - value = table[0][index] - spacing = calculateStringLength(stringify(value)) - spacing = spacing > minCellSize ? spacing : minCellSize - } else { - spacing = sizes[index] + if (end === false) { + line = line.replace(trailingWhitespace, '') } - align = alignment[index] - - // When `align` is left, don't add colons. - value = align === lowercaseR || align === '' ? dash : colon - value += pad(spacing - 2, dash) - value += align !== lowercaseL && align !== '' ? colon : dash - - rule[index] = value + lines.push(line) } - rows.splice(1, 0, rule.join(between)) - - return start + rows.join(end + lineFeed + start) + end + return lines.join(lineFeed) } -function stringify(value) { +function serialize(value) { return value === null || value === undefined ? '' : String(value) } -// Get the length of `value`. -function lengthNoop(value) { - return String(value).length +function defaultStringLength(value) { + return value.length } -// Get a string consisting of `length` `character`s. -function pad(length, character) { - return new Array(length + 1).join(character || space) +function toAlignment(value) { + var code = typeof value === 'string' ? value.charCodeAt(0) : x + + return code === L || code === l + ? l + : code === R || code === r + ? r + : code === C || code === c + ? c + : x } diff --git a/package.json b/package.json index a4aebbf..a70b976 100644 --- a/package.json +++ b/package.json @@ -1,14 +1,13 @@ { "name": "markdown-table", "version": "1.1.3", - "description": "Markdown (or fancy ASCII) tables", + "description": "Markdown tables", "license": "MIT", "keywords": [ "text", "markdown", "table", "align", - "ascii", "rows", "tabular" ], @@ -21,7 +20,9 @@ "files": [ "index.js" ], - "dependencies": {}, + "dependencies": { + "repeat-string": "^1.0.0" + }, "devDependencies": { "browserify": "^16.0.0", "chalk": "^3.0.0", @@ -60,8 +61,7 @@ "prettier": true, "esnext": false, "rules": { - "complexity": "off", - "max-depth": "off" + "complexity": "off" }, "ignores": [ "markdown-table.js" diff --git a/readme.md b/readme.md index 22e80be..19e823c 100644 --- a/readme.md +++ b/readme.md @@ -5,7 +5,7 @@ [![Downloads][downloads-badge]][downloads] [![Size][size-badge]][size] -Generate fancy [Markdown][fancy]/ASCII tables. +Generate fancy [Markdown][fancy] tables. ## Install @@ -97,15 +97,18 @@ When `true`, there is padding: When `false`, there is no padding: ```markdown -|Alpha|B| -|-|-| -|C|Delta| +|Alpha|B | +|-----|-----| +|C |Delta| ``` ###### `options.delimiterStart` Whether to begin each row with the delimiter (`boolean`, default: `true`). +Note: please don’t use this: it could create fragile structures that aren’t +understandable to some Markdown parsers. + When `true`, there are starting delimiters: ```markdown @@ -126,6 +129,9 @@ C | Delta | Whether to end each row with the delimiter (`boolean`, default: `true`). +Note: please don’t use this: it could create fragile structures that aren’t +understandable to some Markdown parsers. + When `true`, there are ending delimiters: ```markdown @@ -142,22 +148,6 @@ When `false`, there are no ending delimiters: | C | Delta ``` -###### `options.stringLength` - -Method to detect the length of a cell (`Function`, default: `s => s.length`). - -ANSI-sequences or Emoji mess up tables on terminals. -To fix this, you have to pass in a `stringLength` option to detect the “visible” -length of a cell. - -```js -var strip = require('strip-ansi') - -function stringLength(cell) { - return strip(cell).length -} -``` - ###### `options.alignDelimiters` Whether to align the delimiters (`boolean`, default: `true`). @@ -177,6 +167,58 @@ Pass `false` to make them staggered: | C | Delta | ``` +###### `options.stringLength` + +Method to detect the length of a cell (`Function`, default: `s => s.length`). + +Full-width characters and ANSI-sequences all mess up delimiter alignment +when viewing the Markdown source. +To fix this, you have to pass in a `stringLength` option to detect the “visible” +length of a cell (note that what is and isn’t visible depends on your editor). + +Without such a function, the following: + +```js +table([ + ['Alpha', 'Bravo'], + ['中文', 'Charlie'], + ['👩‍❤️‍👩', 'Delta'] +]) +``` + +Yields: + +```markdown +| Alpha | Bravo | +| - | - | +| 中文 | Charlie | +| 👩‍❤️‍👩 | Delta | +``` + +With [`string-width`][string-width]: + +```js +var width = require('string-width') + +table( + [ + ['Alpha', 'Bravo'], + ['中文', 'Charlie'], + ['👩‍❤️‍👩', 'Delta'] + ], + {stringLength: width} +) +``` + +Yields: + +```markdown +| Alpha | Bravo | +| ----- | ------- | +| 中文 | Charlie | +| 👩‍❤️‍👩 | Delta | +``` + ## Inspiration The original idea and basic implementation was inspired by James Halliday’s @@ -213,3 +255,5 @@ The original idea and basic implementation was inspired by James Halliday’s [fancy]: https://help.github.com/articles/github-flavored-markdown/#tables [text-table]: https://github.com/substack/text-table + +[string-width]: https://github.com/sindresorhus/string-width diff --git a/test.js b/test.js index f7727b8..5a25320 100644 --- a/test.js +++ b/test.js @@ -21,6 +21,29 @@ test('table()', function(t) { 'should create a table' ) + t.equal( + table([ + ['Type', 'Value'], + ['string', 'alpha'], + ['number', 1], + ['boolean', true], + ['undefined', undefined], + ['null', null], + ['Array', [1, 2, 3]] + ]), + [ + '| Type | Value |', + '| --------- | ----- |', + '| string | alpha |', + '| number | 1 |', + '| boolean | true |', + '| undefined | |', + '| null | |', + '| Array | 1,2,3 |' + ].join('\n'), + 'should serialize values' + ) + t.equal( table( [ @@ -115,7 +138,7 @@ test('table()', function(t) { 'should accept a single value' ) - t.test( + t.equal( table( [ ['Beep', 'No.', 'Boop'], @@ -166,7 +189,7 @@ test('table()', function(t) { ), [ '| Branch | Commit |', - '| ------ | ------ |', + '| - | - |', '| master | 0123456789abcdef |', '| staging | fedcba9876543210 |' ].join('\n'), @@ -184,21 +207,22 @@ test('table()', function(t) { {alignDelimiters: false} ), [ - '| A | |', - '| --- | --- |', - '| | 0123456789abcdef |', + '| A | |', + '| - | - |', + '| | 0123456789abcdef |', '| staging | fedcba9876543210 |', - '| develop | |' + '| develop | |' ].join('\n'), 'handles short rules and missing elements for tables w/o aligned delimiters' ) - t.test( + t.equal( table( [ ['Branch', 'Commit'], ['master', '0123456789abcdef'], - ['staging', 'fedcba9876543210'] + ['staging', 'fedcba9876543210'], + ['develop'] ], {delimiterStart: false} ), @@ -206,30 +230,33 @@ test('table()', function(t) { 'Branch | Commit |', '------- | ---------------- |', 'master | 0123456789abcdef |', - 'staging | fedcba9876543210 |' + 'staging | fedcba9876543210 |', + 'develop | |' ].join('\n'), 'should create rows without starting delimiter' ) - t.test( + t.equal( table( [ ['Branch', 'Commit'], ['master', '0123456789abcdef'], - ['staging', 'fedcba9876543210'] + ['staging', 'fedcba9876543210'], + ['develop'] ], {delimiterEnd: false} ), [ - '| Branch | Commit ', + '| Branch | Commit', '| ------- | ----------------', '| master | 0123456789abcdef', - '| staging | fedcba9876543210' + '| staging | fedcba9876543210', + '| develop |' ].join('\n'), 'should create rows without ending delimiter' ) - t.test( + t.equal( strip( table( [ @@ -254,7 +281,7 @@ test('table()', function(t) { '| Inverse | Strike | Hidden |', '| bar | 45 | lmno |' ].join('\n'), - 'should use `fn` to detect cell lengths' + 'should use `stringLength` to detect cell lengths' ) t.end()