remark-lint-table-pipe-alignment
Version:
remark-lint rule to warn when table pipes are not aligned
628 lines (573 loc) • 19.1 kB
JavaScript
/**
* remark-lint rule to warn when GFM table cells are aligned inconsistently.
*
* ## What is this?
*
* This package checks table cell dividers are aligned.
* Tables are a GFM feature enabled with [`remark-gfm`][github-remark-gfm].
*
* ## When should I use this?
*
* You can use this package to check that tables are consistent.
*
* ## API
*
* ### `unified().use(remarkLintTablePipeAlignment[, options])`
*
* Warn when GFM table cells are aligned inconsistently.
*
* ###### Parameters
*
* * `options` ([`Options`][api-options], optional)
* — configuration
*
* ###### Returns
*
* Transform ([`Transformer` from `unified`][github-unified-transformer]).
*
* ### `Options`
*
* Configuration (TypeScript type).
*
* ###### Properties
*
* * `stringLength` (`(value: string) => number`, optional)
* — function to detect cell size
*
* ## Recommendation
*
* While aligning table dividers improves their legibility,
* it is somewhat hard to maintain manually,
* especially for tables with many rows.
*
* ## Fix
*
* [`remark-stringify`][github-remark-stringify] with
* [`remark-gfm`][github-remark-gfm] aligns table cell dividers by default.
* Pass `tablePipeAlign: false` to use a more compact style.
*
* Aligning perfectly in all cases is not possible because whether characters
* look aligned or not depends on where the markup is shown.
* Some characters (such as emoji or Chinese characters) show smaller or bigger
* in different places.
* You can pass a `stringLength` function to `remark-gfm`,
* to align better for your use case,
* in which case this rule must be configured with the same `stringLength`.
*
* [api-options]: #options
* [api-remark-lint-table-pipe-alignment]: #unifieduseremarklinttablepipealignment-options
* [github-remark-gfm]: https://github.com/remarkjs/remark-gfm
* [github-remark-stringify]: https://github.com/remarkjs/remark/tree/main/packages/remark-stringify
* [github-unified-transformer]: https://github.com/unifiedjs/unified#transformer
*
* @module table-pipe-alignment
* @author Titus Wormer
* @copyright Titus Wormer
* @license MIT
*
* @example
* {"gfm": true, "name": "ok.md"}
*
* This rule is only about the alignment of pipes across rows:
*
* | Planet | Mean anomaly (°) |
* | ------- | ---------------: |
* | Mercury | 174 796 |
*
* |Planet|Mean anomaly (°)|
* |------|---------------:|
* |Venus | 50 115 |
*
* @example
* {"gfm": true, "label": "input", "name": "not-ok.md"}
*
* | Planet | Mean anomaly (°) |
* | - | -: |
* | Mercury | 174 796 |
*
* @example
* {"gfm": true, "label": "output", "name": "not-ok.md"}
*
* 1:10: Unexpected unaligned cell, expected aligned pipes, add `1` space
* 2:5: Unexpected unaligned cell, expected aligned pipes, add `6` spaces (or add `-` to pad alignment row cells)
* 2:7: Unexpected unaligned cell, expected aligned pipes, add `14` spaces (or add `-` to pad alignment row cells)
* 3:13: Unexpected unaligned cell, expected aligned pipes, add `9` spaces
*
* @example
* {"gfm": true, "name": "empty.md"}
*
* | | Satellites | |
* | ------- | ---------- | --- |
* | Mercury | | |
*
* | aaa | bbb | ccc | ddd |
* | --- | :-- | :-: | --: |
* | | | | |
*
* @example
* {"gfm": true, "name": "aligned-pipes-but-weird-content.md"}
*
* | Planet | Moon | Mercury | Venus | Sun | Mars | Jupiter | Saturn |
* | ------ | ---- | :------ | :---- | --: | ---: | :-----: | :----: |
* | Symbol | ☾ | ☿ | ♀ | ☉ | ♂ | ♃ | ♄ |
*
* @example
* {"gfm": true, "name": "missing-cells.md"}
*
* | Planet | Symbol | Satellites |
* | ------- | ------ | ---------- |
* | Mercury |
* | Venus | ♀ |
* | Earth | ♁ | 1 |
* | Mars | ♂ | 2 | 19 412 |
*
* @example
* {"gfm": true, "label": "input", "name": "alignment.md"}
*
* | Planet | Symbol | Satellites | Mean anomaly (°) |
* | - | :- | :-: | -: |
* | Mercury | ☿ | None | 174 796 |
* @example
* {"gfm": true, "label": "output", "name": "alignment.md"}
*
* 1:10: Unexpected unaligned cell, expected aligned pipes, add `1` space
* 2:5: Unexpected unaligned cell, expected aligned pipes, add `6` spaces (or add `-` to pad alignment row cells)
* 2:10: Unexpected unaligned cell, expected aligned pipes, add `4` spaces (or add `-` to pad alignment row cells)
* 2:12: Unexpected unaligned cell, expected aligned pipes, add `4` spaces (or add `-` to pad alignment row cells)
* 2:16: Unexpected unaligned cell, expected aligned pipes, add `3` spaces (or add `-` to pad alignment row cells)
* 2:18: Unexpected unaligned cell, expected aligned pipes, add `14` spaces (or add `-` to pad alignment row cells)
* 3:15: Unexpected unaligned cell, expected aligned pipes, add `5` spaces
* 3:17: Unexpected unaligned cell, expected aligned pipes, add `3` spaces
* 3:22: Unexpected unaligned cell, expected aligned pipes, add `3` spaces
* 3:24: Unexpected unaligned cell, expected aligned pipes, add `9` spaces
*
* @example
* {"gfm": true, "label": "input", "name": "missing-fences.md"}
*
* Planet | Satellites
* -: | -
* Mercury | ☿
* @example
* {"gfm": true, "label": "output", "name": "missing-fences.md"}
*
* 1:1: Unexpected unaligned cell, expected aligned pipes, add `1` space
* 2:1: Unexpected unaligned cell, expected aligned pipes, add `5` spaces (or add `-` to pad alignment row cells)
*
* @example
* {"gfm": true, "label": "input", "name": "trailing-spaces.md"}
*
* | Planet |␠␠
* | -: |␠
* @example
* {"gfm": true, "label": "output", "name": "trailing-spaces.md"}
*
* 2:3: Unexpected unaligned cell, expected aligned pipes, add `4` spaces (or add `-` to pad alignment row cells)
*
* @example
* {"gfm": true, "label": "input", "name": "nothing.md"}
*
* ||||
* |-|-|-|
* @example
* {"gfm": true, "label": "output", "name": "nothing.md"}
*
* 1:2: Unexpected unaligned cell, expected aligned pipes, add `1` space
* 1:3: Unexpected unaligned cell, expected aligned pipes, add `1` space
* 1:4: Unexpected unaligned cell, expected aligned pipes, add `1` space
*
* @example
* {"gfm": true, "label": "input", "name": "more-weirdness.md"}
*
* Mercury
* |-
*
* Venus
* -|
* @example
* {"gfm": true, "label": "output", "name": "more-weirdness.md"}
*
* 5:2: Unexpected unaligned cell, expected aligned pipes, add `4` spaces (or add `-` to pad alignment row cells)
*
* @example
* {"gfm": true, "label": "input", "name": "containers.md"}
*
* > | Mercury|
* > | - |
*
* * | Venus|
* | - |
*
* > * > | Earth|
* > > | - |
* @example
* {"gfm": true, "label": "output", "name": "containers.md"}
*
* 2:5: Unexpected unaligned cell, expected aligned pipes, add `5` spaces (or add `-` to pad alignment row cells)
* 5:5: Unexpected unaligned cell, expected aligned pipes, add `3` spaces (or add `-` to pad alignment row cells)
* 8:5: Unexpected unaligned cell, expected aligned pipes, add `3` spaces (or add `-` to pad alignment row cells)
*
* @example
* {"gfm": true, "label": "input", "name": "windows.md"}
*
* | Mercury|␍␊| --- |␍␊| None |
* @example
* {"gfm": true, "label": "output", "name": "windows.md"}
*
* 2:7: Unexpected unaligned cell, expected aligned pipes, add `3` spaces (or add `-` to pad alignment row cells)
* 3:8: Unexpected unaligned cell, expected aligned pipes, add `2` spaces
*
* @example
* {"gfm": true, "name": "string-length-default.md"}
*
* | Alpha | Bravo |
* | ----- | ------- |
* | 冥王星 | Charlie |
* | 🪐 | Delta |
*
* @example
* {"config": {"stringLength": "__STRING_WIDTH__"}, "gfm": true, "name": "string-length-custom.md"}
*
* | Alpha | Bravo |
* | ------ | ------- |
* | 冥王星 | Charlie |
* | 🪐 | Delta |
*/
/**
* @import {AlignType, Nodes, Root} from 'mdast'
* @import {Point} from 'unist'
*/
/**
* @typedef Options
* Configuration.
* @property {((value: string) => number) | null | undefined} [stringLength]
* Function to detect cell size (optional).
*/
import {ok as assert} from 'devlop'
import {phrasing} from 'mdast-util-phrasing'
import pluralize from 'pluralize'
import {lintRule} from 'unified-lint-rule'
import {pointEnd, pointStart} from 'unist-util-position'
import {SKIP, visitParents} from 'unist-util-visit-parents'
const remarkLintTablePipeAlignment = lintRule(
{
origin: 'remark-lint:table-pipe-alignment',
url: 'https://github.com/remarkjs/remark-lint/tree/main/packages/remark-lint-table-pipe-alignment#readme'
},
/**
* @param {Root} tree
* Tree.
* @param {Options | null | undefined} [options]
* Configuration (optional).
* @returns {undefined}
* Nothing.
*/
function (tree, file, options) {
/**
* @typedef Entry
* @property {AlignType} align
* @property {Array<Nodes>} ancestors
* @property {number} column
* @property {number | undefined} row
* @property {Size | undefined} size
*
* @typedef Size
* @property {number | undefined} left
* @property {Point} leftPoint
* @property {number} middle
* @property {number | undefined} right
* @property {Point} rightPoint
*/
const stringLength = options ? options.stringLength : undefined
const value = String(file)
visitParents(tree, function (node, parents) {
// Do not walk into phrasing.
if (phrasing(node)) {
return SKIP
}
if (node.type !== 'table') return
const entries = inferTable([...parents, node])
// Find max column sizes.
/** @type {Array<number>} */
const sizes = []
for (const info of entries) {
if (info.size) {
let total = info.size.middle
if (info.size.left) total += info.size.left
if (info.size.right) total += info.size.right
if (sizes[info.column] === undefined || total > sizes[info.column]) {
sizes[info.column] = total
}
}
}
for (const info of entries) {
if (!info.size) continue
let total = info.size.middle
if (info.size.left) total += info.size.left
if (info.size.right) total += info.size.right
const difference = sizes[info.column] - total
assert(difference >= 0) // Always positive.
let left = 0
let right = 0
// Center if there is something to center.
if (info.align === 'center' && info.size.middle && difference > 0) {
// Maximum number of spaces we would want on the left.
const max = Math.floor((sizes[info.column] - info.size.middle) / 2)
if (info.size.right !== undefined && max > info.size.right) {
right = max - info.size.right
}
left = difference - right
} else if (info.align === 'right') {
left = difference
} else {
right = difference
}
warn(info, left, info.size.leftPoint)
// If there is no final pipe, we don’t ask for trailing spaces.
if (info.size.right !== undefined) {
warn(info, right, info.size.rightPoint)
}
}
return SKIP
})
/**
* @param {Entry} info
* Info.
* @param {number} add
* Number of spaces to add.
* @param {Point} place
* Place to add spaces.
* @returns {undefined}
* Nothing.
*/
function warn(info, add, place) {
if (add === 0) return
file.message(
'Unexpected unaligned cell, expected aligned pipes, add `' +
add +
'` ' +
pluralize('space', add) +
(info.row === undefined
? ' (or add `-` to pad alignment row cells)'
: ''),
{ancestors: info.ancestors, place}
)
}
// Note: this code is also in `remark-lint-table-cell-padding`.
/**
* Get info about cells in a table.
*
* @param {Array<Nodes>} ancestors
* Ancestors.
* @returns {Array<Entry>}
* Entries.
*/
function inferTable(ancestors) {
const node = ancestors.at(-1)
assert(node) // Always defined.
assert(node.type === 'table') // Always table.
/* c8 ignore next -- `align` is optional in AST. */
const align = node.align || []
/** @type {Array<Entry>} */
const result = []
let rowIndex = -1
// Regular rows.
while (++rowIndex < node.children.length) {
const row = node.children[rowIndex]
let column = -1
while (++column < row.children.length) {
const node = row.children[column]
result.push({
align: align[column],
ancestors: [...ancestors, row, node],
column,
row: rowIndex,
size: inferSize(
pointStart(node),
pointEnd(node),
column === row.children.length - 1
)
})
}
if (rowIndex === 0) {
const alignRow = inferAlignRow(ancestors, align)
if (alignRow) result.push(...alignRow)
}
}
return result
}
/**
* @param {Array<Nodes>} ancestors
* @param {Array<AlignType>} align
* @returns {Array<Entry> | undefined}
*/
function inferAlignRow(ancestors, align) {
const node = ancestors.at(-1)
assert(node) // Always defined.
assert(node.type === 'table') // Always table.
const headEnd = pointEnd(node.children[0])
if (!headEnd || typeof headEnd.offset !== 'number') return
let index = headEnd.offset
if (value.charCodeAt(index) === 13 /* `\r` */) index++
/* c8 ignore next -- should never happen, alignment is needed. */
if (value.charCodeAt(index) !== 10 /* `\n` */) return
index++
/** @type {Array<Entry>} */
const result = []
const line = headEnd.line + 1
// Alignment row can only be on the second line,
// so containers can only indent with `>` or spaces.
let code = value.charCodeAt(index)
while (
code === 9 /* `\t` */ ||
code === 32 /* ` ` */ ||
code === 62 /* `>` */
) {
index++
code = value.charCodeAt(index)
}
/* c8 ignore next 7 -- should always be found. */
if (
code !== 45 /* `-` */ &&
code !== 58 /* `:` */ &&
code !== 124 /* `|` */
) {
return
}
let lineEndOffset = value.indexOf('\n', index)
if (lineEndOffset === -1) lineEndOffset = value.length
if (value.charCodeAt(lineEndOffset - 1) === 13 /* `\r` */) lineEndOffset--
let column = 0
let cellStart = index
let cellEnd = value.indexOf('|', index + (code === 124 ? 1 : 0))
if (cellEnd === -1 || cellEnd > lineEndOffset) {
cellEnd = lineEndOffset
}
while (cellStart !== cellEnd) {
let nextCellEnd = value.indexOf('|', cellEnd + 1)
if (nextCellEnd === -1 || nextCellEnd > lineEndOffset) {
nextCellEnd = lineEndOffset
}
// Check if the trail is empty,
// which means it’s a closing pipe with trailing whitespace.
if (nextCellEnd === lineEndOffset) {
let maybeEnd = lineEndOffset
let code = value.charCodeAt(maybeEnd - 1)
while (code === 9 /* `\t` */ || code === 32 /* ` ` */) {
maybeEnd--
code = value.charCodeAt(maybeEnd - 1)
}
if (cellEnd + 1 === maybeEnd) {
cellEnd = lineEndOffset
}
}
result.push({
align: align[column],
ancestors,
column,
row: undefined,
size: inferSize(
{
line,
column: cellStart - index + 1,
offset: cellStart
},
{line, column: cellEnd - index + 1, offset: cellEnd},
cellEnd === lineEndOffset
)
})
cellStart = cellEnd
cellEnd = nextCellEnd
column++
}
return result
}
/**
* @param {Point | undefined} start
* Start point.
* @param {Point | undefined} end
* End point.
* @param {boolean} tailCell
* Whether this is the last cell in a row.
* @returns {Size | undefined}
* Size info.
*/
function inferSize(start, end, tailCell) {
if (
end &&
start &&
typeof end.offset === 'number' &&
typeof start.offset === 'number'
) {
let leftIndex = start.offset
/** @type {number | undefined} */
let left
/** @type {number | undefined} */
let right
if (value.charCodeAt(leftIndex) === 124 /* `|` */) {
left = 0
leftIndex++
while (value.charCodeAt(leftIndex) === 32) {
left++
leftIndex++
}
}
// Else, A leading pipe can only be omitted in the first cell.
// Where we never want leading whitespace, as it’s seen as
// indentation, and could turn into an indented block.
let rightIndex = end.offset
// The final pipe, if it exists, is part of the last cell in a row
// according to positional info.
if (tailCell) {
while (value.charCodeAt(rightIndex - 1) === 32) {
rightIndex--
}
// Found a pipe: we expect more whitespace.
if (
rightIndex > leftIndex &&
value.charCodeAt(rightIndex - 1) === 124 /* `|` */
) {
rightIndex--
}
// No pipe at the last cell: the trailing whitespace is part of
// the cell.
else {
rightIndex = end.offset
}
}
/** @type {number} */
const rightEdgeIndex = rightIndex
if (value.charCodeAt(rightIndex) === 124 /* `|` */) {
right = 0
while (
rightIndex - 1 > leftIndex &&
value.charCodeAt(rightIndex - 1) === 32
) {
right++
rightIndex--
}
}
// Else, a trailing pipe can only be omitted in the last cell.
// Where we never want trailing whitespace.
const middle = stringLength
? stringLength(value.slice(leftIndex, rightIndex))
: rightIndex - leftIndex
return {
left,
leftPoint: {
line: start.line,
column: start.column + (leftIndex - start.offset),
offset: leftIndex
},
middle,
right,
rightPoint: {
line: end.line,
column: end.column - (end.offset - rightEdgeIndex),
offset: rightEdgeIndex
}
}
}
}
}
)
export default remarkLintTablePipeAlignment