@felisdiligens/md-table-tools
Version:
MultiMarkdown table tools
289 lines (244 loc) • 11.3 kB
text/typescript
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));
}
}