-
Notifications
You must be signed in to change notification settings - Fork 66.9k
Expand file tree
/
Copy pathtable-column-integrity.ts
More file actions
145 lines (123 loc) · 4.88 KB
/
table-column-integrity.ts
File metadata and controls
145 lines (123 loc) · 4.88 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
// @ts-ignore - markdownlint-rule-helpers doesn't provide TypeScript declarations
import { addError } from 'markdownlint-rule-helpers'
import { getRange } from '../helpers/utils'
import frontmatter from '@/frame/lib/read-frontmatter'
import type { RuleParams, RuleErrorCallback } from '@/content-linter/types'
// Regex to detect table rows (must start with |, contain at least one more |, and end with optional whitespace)
const TABLE_ROW_REGEX = /^\s*\|.*\|\s*$/
// Regex to detect table separator rows (contains only |, :, -, and whitespace)
const TABLE_SEPARATOR_REGEX = /^\s*\|[\s\-:|\s]*\|\s*$/
// Regex to detect Liquid-only cells (whitespace, liquid tag, whitespace)
const LIQUID_ONLY_CELL_REGEX = /^\s*{%\s*(ifversion|else|endif|elsif|for|endfor).*%}\s*$/
// Regex to use for splitting on non-escaped pipes only
const NON_ESCAPED_PIPE_REGEX = /(?<!\\)\|/
/**
* Counts the number of columns in a table row by splitting on | and handling edge cases
*/
function countColumns(row: string): number {
// Remove leading and trailing whitespace
const trimmed = row.trim()
// Handle empty rows
if (!trimmed || !trimmed.includes('|')) {
return 0
}
// Split by '|' (but ignore escaped '\|' as these are not true separators)
// Filter out empty cells at start/end (from leading/trailing |)
const cells = trimmed.split(NON_ESCAPED_PIPE_REGEX)
// Remove first and last elements if they're empty (from leading/trailing |)
if (cells.length > 0 && cells[0].trim() === '') {
cells.shift()
}
if (cells.length > 0 && cells[cells.length - 1].trim() === '') {
cells.pop()
}
return cells.length
}
/**
* Checks if a table row contains only Liquid conditionals
*/
function isLiquidOnlyRow(row: string): boolean {
const trimmed = row.trim()
if (!trimmed.includes('|')) return false
const cells = trimmed.split(NON_ESCAPED_PIPE_REGEX)
// Remove empty cells from leading/trailing |
const filteredCells = cells.filter((cell, index) => {
if (index === 0 && cell.trim() === '') return false
if (index === cells.length - 1 && cell.trim() === '') return false
return true
})
// Check if all cells contain only Liquid tags
return (
filteredCells.length > 0 && filteredCells.every((cell) => LIQUID_ONLY_CELL_REGEX.test(cell))
)
}
export const tableColumnIntegrity = {
names: ['GHD047', 'table-column-integrity'],
description: 'Tables must have consistent column counts across all rows',
tags: ['tables', 'accessibility', 'formatting'],
severity: 'error',
function: (params: RuleParams, onError: RuleErrorCallback) => {
// Skip autogenerated files
const frontmatterString = params.frontMatterLines.join('\n')
const fm = frontmatter(frontmatterString).data
if (fm && fm.autogenerated) return
const lines = params.lines
let inTable = false
let inCodeFence = false
let expectedColumnCount: number | null = null
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
// Toggle code fence state
if (line.trim().startsWith('```')) {
inCodeFence = !inCodeFence
continue
}
if (inCodeFence) {
continue
}
const isTableRow = TABLE_ROW_REGEX.test(line)
const isSeparatorRow = TABLE_SEPARATOR_REGEX.test(line)
// Check if we're starting a new table
if (!inTable && isTableRow) {
// Look ahead to see if next line is a separator (confirming this is a table)
const nextLine = lines[i + 1]
if (nextLine && TABLE_SEPARATOR_REGEX.test(nextLine)) {
inTable = true
expectedColumnCount = countColumns(line)
continue
}
}
// Check if we're ending a table
if (inTable && !isTableRow) {
inTable = false
expectedColumnCount = null
continue
}
// If we're in a table, validate column count
if (inTable && isTableRow && !isSeparatorRow) {
// Skip Liquid-only rows as they're allowed to have different column counts
if (isLiquidOnlyRow(line)) {
continue
}
const actualColumnCount = countColumns(line)
if (actualColumnCount !== expectedColumnCount) {
const range = getRange(line, line.trim())
let errorMessage
if (actualColumnCount > expectedColumnCount!) {
errorMessage = `Table row has ${actualColumnCount} columns but header has ${expectedColumnCount}. Add ${actualColumnCount - expectedColumnCount!} more column(s) to the header row to match this row.`
} else {
errorMessage = `Table row has ${actualColumnCount} columns but header has ${expectedColumnCount}. Add ${expectedColumnCount! - actualColumnCount} missing column(s) to this row.`
}
addError(
onError,
i + 1,
errorMessage,
line,
range,
null, // No auto-fix available due to complexity
)
}
}
}
},
}