UNPKG

remark-grid-tables

Version:

This plugin parses custom Markdown syntax to describe tables. It was inspired by [this syntax](https://github.com/smartboyathome/Markdown-GridTables/blob/b4d16d5d254bed4336713d27eb8a37dc0e5f4273/mdx_grid_tables.py).

826 lines (699 loc) 22.2 kB
const trimEnd = require('lodash.trimend') const visit = require('unist-util-visit') const stringWidth = require('string-width') const splitter = new (require('grapheme-splitter'))() const mainLineRegex = /((\+)|(\|)).+((\|)|(\+))/ const totalMainLineRegex = /^((\+)|(\|)).+((\|)|(\+))$/ const headerLineRegex = /^\+=[=+]+=\+$/ const partLineRegex = /\+-[-+]+-\+/ const separationLineRegex = /^\+-[-+]+-\+$/ module.exports = plugin // A small class helping table generation class Table { constructor (linesInfos) { this._parts = [] this._linesInfos = linesInfos this.addPart() } lastPart () { return this._parts[this._parts.length - 1] } addPart () { this._parts.push(new TablePart(this._linesInfos)) } } class TablePart { constructor (linesInfos) { this._rows = [] this._linesInfos = linesInfos this.addRow() } addRow () { this._rows.push(new TableRow(this._linesInfos)) } removeLastRow () { this._rows.pop() } lastRow () { return this._rows[this._rows.length - 1] } updateWithMainLine (line, isEndLine) { // Update last row according to a line. const mergeChars = isEndLine ? '+|' : '|' const newCells = [this.lastRow()._cells[0]] for (let c = 1; c < this.lastRow()._cells.length; c++) { const cell = this.lastRow()._cells[c] // Only cells with rowSpan equals can be merged // Test if the char does not compose a character // or the char before the cell is a separation character if (cell._rowSpan === newCells[newCells.length - 1]._rowSpan && ( !isCodePointPosition(line, cell._startPosition - 1) || !mergeChars.includes(substringLine(line, cell._startPosition - 1)) )) { newCells[newCells.length - 1].mergeWith(cell) } else { newCells.push(cell) } } this.lastRow()._cells = newCells } updateWithPartLine (line) { // Get cells not finished const remainingCells = [] for (let c = 0; c < this.lastRow()._cells.length; c++) { const cell = this.lastRow()._cells[c] const partLine = substringLine(line, cell._startPosition - 1, cell._endPosition + 1) if (!isSeparationLine(partLine)) { cell._lines.push(substringLine(line, cell._startPosition, cell._endPosition)) cell._rowSpan += 1 remainingCells.push(cell) } } // Generate new row this.addRow() const newCells = [] for (let c = 0; c < remainingCells.length; c++) { const remainingCell = remainingCells[c] for (let cc = 0; cc < this.lastRow()._cells.length; cc++) { const cell = this.lastRow()._cells[cc] if (cell._endPosition < remainingCell._startPosition && !newCells.includes(cell)) { newCells.push(cell) } } newCells.push(remainingCell) for (let cc = 0; cc < this.lastRow()._cells.length; cc++) { const cell = this.lastRow()._cells[cc] if (cell._startPosition > remainingCell._endPosition && !newCells.includes(cell)) { newCells.push(cell) } } } // Remove duplicates for (let nc = 0; nc < newCells.length; nc++) { let newCell = newCells[nc] for (let ncc = 0; ncc < newCells.length; ncc++) { if (nc !== ncc) { const other = newCells[ncc] if (other._startPosition >= newCell._startPosition && other._endPosition <= newCell._endPosition) { if (other._lines.length === 0) { newCells.splice(ncc, 1) ncc -= 1 if (nc > ncc) { nc -= 1 newCell = newCells[nc] } } } } } } this.lastRow()._cells = newCells } } class TableRow { constructor (linesInfos) { this._linesInfos = linesInfos this._cells = [] for (let i = 0; i < linesInfos.length - 1; i++) { this._cells.push(new TableCell(linesInfos[i] + 1, linesInfos[i + 1])) } } updateContent (line) { for (let c = 0; c < this._cells.length; c++) { const cell = this._cells[c] cell._lines.push(substringLine(line, cell._startPosition, cell._endPosition)) } } } class TableCell { constructor (startPosition, endPosition) { this._startPosition = startPosition this._endPosition = endPosition this._colSpan = 1 this._rowSpan = 1 this._lines = [] } mergeWith (other) { this._endPosition = other._endPosition this._colSpan += other._colSpan const newLines = [] for (let l = 0; l < this._lines.length; l++) { newLines.push(`${this._lines[l]}|${other._lines[l]}`) } this._lines = newLines } } function merge (beforeTable, gridTable, afterTable) { // get the eaten text let total = beforeTable.join('\n') if (total.length) { total += '\n' } total += gridTable.join('\n') if (afterTable.join('\n').length) { total += '\n' } total += afterTable.join('\n') return total } function isSeparationLine (line) { return separationLineRegex.exec(line) } function isHeaderLine (line) { return headerLineRegex.exec(line) } function isPartLine (line) { return partLineRegex.exec(line) } function findAll (str, characters) { let current = 0 const pos = [] const content = splitter.splitGraphemes(str) for (let i = 0; i < content.length; i++) { const char = content[i] if (characters.includes(char)) { pos.push(current) } current += stringWidth(char) } return pos } function computePlainLineColumnsStartingPositions (line) { return findAll(line, '+|') } function mergeColumnsStartingPositions (allPos) { // Get all starting positions, allPos is an array of array of positions const positions = [] allPos.forEach((posRow) => posRow.forEach((pos) => { if (!positions.includes(pos)) { positions.push(pos) } })) return positions.sort((a, b) => a - b) } function computeColumnStartingPositions (lines) { const linesInfo = [] lines.forEach((line) => { if (isHeaderLine(line) || isPartLine(line)) { linesInfo.push(computePlainLineColumnsStartingPositions(line)) } }) return mergeColumnsStartingPositions(linesInfo) } function isCodePointPosition (line, pos) { const content = splitter.splitGraphemes(line) let offset = 0 for (let i = 0; i < content.length; i++) { // The pos points character position if (pos === offset) { return true } // The pos points non-character position if (pos < offset) { return false } offset += stringWidth(content[i]) } // Reaching end means character position return true } function substringLine (line, start, end) { end = end || start + 1 const content = splitter.splitGraphemes(line) let offset = 0 let str = '' for (let i = 0; i < content.length; i++) { if (offset >= start) { str += content[i] } offset += stringWidth(content[i]) if (offset >= end) { break } } return str } function extractTable (value, eat, tokenizer) { // Extract lines before the grid table const markdownLines = value .split('\n') let i = 0 const before = [] for (; i < markdownLines.length; i++) { const line = markdownLines[i] if (isSeparationLine(line)) break if (stringWidth(line) === 0) break before.push(line) } const possibleGridTable = markdownLines .map(line => trimEnd(line)) // Extract table if (!possibleGridTable[i + 1]) return [null, null, null, null] const gridTable = [] const realGridTable = [] let hasHeader = false for (; i < possibleGridTable.length; i++) { const line = possibleGridTable[i] const realLine = markdownLines[i] // line is in table if (totalMainLineRegex.exec(line)) { const isHeaderLine = headerLineRegex.exec(line) if (isHeaderLine && !hasHeader) hasHeader = true // A table can't have 2 headers else if (isHeaderLine && hasHeader) { break } realGridTable.push(realLine) gridTable.push(line) } else { // this line is not in the grid table. break } } // if the last line is not a plain line if (!separationLineRegex.exec(gridTable[gridTable.length - 1])) { // Remove lines not in the table for (let j = gridTable.length - 1; j >= 0; j--) { const isSeparation = separationLineRegex.exec(gridTable[j]) if (isSeparation) break gridTable.pop() i -= 1 } } // Extract lines after table const after = [] for (; i < possibleGridTable.length; i++) { const line = possibleGridTable[i] if (stringWidth(line) === 0) break after.push(markdownLines[i]) } return [before, gridTable, realGridTable, after, hasHeader] } function extractTableContent (lines, linesInfos, hasHeader) { const table = new Table(linesInfos) for (let l = 0; l < lines.length; l++) { const line = lines[l] // Get if the line separate the head of the table from the body const matchHeader = hasHeader & isHeaderLine(line) !== null // Get if the line close some cells const isEndLine = matchHeader | isPartLine(line) !== null if (isEndLine) { // It is a header, a plain line or a line with plain line part. // First, update the last row table.lastPart().updateWithMainLine(line, isEndLine) // Create the new row if (l !== 0) { if (matchHeader) { table.addPart() } else if (isSeparationLine(line)) { table.lastPart().addRow() } else { table.lastPart().updateWithPartLine(line) } } // update the last row table.lastPart().updateWithMainLine(line, isEndLine) } else { // it's a plain line table.lastPart().updateWithMainLine(line, isEndLine) table.lastPart().lastRow().updateContent(line) } } // Because the last line is a separation, the last row is always empty table.lastPart().removeLastRow() return table } function processCellLines (lines) { const trimmedLines = [] let inCodeBlock = false let leadingWhitespace = 0 let leadingWhitespaceRegex = null for (let i = 0; i < lines.length; i++) { let line = lines[i] // Trim the end of the line line = line.trimEnd() // Check if we're entering or exiting a code block if (line.trim().startsWith('```')) { inCodeBlock = !inCodeBlock // If we're entering a code block, remember the amount of leading whitespace if (inCodeBlock) { // Set how much whitespace to remoev from lines // inside the code-block leadingWhitespace = line.match(/^ */)[0].length leadingWhitespaceRegex = new RegExp(`^ {0,${leadingWhitespace}}`) } // Remove leading whitespace from the opening/closing code-block // statement as well line = line.replace(leadingWhitespaceRegex, '') } else if (inCodeBlock) { // If we're *already* in a code block, trim the start of the line // by the amount of leading whitespace line = line.replace(leadingWhitespaceRegex, '') } else { // We're not in a code block, trim the start of the line line = line.trimStart() } // Replace the line in the array trimmedLines.push(line) } return trimmedLines } function generateTable (tableContent, now, tokenizer) { // Generate the gridTable node to insert in the AST const tableElt = { type: 'gridTable', children: [], data: { hName: 'table' } } const hasHeader = tableContent._parts.length > 1 for (let p = 0; p < tableContent._parts.length; p++) { const part = tableContent._parts[p] const partElt = { type: 'tableHeader', children: [], data: { hName: (hasHeader && p === 0) ? 'thead' : 'tbody' } } for (let r = 0; r < part._rows.length; r++) { const row = part._rows[r] const rowElt = { type: 'tableRow', children: [], data: { hName: 'tr' } } for (let c = 0; c < row._cells.length; c++) { const cell = row._cells[c] const trimmedLines = processCellLines(cell._lines) const tokenizedContent = tokenizer.tokenizeBlock( trimmedLines.join('\n'), now ) const cellElt = { type: 'tableCell', children: tokenizedContent, data: { hName: (hasHeader && p === 0) ? 'th' : 'td', hProperties: { colSpan: cell._colSpan, rowSpan: cell._rowSpan } } } const endLine = r + cell._rowSpan if (cell._rowSpan > 1 && endLine - 1 < part._rows.length) { for (let rs = 1; rs < cell._rowSpan; rs++) { for (let cc = 0; cc < part._rows[r + rs]._cells.length; cc++) { const other = part._rows[r + rs]._cells[cc] if (cell._startPosition === other._startPosition && cell._endPosition === other._endPosition && cell._colSpan === other._colSpan && cell._rowSpan === other._rowSpan && cell._lines === other._lines) { part._rows[r + rs]._cells.splice(cc, 1) } } } } rowElt.children.push(cellElt) } partElt.children.push(rowElt) } tableElt.children.push(partElt) } return tableElt } function gridTableTokenizer (eat, value, silent) { let index = 0 const length = value.length let character while (index < length) { character = value.charAt(index) if (character !== ' ' && character !== '\t') { break } index++ } if (value.charAt(index) !== '+') { return } if (value.charAt(index + 1) !== '-') { return } const keep = mainLineRegex.test(value) if (!keep) return const [before, gridTable, realGridTable, after, hasHeader] = extractTable(value, eat, this) if (!gridTable || gridTable.length < 3) return const now = eat.now() const linesInfos = computeColumnStartingPositions(gridTable) const tableContent = extractTableContent(gridTable, linesInfos, hasHeader) const tableElt = generateTable(tableContent, now, this) const merged = merge(before, realGridTable, after) // Because we can't add multiples blocs in one eat, I use a temp block const wrapperBlock = { type: 'element', tagName: 'WrapperBlock', children: [] } if (before.length) { const tokensBefore = this.tokenizeBlock(before.join('\n'), now)[0] wrapperBlock.children.push(tokensBefore) } wrapperBlock.children.push(tableElt) if (after.length) { const tokensAfter = this.tokenizeBlock(after.join('\n'), now) if (tokensAfter.length) { wrapperBlock.children.push(tokensAfter[0]) } } return eat(merged)(wrapperBlock) } function deleteWrapperBlock () { function one (node, index, parent) { if (!node.children) return const newChildren = [] let replace = false for (let c = 0; c < node.children.length; c++) { const child = node.children[c] if (child.tagName === 'WrapperBlock' && child.type === 'element') { replace = true for (let cc = 0; cc < child.children.length; cc++) { newChildren.push(child.children[cc]) } } else { newChildren.push(child) } } if (replace) { node.children = newChildren } } return one } function transformer (tree) { // Remove the temporary block in which we previously wrapped the table parts visit(tree, deleteWrapperBlock()) } function createGrid (nbRows, nbCols) { const grid = [] for (let i = 0; i < nbRows; i++) { grid.push([]) for (let j = 0; j < nbCols; j++) { grid[i].push({ height: -1, width: -1, hasBottom: true, hasRigth: true }) } } return grid } function setWidth (grid, i, j, cols) { /* To do it, we put enougth space to write the text. * For multi-cell, we divid it among the cells. */ let tmpWidth = Math.max(...Array.from(grid[i][j].value).map(x => x.length)) + 2 grid[i].forEach((_, c) => { if (c < cols) { // To divid const localWidth = Math.ceil(tmpWidth / (cols - c)) // cols - c will be 1 for the last cell tmpWidth -= localWidth grid[i][j + c].width = localWidth } }) } function setHeight (grid, i, j, values) { // To do it, we count the line. Extra length to cell with a pipe // in the value of the last line, to not be confuse with a border. grid[i][j].height = values.length // Extra line if (values[values.length - 1].indexOf('|') > 0) { grid[i][j].height += 1 } } function extractAST (gridNode, grid) { let i = 0 /* Fill the grid with value, height and width from the ast */ gridNode.children.forEach(th => { th.children.forEach(row => { row.children.forEach((cell, j) => { let X = 0 // x taking colSpan and rowSpan into account while (grid[i][j + X].evaluated) X++ grid[i][j + X].value = this.all(cell).join('\n\n').split('\n') setHeight(grid, i, j + X, grid[i][j + X].value) setWidth(grid, i, j + X, cell.data.hProperties.colSpan) // If it's empty, we fill it up with a useless space // Otherwise, it will not be parsed. if (!grid[0][0].value.join('\n')) { grid[0][0].value = [' '] grid[0][0].width = 3 } // Define the border of each cell for (let x = 0; x < cell.data.hProperties.rowSpan; x++) { for (let y = 0; y < cell.data.hProperties.colSpan; y++) { // b attribute is for bottom grid[i + x][j + X + y].hasBottom = x + 1 === cell.data.hProperties.rowSpan // r attribute is for right grid[i + x][j + X + y].hasRigth = y + 1 === cell.data.hProperties.colSpan // set v if a cell has ever been define grid[i + x][j + X + y].evaluated = ' ' } } }) i++ }) }) // If they is 2 differents tableHeader, so the first one is a header and // should be underlined if (gridNode.children.length > 1) { grid[gridNode.children[0].children.length - 1][0].isHeader = true } } function setSize (grid) { // The idea is the max win // Set the height of each column grid.forEach(row => { // Find the max const maxHeight = Math.max(...row.map(cell => cell.height)) // Set it to each cell row.forEach(cell => { cell.height = maxHeight }) }) // Set the width of each row grid[0].forEach((_, j) => { // Find the max const maxWidth = Math.max(...grid.map(row => row[j].width)) // Set it to each cell grid.forEach(row => { row[j].width = maxWidth }) }) } function generateBorders (grid, nbRows, nbCols, gridString) { /** **** Create the borders *******/ // Create the first line /* * We have to create the first line manually because * we process the borders from the attributes bottom * and right of each cell. For the first line, their * is no bottom nor right cell. * * We only need the right attribute of the first row's * cells */ let first = '+' grid[0].forEach((cell, i) => { first += '-'.repeat(cell.width) first += cell.hasRigth || i === nbCols - 1 ? '+' : '-' }) gridString.push(first) grid.forEach((row, i) => { let line = '' // Cells lines // The inner of the cell line = '|' row.forEach(cell => { cell.y = gridString.length cell.x = line.length + 1 line += ' '.repeat(cell.width) line += cell.hasRigth ? '|' : ' ' }) // Add it until the text can fit for (let t = 0; t < row[0].height; t++) { gridString.push(line) } // "End" line // It's the last line of the cell. Actually the border. line = row[0].hasBottom ? '+' : '|' row.forEach((cell, j) => { let char = ' ' if (cell.hasBottom) { if (row[0].isHeader) { char = '=' } else { char = '-' } } line += char.repeat(cell.width) if (cell.hasBottom || (j + 1 < nbCols && grid[i][j + 1].hasBottom)) { if (cell.hasRigth || (i + 1 < nbRows && grid[i + 1][j].hasRigth)) { line += '+' } else { line += (row[0].isHeader ? '=' : '-') } } else if (cell.hasRigth || (i + 1 < nbRows && grid[i + 1][j].hasRigth)) { line += '|' } else { line += ' ' } }) gridString.push(line) }) } function writeText (grid, gridString) { grid.forEach(row => { row.forEach(cell => { if (cell.value && cell.value[0]) { for (let tmpCount = 0; tmpCount < cell.value.length; tmpCount++) { const tmpLine = cell.y + tmpCount const line = cell.value[tmpCount] const lineEdit = gridString[tmpLine] gridString[tmpLine] = lineEdit.substr(0, cell.x) gridString[tmpLine] += line gridString[tmpLine] += lineEdit.substr(cell.x + line.length) } } }) }) } function stringifyGridTables (gridNode) { const gridString = [] const nbRows = gridNode.children.map(th => th.children.length).reduce((a, b) => a + b) const nbCols = gridNode.children[0] .children[0] .children.map(c => c.data.hProperties.colSpan) .reduce((a, b) => a + b) const grid = createGrid(nbRows, nbCols) /* First, we extract the information * then, we set the size(2) of the border * and create it(3). * Finaly we fill it up. */ extractAST.bind(this)(gridNode, grid) setSize(grid) generateBorders(grid, nbRows, nbCols, gridString) writeText(grid, gridString) return gridString.join('\n') } function plugin () { const Parser = this.Parser // Inject blockTokenizer const blockTokenizers = Parser.prototype.blockTokenizers const blockMethods = Parser.prototype.blockMethods blockTokenizers.gridTable = gridTableTokenizer blockMethods.splice(blockMethods.indexOf('fencedCode') + 1, 0, 'gridTable') const Compiler = this.Compiler // Stringify if (Compiler) { const visitors = Compiler.prototype.visitors if (!visitors) return visitors.gridTable = stringifyGridTables } return transformer }