docs-v2/cypress/support/markdown-validator.js

261 lines
5.7 KiB
JavaScript

/**
* Markdown Validation Helper for Cypress Tests
*
* Uses remark/unified to parse and validate Markdown structure
* instead of brittle regex patterns.
*/
import { unified } from 'unified';
import remarkParse from 'remark-parse';
import remarkGfm from 'remark-gfm';
import remarkFrontmatter from 'remark-frontmatter';
import { visit } from 'unist-util-visit';
/**
* Parse Markdown and return AST (Abstract Syntax Tree)
*/
export function parseMarkdown(markdown) {
return unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkFrontmatter, ['yaml'])
.parse(markdown);
}
/**
* Validate Markdown structure and return validation results
*/
export function validateMarkdown(markdown) {
const ast = parseMarkdown(markdown);
const results = {
valid: true,
errors: [],
warnings: [],
info: {
hasFrontmatter: false,
frontmatter: null,
hasLists: false,
hasTables: false,
tableCount: 0,
tables: [],
headings: [],
codeBlocks: [],
links: [],
},
};
// Extract frontmatter
visit(ast, 'yaml', (node) => {
results.info.hasFrontmatter = true;
try {
// Store raw frontmatter for parsing
results.info.frontmatter = node.value;
} catch (error) {
results.errors.push(`Invalid YAML frontmatter: ${error.message}`);
results.valid = false;
}
});
// Check for lists
visit(ast, 'list', (node) => {
results.info.hasLists = true;
});
// Check for tables and validate structure
visit(ast, 'table', (node) => {
results.info.hasTables = true;
results.info.tableCount++;
const table = {
rows: node.children.length,
columns: node.children[0]?.children.length || 0,
headers: [],
cells: [],
};
// Extract headers from first row
if (node.children[0]) {
node.children[0].children.forEach((cell) => {
const text = extractText(cell);
table.headers.push(text);
});
}
// Extract all cell content
node.children.forEach((row, rowIndex) => {
const rowCells = [];
row.children.forEach((cell) => {
rowCells.push(extractText(cell));
});
table.cells.push(rowCells);
});
results.info.tables.push(table);
});
// Extract headings
visit(ast, 'heading', (node) => {
results.info.headings.push({
depth: node.depth,
text: extractText(node),
});
});
// Extract code blocks
visit(ast, 'code', (node) => {
results.info.codeBlocks.push({
lang: node.lang || null,
value: node.value,
});
});
// Extract links
visit(ast, 'link', (node) => {
results.info.links.push({
url: node.url,
title: node.title || null,
text: extractText(node),
});
});
return results;
}
/**
* Extract text content from a node (recursively handles all node types)
*/
function extractText(node) {
if (!node) {
return '';
}
if (node.type === 'text') {
return node.value;
}
// Handle inline code
if (node.type === 'inlineCode') {
return node.value;
}
// Handle links - extract the text children
if (node.type === 'link') {
if (node.children) {
return node.children.map(extractText).join('');
}
return '';
}
// Handle emphasis, strong, etc - recursively extract children
if (node.children) {
return node.children.map(extractText).join('');
}
// For any other node type with a value
if (node.value) {
return node.value;
}
return '';
}
/**
* Check if content contains specific text (case-insensitive)
*/
export function containsText(markdown, searchText) {
return markdown.toLowerCase().includes(searchText.toLowerCase());
}
/**
* Check if content does NOT contain specific text (case-insensitive)
*/
export function doesNotContainText(markdown, searchText) {
return !containsText(markdown, searchText);
}
/**
* Validate frontmatter has required fields
*/
export function validateFrontmatter(frontmatter, requiredFields) {
const errors = [];
if (!frontmatter) {
return { valid: false, errors: ['No frontmatter found'] };
}
// Parse YAML frontmatter
let parsed;
try {
// Simple YAML parsing - split by lines and extract key-value pairs
parsed = {};
const lines = frontmatter.split('\n');
lines.forEach((line) => {
const match = line.match(/^([^:]+):\s*(.*)$/);
if (match) {
parsed[match[1].trim()] = match[2].trim();
}
});
} catch (error) {
return {
valid: false,
errors: [`Failed to parse frontmatter: ${error.message}`],
};
}
// Check required fields
requiredFields.forEach((field) => {
if (!parsed[field]) {
errors.push(`Missing required frontmatter field: ${field}`);
}
});
return {
valid: errors.length === 0,
errors,
data: parsed,
};
}
/**
* Validate table structure
*/
export function validateTable(tableInfo, expectedHeaders = null, minRows = 0) {
const errors = [];
if (!tableInfo) {
return { valid: false, errors: ['Table not found'] };
}
// Check column count consistency
const columnCounts = tableInfo.cells.map((row) => row.length);
const uniqueCounts = [...new Set(columnCounts)];
if (uniqueCounts.length > 1) {
errors.push(`Inconsistent column count: ${uniqueCounts.join(', ')}`);
}
// Check expected headers
if (expectedHeaders) {
expectedHeaders.forEach((header) => {
if (
!tableInfo.headers.some((h) =>
h.toLowerCase().includes(header.toLowerCase())
)
) {
errors.push(`Missing expected header: ${header}`);
}
});
}
// Check minimum rows
if (tableInfo.rows < minRows) {
errors.push(
`Table has ${tableInfo.rows} rows, expected at least ${minRows}`
);
}
return {
valid: errors.length === 0,
errors,
};
}