@adobe/micromark-extension-gridtables
Version:
Micromark Extension Gridtables
356 lines (327 loc) • 9.83 kB
JavaScript
/*
* Copyright 2022 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/
/* eslint-disable no-underscore-dangle */
import { micromark } from 'micromark';
import {
TYPE_BODY, TYPE_CELL, TYPE_HEADER, TYPE_FOOTER, TYPE_TABLE, TYPE_ROW_LINE, TYPE_GRID_DIVIDER,
} from './types.js';
/**
* @typedef {import('micromark-util-types').HtmlExtension} HtmlExtension
* @typedef {import('./syntax.js').Align} Align
*/
function multiline(lines) {
// remove empty trailing lines
while (lines.length > 0 && lines[lines.length - 1].match(/^\s*$/)) {
lines.pop();
}
// calculate common indent
const prefixLen = lines
.filter((line) => !line.match(/^\s*$/))
.map((line) => line.match(/^ */)[0].length)
.reduce((min, len) => Math.min(len, min), Infinity);
// remove prefix
return lines
.map((line) => line.substring(prefixLen).trimEnd());
}
function getColSpan(info, token) {
const i0 = info.cols.indexOf(token._colStart);
const i1 = info.cols.indexOf(token._colEnd);
return i1 - i0;
}
function enterTable(token) {
this.lineEndingIfNeeded();
this.tag('<table>');
this.buffer();
this.setData('tableInfo', {
// the column positions of the table
cols: token._cols,
// the current column
colPos: 0,
// list of all cells
allCells: [],
// cells that are still open via rowSpan
pendingCells: [],
// the current cells of a row
cells: [],
// the grid dividers use for align the cells
dividers: [],
// the link/image reference definitions
definitions: token._definitions,
// the tables rows
rows: [],
// the current section type (header, body, footer)
type: 'tbody',
});
}
/**
* Extension that unescapes pipes and plusses in inine and block code
* @type HtmlExtension
*/
const unescapeCodeExtension = {
exit: {
codeTextData(token) {
let value = this.sliceSerialize(token);
value = value.replace(/\\([+|])/gm, '$1');
this.raw(this.encode(value));
},
codeFlowValue(token) {
unescapeCodeExtension.exit.codeTextData.call(this, token);
this.setData('flowCodeSeenData', true);
},
},
};
function exitTable() {
// render cells
this.resume();
const info = this.getData('tableInfo');
for (const cell of info.allCells) {
const sanitizedLines = multiline(cell.lines);
// remove trailing `\` on lines followed by empty line. we assume that proper backslashes are
// always escaped inside gridtables. also see https://github.com/micromark/micromark/issues/118
for (let i = 0; i < sanitizedLines.length; i += 1) {
const line = sanitizedLines[i];
if (line.endsWith('\\') && !sanitizedLines[i + 1]) {
let idx = line.length - 1;
while (idx >= 0 && line[idx] === '\\') {
idx -= 1;
}
// only remove if odd number of backslashes
if ((line.length - idx + 2) % 2 === 0) {
if (line.length === 1) {
sanitizedLines.splice(i, 1);
i -= 1;
} else {
sanitizedLines[i] = line.substring(0, line.length - 1);
}
}
}
}
// add the definitions from the main document maybe they could be injected dynamically...
const definitions = this.getData('definitions');
if (definitions) {
for (const def of Object.values(definitions)) {
const key = `[${def.labelId}]`;
if (sanitizedLines.find((line) => line.indexOf(key) >= 0)) {
const title = def.title ? ` ${JSON.stringify(def.title)}` : '';
sanitizedLines.push('');
sanitizedLines.push(`${key}: ${def.destination}${title}`);
}
}
}
const cellContent = sanitizedLines.join('\n');
// add extension to unescape pipes and plusses since the content is considered in a table
let { htmlExtensions } = this.options;
if (htmlExtensions.indexOf(unescapeCodeExtension) < 0) {
htmlExtensions = [
...this.options.htmlExtensions,
unescapeCodeExtension,
];
}
let html = micromark(cellContent, {
...this.options,
htmlExtensions,
}).trim();
// remove surrounding <p> if it's the only one
const first = html.lastIndexOf('<p>');
const last = html.indexOf('</p>');
if (first === 0 && last === html.length - 4) {
html = html.substring(3, last);
}
cell.html = html;
}
let type = '';
let indent = 4;
for (const row of info.rows) {
if (type !== row.type) {
if (type) {
indent -= 4;
this.lineEndingIfNeeded();
this.raw(' '.repeat(indent));
this.tag(`</${type}>`);
}
type = row.type;
this.lineEndingIfNeeded();
this.raw(' '.repeat(indent));
this.tag(`<${type}>`);
indent += 4;
}
this.lineEndingIfNeeded();
this.raw(' '.repeat(indent));
this.tag('<tr>');
indent += 4;
const tag = row.type === 'thead' ? 'th' : 'td';
for (const cell of row.cells) {
const {
colSpan, rowSpan, align, valign,
} = cell;
const attrs = [];
if (colSpan > 1) {
attrs.push(`colspan="${colSpan}"`);
}
if (rowSpan > 1) {
attrs.push(`rowspan="${rowSpan}"`);
}
if (align) {
attrs.push(`align="${align}"`);
}
if (valign) {
attrs.push(`valign="${valign}"`);
}
const attrStr = attrs.length ? ` ${attrs.join(' ')}` : '';
this.lineEndingIfNeeded();
this.raw(' '.repeat(indent));
this.tag(`<${tag}${attrStr}>`);
if (cell.html.startsWith('<') && cell.html.indexOf('<pre>') < 0) {
// indent all html
const html = cell.html.split('\n').join(`\n${' '.repeat(indent + 4)}`);
this.lineEndingIfNeeded();
this.raw(' '.repeat(indent + 4));
this.raw(html);
this.lineEndingIfNeeded();
this.raw(' '.repeat(indent));
} else {
this.raw(cell.html);
}
this.tag(`</${tag}>`);
}
this.lineEndingIfNeeded();
indent -= 4;
this.raw(' '.repeat(indent));
this.tag('</tr>');
}
// this.exit(token);
if (type) {
this.lineEndingIfNeeded();
indent -= 4;
this.raw(' '.repeat(indent));
this.tag(`</${type}>`);
}
this.lineEndingIfNeeded();
this.tag('</table>');
}
function enterSection(type) {
return function enter() {
const info = this.getData('tableInfo');
info.type = type;
};
}
function exitCell(token) {
// this.config.enter.data.call(this, token);
// this.config.exit.data.call(this, token);
const data = this.sliceSerialize(token); // this.resume();
const info = this.getData('tableInfo');
const colSpan = getColSpan(info, token);
let cell = info.pendingCells[info.colPos];
// open rowspan if we are on a divider line
if (info.isDivider) {
if (!cell) {
cell = info.cells[info.colPos];
info.pendingCells[info.colPos] = cell;
}
if (!cell) {
// throw Error('no matching rowspan');
} else {
cell.rowSpan += 1;
}
}
// if a rowspan is open, append to its cell
if (cell) {
cell.lines.push(data);
info.colPos += colSpan;
return;
}
// otherwise append to regular cell
cell = info.cells[info.colPos];
if (!cell) {
const div = info.dividers[info.colPos];
cell = {
rowSpan: 1,
colSpan,
align: div?._align,
valign: div?._valign,
node: {
type: TYPE_CELL,
},
lines: [],
};
info.cells[info.colPos] = cell;
info.allCells.push(cell);
}
cell.lines.push(data);
info.colPos += colSpan;
}
function enterGridDivider(token) {
const info = this.getData('tableInfo');
// clear pending rowspans and set divider info
let colSpan = getColSpan(info, token);
while (colSpan > 0) {
colSpan -= 1;
info.pendingCells[info.colPos] = null;
info.dividers[info.colPos] = token;
info.colPos += 1;
}
}
function enterRowLine(token) {
const info = this.getData('tableInfo');
info.isDivider = token._type;
info.colPos = 0;
if (info.isDivider) {
info.dividers = [];
}
}
function commitRow(info) {
const row = { type: info.type, cells: [] };
// emit cells
for (const cell of info.cells) {
if (cell) {
row.cells.push(cell);
}
}
// this.exit(rowToken);
this.getData('tableInfo').rows.push(row);
// eslint-disable-next-line no-param-reassign
info.cells = [];
}
function exitHeader() {
const info = this.getData('tableInfo');
// commit row has some cells
if (info.cells.length) {
commitRow.call(this, info);
// also close all rowspans.
info.pendingCells = [];
}
}
function exitRowLine() {
const info = this.getData('tableInfo');
// commit row if on a divider and has some cells
if (info.isDivider && info.cells.length) {
commitRow.call(this, info);
}
}
export const gridTablesHtml = {
enter: {
[TYPE_TABLE]: enterTable,
[TYPE_HEADER]: enterSection('thead'),
[TYPE_BODY]: enterSection('tbody'),
[TYPE_FOOTER]: enterSection('tfoot'),
[TYPE_GRID_DIVIDER]: enterGridDivider,
[TYPE_ROW_LINE]: enterRowLine,
},
exit: {
[TYPE_TABLE]: exitTable,
[TYPE_HEADER]: exitHeader,
[TYPE_BODY]: exitHeader,
[TYPE_FOOTER]: exitHeader,
[TYPE_CELL]: exitCell,
[TYPE_ROW_LINE]: exitRowLine,
},
};