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 = /(? 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 ) } } } }, }