261 lines
5.7 KiB
JavaScript
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,
|
|
};
|
|
}
|