| | 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' |
| |
|
| | |
| | const TABLE_ROW_REGEX = /^\s*\|.*\|\s*$/ |
| | |
| | const TABLE_SEPARATOR_REGEX = /^\s*\|[\s\-:|\s]*\|\s*$/ |
| | |
| | const LIQUID_ONLY_CELL_REGEX = /^\s*{%\s*(ifversion|else|endif|elsif|for|endfor).*%}\s*$/ |
| | |
| | const NON_ESCAPED_PIPE_REGEX = /(?<!\\)\|/ |
| | |
| | |
| | |
| | function countColumns(row: string): number { |
| | |
| | const trimmed = row.trim() |
| |
|
| | |
| | if (!trimmed || !trimmed.includes('|')) { |
| | return 0 |
| | } |
| |
|
| | |
| | |
| | const cells = trimmed.split(NON_ESCAPED_PIPE_REGEX) |
| |
|
| | |
| | if (cells.length > 0 && cells[0].trim() === '') { |
| | cells.shift() |
| | } |
| | if (cells.length > 0 && cells[cells.length - 1].trim() === '') { |
| | cells.pop() |
| | } |
| |
|
| | return cells.length |
| | } |
| |
|
| | |
| | |
| | |
| | function isLiquidOnlyRow(row: string): boolean { |
| | const trimmed = row.trim() |
| | if (!trimmed.includes('|')) return false |
| |
|
| | const cells = trimmed.split(NON_ESCAPED_PIPE_REGEX) |
| | |
| | const filteredCells = cells.filter((cell, index) => { |
| | if (index === 0 && cell.trim() === '') return false |
| | if (index === cells.length - 1 && cell.trim() === '') return false |
| | return true |
| | }) |
| |
|
| | |
| | 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) => { |
| | |
| | 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] |
| |
|
| | |
| | if (line.trim().startsWith('```')) { |
| | inCodeFence = !inCodeFence |
| | continue |
| | } |
| |
|
| | if (inCodeFence) { |
| | continue |
| | } |
| |
|
| | const isTableRow = TABLE_ROW_REGEX.test(line) |
| | const isSeparatorRow = TABLE_SEPARATOR_REGEX.test(line) |
| |
|
| | |
| | if (!inTable && isTableRow) { |
| | |
| | const nextLine = lines[i + 1] |
| | if (nextLine && TABLE_SEPARATOR_REGEX.test(nextLine)) { |
| | inTable = true |
| | expectedColumnCount = countColumns(line) |
| | continue |
| | } |
| | } |
| |
|
| | |
| | if (inTable && !isTableRow) { |
| | inTable = false |
| | expectedColumnCount = null |
| | continue |
| | } |
| |
|
| | |
| | if (inTable && isTableRow && !isSeparatorRow) { |
| | |
| | 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, |
| | ) |
| | } |
| | } |
| | } |
| | }, |
| | } |
| |
|