UNPKG

@felisdiligens/md-table-tools

Version:

MultiMarkdown table tools

289 lines (244 loc) 11.3 kB
import { Table, TableCaption, TableCaptionPosition, TableCell, TableCellMerge, TableColumn, TableRow, TextAlignment } from "./table.js"; import { ParsingError, TableParser } from "./tableParser.js"; import { TableRenderer } from "./tableRenderer.js"; import stringWidth from "string-width"; /* Specification: https://github.github.com/gfm/#tables-extension- */ const rowRegex = /^\|(.+)\|$/ const delimiterRowRegex = /^\|(\s*:?\-+:?\s*\|)+$/; enum ParsingState { BeforeTable, HeaderRow, DelimiterRow, DataRows, AfterTable } export class GitHubFlavoredMarkdownTableParser implements TableParser { public parse(table: string): Table { let parsedTable = new Table(); let state = ParsingState.BeforeTable; let hasDelimiterRow = false; let beforeTable = []; let afterTable = []; // Now parse line by line: for (let line of table.split("\n")) { /* Determine parsing state and prepare: */ // Check if we are in the table: if (state == ParsingState.BeforeTable && line.match(/[^|\\`]\|/g)) { state = ParsingState.HeaderRow; } // The table is broken at the first empty line, or beginning of another block-level structure: if (line.trim() === "" || line.trim().startsWith("> ")){ state = ParsingState.AfterTable; } // If not inside table: if (state == ParsingState.BeforeTable) { beforeTable.push(line); continue; // Skip the rest } else if (state == ParsingState.AfterTable) { afterTable.push(line); continue; // Skip the rest } // Format table line: line = line.trim(); if (!line.startsWith("|")) line = "|" + line; if (!line.endsWith("|") || (line.charAt(line.length - 3) != "\\" && line.endsWith("\\|"))) // Check if last pipe is escaped ('\|') line = line + "|"; if (!line.match(rowRegex)) throw new ParsingError(`Invalid row: ${line}`); // Is delimiter row too early? if (state == ParsingState.HeaderRow && line.match(delimiterRowRegex)) { throw new ParsingError("Header row missing."); } /* Parse line depending on parsing state: */ if (state == ParsingState.HeaderRow || state == ParsingState.DataRows) { let tableRow = new TableRow(); tableRow.isHeader = state == ParsingState.HeaderRow; parsedTable.addRow(-1, tableRow); // Parse each character: let cellContent = ""; let colIndex = 0; let slashEscaped = false; let fenceEscaped = false; for (let char of line.substring(1, line.length)) { if (!slashEscaped && !fenceEscaped && char == "|") { // Ignore excess cells: if (state == ParsingState.HeaderRow || colIndex < parsedTable.columnCount()) { let tableColumn = parsedTable.getColumn(colIndex); if (!tableColumn) tableColumn = parsedTable.addColumn(); let cell = new TableCell(parsedTable, tableRow, tableColumn); parsedTable.addCell(cell); cell.setText( cellContent .trim() .replace(/(<[bB][rR]\s*\/?>)/g, "\n") ); } cellContent = ""; colIndex++; } else if (!slashEscaped && char == "\\") { slashEscaped = true; } else { if (!slashEscaped && char == "\`") fenceEscaped = !fenceEscaped; if (slashEscaped) cellContent += "\\"; cellContent += char; slashEscaped = false; } } // Insert empty cells if missing: for (; colIndex < parsedTable.columnCount(); colIndex++) { let cell = new TableCell(parsedTable, tableRow, parsedTable.getColumn(colIndex)); parsedTable.addCell(cell); } // If the header row has been parsed, parse the delimiter row next: if (state == ParsingState.HeaderRow) state = ParsingState.DelimiterRow; } else if (state == ParsingState.DelimiterRow) { if (!line.match(delimiterRowRegex)) throw new ParsingError("Invalid delimiter row"); hasDelimiterRow = true; let colIndex = 0; let alignment = TextAlignment.default; let separator = false; for (let char of line.substring(1, line.length)) { if (char == "|") { let tableColumn = parsedTable.getColumn(colIndex); if (!tableColumn) throw new ParsingError("Header row doesn't match the delimiter row in the number of cells."); tableColumn.textAlign = alignment; alignment = TextAlignment.default; separator = false; colIndex++; } else if (char == ":") { if (!separator) { alignment = TextAlignment.left; } else { if (alignment == TextAlignment.left) alignment = TextAlignment.center; else alignment = TextAlignment.right; } } else if (char == "-") { separator = true; if (alignment == TextAlignment.right) throw new ParsingError("Invalid delimiter row (minus sign after colon)"); } else if (!char.match(/\s/g)) { throw new ParsingError(`Unexpected character in delimiter row: '${char}'`); } } if (colIndex < parsedTable.columnCount()) { throw new ParsingError("Header row doesn't match the delimiter row in the number of cells."); } // Once the delimiter row has been parsed, parse the data rows next: state = ParsingState.DataRows; } else { throw new ParsingError(`Not implemented ParsingState: ${state}`); } } if (!hasDelimiterRow) throw new ParsingError("No delimiter row found."); parsedTable.beforeTable = beforeTable.join("\n"); parsedTable.afterTable = afterTable.join("\n"); return parsedTable.update(); } } export class GitHubFlavoredMarkdownTableRenderer implements TableRenderer { public constructor( public prettify = true, public renderOutsideTable = true) { } public render(table: Table): string { const headerRow = table.getHeaderRows()[0]; const dataRows = table.getNormalRows(); const columnWidths: number[] = this.prettify ? this.determineColumnWidths(table) : null; let result: string[] = []; if (this.renderOutsideTable && table.beforeTable.trim() !== "") result.push(table.beforeTable); // Header row: result.push(this.renderRow(table, headerRow, columnWidths)); // Delimiter row: result.push(this.renderDelimiterRow(table, columnWidths)); // Data rows: for (const row of dataRows) result.push(this.renderRow(table, row, columnWidths)); if (this.renderOutsideTable && table.afterTable.trim() !== "") result.push(table.afterTable); return result.join("\n"); } private renderDelimiterRow(table: Table, columnWidths: number[]): string { let result: string[] = []; table.getColumns().forEach((col, i) => { let width = this.prettify ? columnWidths[i] : null; switch (col.textAlign) { case TextAlignment.left: result.push(this.prettify ? `:${"-".repeat(width + 1)}` : ":-"); break; case TextAlignment.center: result.push(this.prettify ? `:${"-".repeat(width)}:` : ":-:"); break; case TextAlignment.right: result.push(this.prettify ? `${"-".repeat(width + 1)}:` : "-:"); break; case TextAlignment.default: default: result.push(this.prettify ? "-".repeat(width + 2) : "-"); break; } }); if (this.prettify) return `|${result.join("|")}|`; else return result.join("|"); } private renderRow(table: Table, row: TableRow, columnWidths: number[]): string { let result: string[] = []; row.getCells().forEach((cell, i) => { result.push(this.renderCell(cell, this.prettify ? columnWidths[i] : null)); if (!this.prettify && i == row.getCells().length - 1 && cell.text.trim() == "") result.push(""); }); if (this.prettify) return `|${result.join("|")}|`; else return result.join("|"); } private renderCell(cell: TableCell, cellWidth: number = -1): string { let text = cell.text.replace(/\r?\n/g, "<br>"); if (!this.prettify){ return text; } const textLength = stringWidth(text); switch (cell.column.textAlign) { case TextAlignment.center: return `${" ".repeat(Math.max(0, Math.floor((cellWidth - textLength) / 2)))} ${text} ${" ".repeat(Math.max(0, Math.ceil((cellWidth - textLength) / 2)))}`; case TextAlignment.right: return `${" ".repeat(Math.max(0, cellWidth - textLength))} ${text} `; case TextAlignment.left: case TextAlignment.default: default: return ` ${text} ${" ".repeat(Math.max(0, cellWidth - textLength))}`; } } private determineColumnWidth(table: Table, column: TableColumn): number { let width = 0; for (const cell of table.getCellsInColumn(column)) { const cellTextLength = stringWidth(cell.text.replace(/\r?\n/g, "<br>")); width = Math.max(cellTextLength, width); } return width; } private determineColumnWidths(table: Table): number[] { return table.getColumns().map(column => this.determineColumnWidth(table, column)); } }