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).

726 lines (694 loc) 22.3 kB
"use strict"; 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; }