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