mirror of https://github.com/requarks/wiki
parent
8cd2f0de43
commit
21fac3a94e
@ -0,0 +1,248 @@
|
|||||||
|
/**
|
||||||
|
* Markdown tables, written and read back.
|
||||||
|
*
|
||||||
|
* Both directions live here because they have to agree on one shape: `TableEditorOverlay` writes a table
|
||||||
|
* with `buildTable`, the markdown editor finds one with `findEditableTables` so it can offer to edit it,
|
||||||
|
* and the overlay reads it back with `parseTable`. Two definitions of "what a table looks like" would
|
||||||
|
* mean a table that came back out of the editor differing from the one that went in, in whitespace
|
||||||
|
* nobody asked to change.
|
||||||
|
*
|
||||||
|
* A table is a grid of one-line strings plus an alignment per column, which is the only formatting the
|
||||||
|
* syntax carries. Everything else a MultiMarkdown table can do -- a multi-line cell, a `^^` rowspan, a
|
||||||
|
* second body, no header at all -- has nowhere to go in that model, which is what `findEditableTables`
|
||||||
|
* is for: it offers only the tables that survive the round trip.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Narrowest a delimiter cell can be and still show its colons: `:-:`. */
|
||||||
|
const MIN_WIDTH = 3
|
||||||
|
|
||||||
|
/** Cycled through by the editor's per-column button, in this order. */
|
||||||
|
export const ALIGNMENTS = ['left', 'center', 'right']
|
||||||
|
|
||||||
|
/** A delimiter row's cell, and nothing else: dashes, with a colon at either end or both. */
|
||||||
|
const DELIMITER_CELL = /^:?-+:?$/
|
||||||
|
|
||||||
|
/** The opening or closing line of a fenced block, indented up to the three spaces markdown allows. */
|
||||||
|
const FENCE = /^ {0,3}(`{3,}|~{3,})/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A cell as it is written into a row.
|
||||||
|
*
|
||||||
|
* Its own `|` is escaped, and a newline -- which only a paste can produce -- becomes a space, because
|
||||||
|
* there is no way to write either into a table row.
|
||||||
|
*/
|
||||||
|
export function escapeCell(value) {
|
||||||
|
return (value ?? '').replaceAll('|', '\\|').replaceAll(/\s+/g, ' ').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The table as markdown.
|
||||||
|
*
|
||||||
|
* `compact` writes each cell as it is; without it every column is padded to its widest cell, which
|
||||||
|
* lines the columns up under their headers and costs a rewrite of the whole block on every edit. Either
|
||||||
|
* way the delimiter row is as wide as the column, so the two stay in step.
|
||||||
|
*/
|
||||||
|
export function buildTable({ align, rows }, { compact = true } = {}) {
|
||||||
|
const cells = rows.map((row) => align.map((_, colIndex) => escapeCell(row[colIndex])))
|
||||||
|
const widths = align.map((_, colIndex) =>
|
||||||
|
compact ? MIN_WIDTH : Math.max(MIN_WIDTH, ...cells.map((row) => row[colIndex].length))
|
||||||
|
)
|
||||||
|
const line = (row) =>
|
||||||
|
`| ${row.map((cell, i) => (compact ? cell : cell.padEnd(widths[i]))).join(' | ')} |`
|
||||||
|
const delimiters = align.map((value, i) => {
|
||||||
|
const dashes = '-'.repeat(widths[i] - (value === 'center' ? 2 : 1))
|
||||||
|
switch (value) {
|
||||||
|
case 'center': {
|
||||||
|
return `:${dashes}:`
|
||||||
|
}
|
||||||
|
case 'right': {
|
||||||
|
return `${dashes}:`
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
return `:${dashes}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return [line(cells[0]), line(delimiters), ...cells.slice(1).map((row) => line(row))].join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The raw text between one row's pipes, unsplit and untrimmed -- what `isCompact` measures.
|
||||||
|
*
|
||||||
|
* An escaped `\|` is not a separator, and the outer pipes markdown allows on each side leave an empty
|
||||||
|
* segment at each end which is not a column.
|
||||||
|
*/
|
||||||
|
function rawCells(line) {
|
||||||
|
const cells = []
|
||||||
|
let cell = ''
|
||||||
|
for (let index = 0; index < line.length; index++) {
|
||||||
|
if (line[index] === '\\' && line[index + 1] === '|') {
|
||||||
|
cell += '|'
|
||||||
|
index++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (line[index] === '|') {
|
||||||
|
cells.push(cell)
|
||||||
|
cell = ''
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cell += line[index]
|
||||||
|
}
|
||||||
|
cells.push(cell)
|
||||||
|
const trimmed = line.trim()
|
||||||
|
if (trimmed.startsWith('|') && cells[0].trim() === '') {
|
||||||
|
cells.shift()
|
||||||
|
}
|
||||||
|
if (trimmed.endsWith('|') && cells.length > 0 && cells.at(-1).trim() === '') {
|
||||||
|
cells.pop()
|
||||||
|
}
|
||||||
|
return cells
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One row's cells, as the values they hold. */
|
||||||
|
function splitRow(line) {
|
||||||
|
return rawCells(line).map((cell) => cell.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The alignments a delimiter row states, or `null` if the line is not one.
|
||||||
|
*
|
||||||
|
* This is also what tells a table's header row from a paragraph that happens to hold a pipe: a table is
|
||||||
|
* a line followed by one of these.
|
||||||
|
*
|
||||||
|
* A cell with no colon means no alignment, which renders as left and is stored as `left` -- the editor
|
||||||
|
* has no fourth state to keep it in, so writing such a table back states the colon it left out.
|
||||||
|
*/
|
||||||
|
function parseDelimiters(line) {
|
||||||
|
if (!line?.includes('|') && !/^ {0,3}:?-+:?$/.test(line ?? '')) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const cells = splitRow(line)
|
||||||
|
if (cells.length === 0 || !cells.every((cell) => DELIMITER_CELL.test(cell))) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return cells.map((cell) => {
|
||||||
|
if (cell.startsWith(':') && cell.endsWith(':')) {
|
||||||
|
return 'center'
|
||||||
|
}
|
||||||
|
return cell.endsWith(':') ? 'right' : 'left'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the source was written compact, so that reading a table and writing it back does not reformat
|
||||||
|
* it on the author's behalf.
|
||||||
|
*
|
||||||
|
* Measured on the whitespace rather than by building the table both ways and comparing: a hand-written
|
||||||
|
* table is compact whether it was written `|a|b|` or `| a | b |`, and neither is what either branch of
|
||||||
|
* `buildTable` emits exactly. What padding looks like is a cell holding spaces beyond the single one
|
||||||
|
* that keeps the text off the pipe -- and, in a delimiter row, a run of dashes longer than the three a
|
||||||
|
* compact table ever needs.
|
||||||
|
*/
|
||||||
|
function isCompact(lines) {
|
||||||
|
const unpadded = (raw) => {
|
||||||
|
const value = raw.trim()
|
||||||
|
return value === '' ? raw.length <= 2 : raw === value || raw === ` ${value} `
|
||||||
|
}
|
||||||
|
return lines.every((line, index) =>
|
||||||
|
rawCells(line).every((raw) => unpadded(raw) && (index !== 1 || raw.trim().length <= MIN_WIDTH))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A table's source as the editor's own state: `rows[0]` is the header, one alignment per column.
|
||||||
|
*
|
||||||
|
* The column count is the widest row rather than the delimiter row's, so a body row carrying more cells
|
||||||
|
* than the header -- which markdown itself drops on the floor -- arrives as a column the author can see
|
||||||
|
* and deal with, instead of being deleted by opening the editor.
|
||||||
|
*/
|
||||||
|
export function parseTable(source) {
|
||||||
|
const lines = source.split('\n').filter((line) => line.trim() !== '')
|
||||||
|
const align = parseDelimiters(lines[1]) ?? []
|
||||||
|
const rows = [splitRow(lines[0] ?? ''), ...lines.slice(2).map(splitRow)]
|
||||||
|
const columns = Math.max(1, align.length, ...rows.map((row) => row.length))
|
||||||
|
return {
|
||||||
|
align: Array.from({ length: columns }, (_, i) => align[i] ?? 'left'),
|
||||||
|
rows: rows.map((row) => Array.from({ length: columns }, (_, i) => row[i] ?? '')),
|
||||||
|
compact: isCompact(lines)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the table ending at `last` carries on below it.
|
||||||
|
*
|
||||||
|
* A MultiMarkdown table may have a second body, separated from the first by one blank line, and it is
|
||||||
|
* part of the same table -- so a lens over the first half would offer to replace a piece of a table and
|
||||||
|
* leave the rest of it stranded. Told apart from an ordinary table that merely follows this one by
|
||||||
|
* whether that next row brings a delimiter row of its own; a paragraph holding a pipe reads as a
|
||||||
|
* continuation too, and costs only the offer to edit.
|
||||||
|
*/
|
||||||
|
function continuesBelow(lines, last) {
|
||||||
|
if ((lines[last + 1] ?? '').trim() !== '' || !lines[last + 2]?.includes('|')) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return !parseDelimiters(lines[last + 3])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every table in the source that the table editor can hold, in the order they appear.
|
||||||
|
*
|
||||||
|
* Line numbers are 1-based, to be handed straight to the editor.
|
||||||
|
*
|
||||||
|
* What is deliberately left out: a table inside a fenced block, which is a code sample and not a table;
|
||||||
|
* a headerless table, whose first line is already the delimiter row; and a table using a multi-line
|
||||||
|
* cell, a `^^` rowspan or a second body. The editor's model has no place to keep any of those, so
|
||||||
|
* offering to edit one would be offering to throw it away.
|
||||||
|
*/
|
||||||
|
export function findEditableTables(text) {
|
||||||
|
const lines = text.split('\n')
|
||||||
|
const tables = []
|
||||||
|
let fence = null
|
||||||
|
|
||||||
|
for (let index = 0; index < lines.length; index++) {
|
||||||
|
const edge = FENCE.exec(lines[index])
|
||||||
|
if (fence) {
|
||||||
|
if (edge && edge[1][0] === fence[0] && edge[1].length >= fence.length) {
|
||||||
|
fence = null
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (edge) {
|
||||||
|
fence = edge[1]
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// -> A header row and then a delimiter row. A delimiter row FIRST is a headerless table
|
||||||
|
if (!lines[index].includes('|') || parseDelimiters(lines[index])) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (!parseDelimiters(lines[index + 1])) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = index
|
||||||
|
let last = index + 1
|
||||||
|
while ((lines[last + 1] ?? '').trim() !== '' && lines[last + 1].includes('|')) {
|
||||||
|
last++
|
||||||
|
}
|
||||||
|
// -> Whatever this block turns out to be, no line of it starts another table
|
||||||
|
index = last
|
||||||
|
|
||||||
|
const body = lines.slice(start + 2, last + 1)
|
||||||
|
if (
|
||||||
|
body.some((line) => line.trimEnd().endsWith('\\')) ||
|
||||||
|
body.some((line) => splitRow(line).includes('^^')) ||
|
||||||
|
continuesBelow(lines, last)
|
||||||
|
) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
tables.push({
|
||||||
|
startLine: start + 1,
|
||||||
|
endLine: last + 1,
|
||||||
|
source: lines.slice(start, last + 1).join('\n')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return tables
|
||||||
|
}
|
||||||
Loading…
Reference in new issue