We provide all the information about MCP servers via our MCP API.
curl -X GET 'https://glama.ai/api/mcp/v1/servers/ernestgwilsonii/markdownlint-mcp'
If you have feedback or need assistance with the MCP directory API, please join our Discord server
import { jest } from '@jest/globals';
// Simple tests for the rule-specific fix logic without relying on server or file operations
describe('Markdownlint Rule Fix Logic', () => {
// MD009: Trailing spaces
test('MD009: should fix trailing spaces', () => {
// Test content with trailing spaces
const lines = [
'This line has trailing spaces. ',
'This one too. ',
'This one is fine.'
];
// Apply the fix logic
const fixedLines = lines.map(line => line.replace(/[ \t]+$/, ''));
// Check the results
expect(fixedLines).toEqual([
'This line has trailing spaces.',
'This one too.',
'This one is fine.'
]);
});
// MD010: Hard tabs
test('MD010: should fix hard tabs', () => {
// Test content with hard tabs
const lines = [
'Line with\ttab',
'Another\tline\twith tabs',
'Line without tabs'
];
// Apply the fix logic
const fixedLines = lines.map(line => line.replace(/\t/g, ' '));
// Check the results
expect(fixedLines).toEqual([
'Line with tab',
'Another line with tabs',
'Line without tabs'
]);
});
// MD018: No space after hash on atx style heading
test('MD018: should fix headings with no space after hash', () => {
// Test content with incorrect heading format
const lines = [
'#Heading 1',
'##Heading 2',
'### This one is correct'
];
// Apply the fix logic
const fixedLines = lines.map(line =>
line.replace(/^(#{1,6})([^#\s])/, '$1 $2')
);
// Check the results
expect(fixedLines).toEqual([
'# Heading 1',
'## Heading 2',
'### This one is correct'
]);
});
// MD019: Multiple spaces after hash on atx style heading
test('MD019: should fix headings with multiple spaces after hash', () => {
// Test content with incorrect heading format
const lines = [
'# Too many spaces',
'## Also too many',
'### Just right'
];
// Apply the fix logic
const fixedLines = lines.map(line =>
line.replace(/^(#{1,6})[ \t]{2,}/, '$1 ')
);
// Check the results
expect(fixedLines).toEqual([
'# Too many spaces',
'## Also too many',
'### Just right'
]);
});
// MD020: No space inside hashes on closed atx style heading
test('MD020: should fix headings with no space inside closing hashes', () => {
// Test content with incorrect heading format
const lines = [
'# Heading 1#',
'## Heading 2#',
'### Heading 3 #' // This one is correct
];
// Apply the fix logic
const fixedLines = lines.map(line =>
line.replace(/^(#{1,6})[ \t]+([^#\s].*?)[ \t]*#$/m, '$1 $2 #')
);
// Check the results
expect(fixedLines).toEqual([
'# Heading 1 #',
'## Heading 2 #',
'### Heading 3 #'
]);
});
// MD021: Multiple spaces inside hashes on closed atx style heading
test('MD021: should fix headings with multiple spaces inside closing hashes', () => {
// Test content with incorrect heading format
const lines = [
'# Heading 1 #',
'## Heading 2 #',
'### Heading 3 #' // This one is correct
];
// Apply the fix logic
const fixedLines = lines.map(line =>
line.replace(/^(#{1,6})[ \t]+(.+?)[ \t]{2,}#$/m, '$1 $2 #')
);
// Check the results
expect(fixedLines).toEqual([
'# Heading 1 #',
'## Heading 2 #',
'### Heading 3 #'
]);
});
// MD047: Files should end with a single newline character
test('MD047: should ensure file ends with single newline', () => {
// Test 1: No newline at end
let lines = ['Line 1', 'Line 2'];
// Remove any trailing blank lines
while (lines.length > 0 && lines[lines.length - 1].trim() === '') {
lines.pop();
}
// Add a single blank line at the end
lines.push('');
// Check the results
expect(lines).toEqual(['Line 1', 'Line 2', '']);
// Test 2: Multiple newlines at end
lines = ['Line 1', 'Line 2', '', '', ''];
// Remove any trailing blank lines
while (lines.length > 0 && lines[lines.length - 1].trim() === '') {
lines.pop();
}
// Add a single blank line at the end
lines.push('');
// Check the results
expect(lines).toEqual(['Line 1', 'Line 2', '']);
});
// MD023: Headings must start at the beginning of the line
test('MD023: should fix headings with leading whitespace', () => {
// Test content with incorrect heading format
const lines = [
'# Correct heading',
' ## Heading with leading spaces',
'\t# Heading with tab',
'This is not a heading'
];
// Apply the fix logic
const fixedLines = lines.map(line => {
const headingMatch = line.match(/^\s+(#{1,6}\s+.+)$/);
return headingMatch ? headingMatch[1] : line;
});
// Check the results
expect(fixedLines).toEqual([
'# Correct heading',
'## Heading with leading spaces',
'# Heading with tab',
'This is not a heading'
]);
});
// MD026: Trailing punctuation in heading
test('MD026: should fix headings with trailing punctuation', () => {
// Test content with incorrect heading format
const lines = [
'# Heading 1.',
'## Heading 2!',
'### Heading 3?',
'#### Heading 4;',
'##### Heading 5:',
'###### Heading 6 with closing hash #',
'# This heading is correct'
];
// Apply the fix logic
const fixedLines = lines.map(line => {
if (/^#{1,6}\s+.+$/.test(line)) {
return line.replace(/^(#{1,6}\s+.+?)[.,;:!?]+(\s*#*\s*)$/, '$1$2');
}
return line;
});
// Check the results
expect(fixedLines).toEqual([
'# Heading 1',
'## Heading 2',
'### Heading 3',
'#### Heading 4',
'##### Heading 5',
'###### Heading 6 with closing hash #',
'# This heading is correct'
]);
});
// MD027: Multiple spaces after blockquote symbol
test('MD027: should fix blockquotes with too many spaces', () => {
// Test content with incorrect blockquote format
const lines = [
'> Correct blockquote',
'> Blockquote with 2 spaces',
'> Blockquote with 3 spaces',
'> Blockquote with 4 spaces',
' > Indented blockquote with correct spacing',
' > Indented blockquote with too many spaces'
];
// Apply the fix logic
const fixedLines = lines.map(line =>
line.replace(/^(\s*>)\s{2,}(.*)$/, '$1 $2')
);
// Check the results
expect(fixedLines).toEqual([
'> Correct blockquote',
'> Blockquote with 2 spaces',
'> Blockquote with 3 spaces',
'> Blockquote with 4 spaces',
' > Indented blockquote with correct spacing',
' > Indented blockquote with too many spaces'
]);
});
// MD012: Multiple consecutive blank lines
test('MD012: should fix multiple consecutive blank lines', () => {
// Test content with multiple blank lines
const lines = [
'Line 1',
'',
'',
'',
'Line 2',
'',
'Line 3',
'',
'',
'Line 4'
];
// Apply the fix logic
let fixedLines: string[] = [];
let blankLineCount = 0;
for (const line of lines) {
if (line.trim() === '') {
blankLineCount++;
// Only add the first blank line of a sequence
if (blankLineCount === 1) {
fixedLines.push(line);
}
} else {
blankLineCount = 0;
fixedLines.push(line);
}
}
// Check the results
expect(fixedLines).toEqual([
'Line 1',
'',
'Line 2',
'',
'Line 3',
'',
'Line 4'
]);
});
// MD022: Headings should be surrounded by blank lines
test('MD022: should fix headings not surrounded by blank lines', () => {
// Test content with headings not properly surrounded by blank lines
const lines = [
'Text before heading.',
'# Heading 1',
'Text after heading.',
'More text.',
'## Heading 2',
'Text after second heading.',
'',
'### Heading 3',
'',
'Text properly surrounded by blank lines.'
];
let fixedLines: string[] = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const isHeading = /^#{1,6}\s+.+/.test(line);
if (isHeading) {
// Check if there's a blank line before the heading (unless it's the first line)
const needsBlankBefore = i > 0 && lines[i-1].trim() !== '';
// Check if there's a blank line after the heading (unless it's the last line)
const needsBlankAfter = i < lines.length - 1 && lines[i+1].trim() !== '';
if (needsBlankBefore) {
fixedLines.push('');
}
fixedLines.push(line);
if (needsBlankAfter) {
fixedLines.push('');
}
} else {
fixedLines.push(line);
}
}
// Check the results
expect(fixedLines).toEqual([
'Text before heading.',
'',
'# Heading 1',
'',
'Text after heading.',
'More text.',
'',
'## Heading 2',
'',
'Text after second heading.',
'',
'### Heading 3',
'',
'Text properly surrounded by blank lines.'
]);
});
// MD031: Fenced code blocks should be surrounded by blank lines
test('MD031: should fix code blocks not surrounded by blank lines', () => {
// Test content with code blocks not properly surrounded by blank lines
const lines = [
'Text before code block.',
'```javascript',
'const x = 1;',
'```',
'Text after code block.',
'More text.',
'```python',
'print("Hello")',
'```',
'',
'Text properly spaced after code.',
'',
'```ruby',
'puts "Hi"',
'```',
'',
'Text properly spaced after code.'
];
let fixedLines: string[] = [];
let inCodeBlock = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const isFenceStart = line.trim().startsWith('```');
const isFenceEnd = inCodeBlock && line.trim() === '```';
if (isFenceStart && !inCodeBlock) {
// Check if there's a blank line before the code block (unless it's the first line)
const needsBlankBefore = i > 0 && lines[i-1].trim() !== '';
if (needsBlankBefore) {
fixedLines.push('');
}
fixedLines.push(line);
inCodeBlock = true;
} else if (isFenceEnd) {
fixedLines.push(line);
inCodeBlock = false;
// Check if there's a blank line after the code block (unless it's the last line)
const needsBlankAfter = i < lines.length - 1 && lines[i+1].trim() !== '';
if (needsBlankAfter) {
fixedLines.push('');
}
} else {
fixedLines.push(line);
}
}
// Check the results
expect(fixedLines).toEqual([
'Text before code block.',
'',
'```javascript',
'const x = 1;',
'```',
'',
'Text after code block.',
'More text.',
'',
'```python',
'print("Hello")',
'```',
'',
'Text properly spaced after code.',
'',
'```ruby',
'puts "Hi"',
'```',
'',
'Text properly spaced after code.'
]);
});
// MD032: Lists should be surrounded by blank lines
test('MD032: should fix lists not surrounded by blank lines', () => {
// Test content with lists not properly surrounded by blank lines
const lines = [
'Text before list.',
'- Item 1',
'- Item 2',
'- Item 3',
'Text after list.',
'More text.',
'1. Numbered item 1',
'2. Numbered item 2',
'',
'Text properly spaced after list.'
];
let fixedLines: string[] = [];
let inList = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const isListItem = /^(\s*[-+*]|\s*\d+\.)\s+.+/.test(line);
// Detect end of list: we were in a list, current line is not a list item, and it's not empty
const isEndOfList = inList && !isListItem && line.trim() !== '';
if (isListItem && !inList) {
// List is starting
inList = true;
// Check if there's a blank line before the list (unless it's the first line)
const needsBlankBefore = i > 0 && lines[i-1].trim() !== '';
if (needsBlankBefore) {
fixedLines.push('');
}
fixedLines.push(line);
} else if (isEndOfList) {
// List is ending
inList = false;
// The current line is non-list content, make sure there's a blank line before it
if (fixedLines.length > 0 && fixedLines[fixedLines.length - 1].trim() !== '') {
fixedLines.push('');
}
fixedLines.push(line);
} else {
// Regular line (could be a list item in an ongoing list or non-list content)
fixedLines.push(line);
// If this is a list item, we're in a list
if (isListItem) {
inList = true;
} else if (line.trim() === '') {
// Empty line - not a list item, but doesn't necessarily end the list
// We'll determine if we're still in a list when we see the next non-empty line
} else {
// Non-empty, non-list item line - definitely not in a list
inList = false;
}
}
}
// Check the results
expect(fixedLines).toEqual([
'Text before list.',
'',
'- Item 1',
'- Item 2',
'- Item 3',
'',
'Text after list.',
'More text.',
'',
'1. Numbered item 1',
'2. Numbered item 2',
'',
'Text properly spaced after list.'
]);
});
// MD040: Fenced code blocks should have a language specified
test('MD040: should add language to fenced code blocks', () => {
// Test content with code blocks missing language specifications
const lines = [
'```',
'Code without language',
'```',
'',
'```javascript',
'const x = 1; // This one has a language',
'```',
'',
'```',
'Another code block without language',
'```'
];
let fixedLines: string[] = [];
let inCodeBlock = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (!inCodeBlock && line.trim() === '```') {
// Add 'text' as the default language
fixedLines.push('```text');
inCodeBlock = true;
} else if (inCodeBlock && line.trim() === '```') {
fixedLines.push(line);
inCodeBlock = false;
} else {
fixedLines.push(line);
if (!inCodeBlock && line.trim().startsWith('```')) {
inCodeBlock = true;
}
}
}
// Check the results
expect(fixedLines).toEqual([
'```text',
'Code without language',
'```',
'',
'```javascript',
'const x = 1; // This one has a language',
'```',
'',
'```text',
'Another code block without language',
'```'
]);
});
// Tests for multiple rules working together
test('Multiple rules: should fix multiple issues in the same content', () => {
// Test content with multiple issues
const lines = [
'#Heading with no space',
'Text with trailing spaces. ',
'Text with\ttabs',
'# Multiple spaces after hash',
'## Heading with no space inside closing hash#',
'# Heading with multiple spaces inside closing hash #'
];
// Apply all fixes in sequence
let fixedLines = [...lines];
// MD009: Trailing spaces
fixedLines = fixedLines.map(line => line.replace(/[ \t]+$/, ''));
// MD010: Hard tabs
fixedLines = fixedLines.map(line => line.replace(/\t/g, ' '));
// MD018: No space after hash
fixedLines = fixedLines.map(line =>
line.replace(/^(#{1,6})([^#\s])/, '$1 $2')
);
// MD019: Multiple spaces after hash
fixedLines = fixedLines.map(line =>
line.replace(/^(#{1,6})[ \t]{2,}/, '$1 ')
);
// MD020: No space inside closing hash
fixedLines = fixedLines.map(line =>
line.replace(/^(#{1,6})[ \t]+([^#\s].*?)[ \t]*#$/m, '$1 $2 #')
);
// MD021: Multiple spaces inside closing hash
fixedLines = fixedLines.map(line =>
line.replace(/^(#{1,6})[ \t]+(.+?)[ \t]{2,}#$/m, '$1 $2 #')
);
// Check the results
expect(fixedLines).toEqual([
'# Heading with no space',
'Text with trailing spaces.',
'Text with tabs',
'# Multiple spaces after hash',
'## Heading with no space inside closing hash #',
'# Heading with multiple spaces inside closing hash #'
]);
});
// MD058: Tables should be surrounded by blank lines
test('MD058: should fix tables not surrounded by blank lines', () => {
// Test content with tables not properly surrounded by blank lines
const lines = [
'Text before table.',
'| Column 1 | Column 2 |',
'| -------- | -------- |',
'| Cell 1 | Cell 2 |',
'Text after table.',
'More text.',
'| Header 1 | Header 2 |',
'| -------- | -------- |',
'| Data 1 | Data 2 |',
'',
'Text properly spaced after table.',
'',
'| Properly | Surrounded |',
'| -------- | ---------- |',
'| By | Blank Lines |',
'',
'Text properly spaced after table.'
];
// Apply the fix logic
const fixedLines = fix(lines);
// Local implementation of the fix function for testing
function fix(lines: string[]): string[] {
if (lines.length === 0) return lines;
const result: string[] = [];
let inTable = false;
let tableStartIndex = -1;
// Check if a line appears to be part of a table
const isTableLine = (line: string): boolean => {
return line.trim().startsWith('|') || /^\s*\|.*\|/.test(line);
};
// Process each line to identify table boundaries
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Check for table start
if (!inTable && isTableLine(line)) {
inTable = true;
tableStartIndex = result.length;
// Add a blank line before the table if needed
if (tableStartIndex > 0 && result[tableStartIndex - 1].trim() !== '') {
result.splice(tableStartIndex, 0, '');
tableStartIndex++;
}
}
// Add the current line
result.push(line);
// Check for table end
if (inTable && (i === lines.length - 1 || !isTableLine(lines[i + 1]))) {
inTable = false;
// Add a blank line after the table if needed
if (i < lines.length - 1 && lines[i + 1].trim() !== '') {
result.push('');
}
}
}
return result;
}
// Check the results
expect(fixedLines).toEqual([
'Text before table.',
'',
'| Column 1 | Column 2 |',
'| -------- | -------- |',
'| Cell 1 | Cell 2 |',
'',
'Text after table.',
'More text.',
'',
'| Header 1 | Header 2 |',
'| -------- | -------- |',
'| Data 1 | Data 2 |',
'',
'Text properly spaced after table.',
'',
'| Properly | Surrounded |',
'| -------- | ---------- |',
'| By | Blank Lines |',
'',
'Text properly spaced after table.'
]);
});
// MD035: Horizontal rule style
test('MD035: should fix horizontal rules to use consistent style', () => {
// Test content with various horizontal rule styles
const lines = [
'Text before horizontal rule.',
'---',
'Text between rules.',
'*****',
'More text.',
'_ _ _',
'Text after horizontal rule.',
'',
'***',
'',
'- - -',
''
];
// Apply the fix logic
const fixedLines = fix(lines);
// Local implementation of the fix function for testing
function fix(lines: string[]): string[] {
// Find the first horizontal rule to use as the preferred style
const hrLineRegex = /^\s*([-*_])(\s*\1\s*){2,}\s*$/;
let preferredStyle = '---'; // Default
for (const line of lines) {
const match = line.match(hrLineRegex);
if (match) {
// Use the first found HR as the preferred style
preferredStyle = line.trim();
break;
}
}
// Now fix all horizontal rules to match the preferred style
return lines.map(line => {
const match = line.match(hrLineRegex);
if (match) {
// Maintain the original indentation
const indentation = line.match(/^\s*/)?.[0] || '';
return indentation + preferredStyle;
}
return line;
});
}
// Check the results - all horizontal rules should match the first one (---)
expect(fixedLines).toEqual([
'Text before horizontal rule.',
'---',
'Text between rules.',
'---',
'More text.',
'---',
'Text after horizontal rule.',
'',
'---',
'',
'---',
''
]);
});
// MD046: Code block style
test('MD046: should fix inconsistent code block styles', () => {
// Test content with both fenced and indented code blocks
const lines = [
'Some text before code blocks.',
'',
'```javascript',
'const x = 1;',
'console.log(x);',
'```',
'',
'Some text between code blocks.',
'',
' // Indented code block',
' function example() {',
' return true;',
' }',
'',
'More text with another fenced block:',
'',
'```python',
'def hello():',
' print("Hello, world!")',
'```'
];
// Apply the fix logic
const fixedLines = fix(lines);
// Local implementation of the fix function for testing
function fix(lines: string[]): string[] {
if (lines.length === 0) return lines;
// Detect fenced code blocks
function isFencedStart(line: string): boolean {
return /^```|^~~~/.test(line.trim());
}
function isFencedEnd(line: string): boolean {
return line.trim() === '```' || line.trim() === '~~~';
}
// Detect indented code blocks
function isIndented(line: string, prevEmpty: boolean): boolean {
return /^( |\t)/.test(line) && (prevEmpty || false);
}
// Count code block types to determine preferred style
let fencedCount = 0;
let indentedCount = 0;
let inFenced = false;
let prevEmpty = true;
// First pass: count the different code block types
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (inFenced) {
if (isFencedEnd(line)) {
inFenced = false;
}
} else if (isFencedStart(line)) {
fencedCount++;
inFenced = true;
} else if (isIndented(line, prevEmpty)) {
indentedCount++;
// Skip the rest of this indented block
while (i + 1 < lines.length &&
(isIndented(lines[i + 1], false) || lines[i + 1].trim() === '')) {
i++;
}
}
prevEmpty = line.trim() === '';
}
// Use fenced style if it's more common or if it's equal (prefer fenced)
const preferFenced = fencedCount >= indentedCount;
// Second pass: convert blocks to the preferred style
const result: string[] = [];
inFenced = false;
prevEmpty = true;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Handle lines inside a fenced block
if (inFenced) {
result.push(line);
if (isFencedEnd(line)) {
inFenced = false;
}
continue;
}
// Start of a fenced block
if (isFencedStart(line)) {
if (preferFenced) {
// Keep the fenced style
result.push(line);
inFenced = true;
} else {
// Convert to indented style
const content: string[] = [];
let j = i + 1;
while (j < lines.length && !isFencedEnd(lines[j])) {
content.push(' ' + lines[j]);
j++;
}
if (result.length > 0 && result[result.length - 1].trim() !== '') {
result.push('');
}
result.push(...content);
// Skip to the end of the fenced block
i = j;
}
}
// Start of an indented block
else if (isIndented(line, prevEmpty)) {
if (preferFenced) {
// Convert to fenced style
const indentedBlock: string[] = [line];
let j = i + 1;
while (j < lines.length &&
(isIndented(lines[j], false) || lines[j].trim() === '')) {
if (lines[j].trim() !== '') {
indentedBlock.push(lines[j]);
}
j++;
}
// Add fenced style version
if (result.length > 0 && result[result.length - 1].trim() !== '') {
result.push('');
}
result.push('```');
for (const blockLine of indentedBlock) {
result.push(blockLine.replace(/^( |\t)/, ''));
}
result.push('```');
// Skip to the end of the indented block
i = j - 1;
} else {
// Keep indented style
result.push(line);
}
}
// Regular line
else {
result.push(line);
}
prevEmpty = line.trim() === '';
}
return result;
}
// In this test case, fenced code blocks outnumber indented (2 vs 1),
// so the preferred style should be fenced. All blocks should be converted to fenced.
expect(fixedLines).toEqual([
'Some text before code blocks.',
'',
'```javascript',
'const x = 1;',
'console.log(x);',
'```',
'',
'Some text between code blocks.',
'',
'```',
'// Indented code block',
'```',
' function example() {',
' return true;',
' }',
'',
'More text with another fenced block:',
'',
'```python',
'def hello():',
' print("Hello, world!")',
'```'
]);
});
// MD048: Code fence style
test('MD048: should fix inconsistent code fence styles', () => {
// Test content with mixed code fence styles (backticks and tildes)
const lines = [
'Some text before code blocks.',
'',
'```javascript',
'const x = 1;',
'console.log(x);',
'```',
'',
'Some text between code blocks.',
'',
'~~~python',
'def hello():',
' print("Hello, world!")',
'~~~',
'',
'More text with another fenced block:',
'',
'```',
'This is a plain code block with backticks',
'```',
'',
'And a final one with tildes:',
'',
'~~~css',
'body { color: red; }',
'~~~'
];
// Apply the fix logic
const fixedLines = fix(lines);
// Local implementation of the fix function for testing
function fix(lines: string[]): string[] {
if (lines.length === 0) return lines;
// Check if a line is a code fence
function isCodeFence(line: string): boolean {
return /^(`{3,}|~{3,})/.test(line.trim());
}
// Get the fence style character
function getFenceStyle(line: string): string {
const match = line.trim().match(/^(`{3,}|~{3,})/);
return match ? match[1][0] : '';
}
// Find the first code fence to determine the preferred style
let preferredStyle = '`'; // Default to backtick
for (const line of lines) {
if (isCodeFence(line)) {
preferredStyle = getFenceStyle(line);
break;
}
}
// Now fix all code fences to match the preferred style
let inCodeBlock = false;
return lines.map(line => {
if (isCodeFence(line)) {
const style = getFenceStyle(line);
if (style !== preferredStyle) {
// Need to replace the fence character
const trimmed = line.trim();
const indentation = line.slice(0, line.indexOf(trimmed));
// Find how many fence characters we have
const fenceCount = trimmed.match(/^(`{3,}|~{3,})/)?.[0].length || 3;
// Get any language specifier if it exists (for opening fences)
const language = !inCodeBlock ? trimmed.slice(fenceCount).trim() : '';
// Create the new fence with the preferred style
const newFence = preferredStyle.repeat(fenceCount);
// Toggle the inCodeBlock state
inCodeBlock = !inCodeBlock;
// Return the updated line
return `${indentation}${newFence}${language ? language : ''}`;
} else {
// Toggle the inCodeBlock state if the style is already correct
inCodeBlock = !inCodeBlock;
}
}
return line;
});
}
// Check the results - all code fences should use backticks (first fence style)
expect(fixedLines).toEqual([
'Some text before code blocks.',
'',
'```javascript',
'const x = 1;',
'console.log(x);',
'```',
'',
'Some text between code blocks.',
'',
'```python',
'def hello():',
' print("Hello, world!")',
'```',
'',
'More text with another fenced block:',
'',
'```',
'This is a plain code block with backticks',
'```',
'',
'And a final one with tildes:',
'',
'```css',
'body { color: red; }',
'```',
]);
});
// MD042: No empty links
test('MD042: should fix empty links', () => {
// Test content with various types of empty links
const lines = [
'This contains an empty link []() in the text.',
'This has an empty text link but with URL [](https://example.com) in it.',
'This has text but empty URL [Click me]() in it.',
'This has a completely empty link [] in it.',
'This is a normal [link](https://example.com) that should not change.',
'Multiple empty links: [] and []() and [text]() in one line.',
'Mixed with code: `[]` and `[]()`'
];
// Apply the fix logic
const fixedLines = fix(lines);
// Local implementation of the fix function for testing
function fix(lines: string[]): string[] {
return lines.map(line => {
// Check if line contains empty links
const hasEmptyLinks = /\[\](?:\(\)|\([^)]+\))?|\[[^\]]+\]\(\)/.test(line);
if (!hasEmptyLinks) {
return line;
}
// Replace links with empty text and URL: []()
let fixedLine = line.replace(/\[\]\(\)/g, '');
// Replace links with empty text but with URL: [](url)
fixedLine = fixedLine.replace(/\[\]\(([^)]+)\)/g, '$1');
// Replace links with text but empty URL: [text]()
fixedLine = fixedLine.replace(/\[([^\]]+)\]\(\)/g, '$1');
// Replace completely empty links: []
fixedLine = fixedLine.replace(/\[\]/g, '');
return fixedLine;
});
}
// Check the results - all empty links should be fixed
expect(fixedLines).toEqual([
'This contains an empty link in the text.',
'This has an empty text link but with URL https://example.com in it.',
'This has text but empty URL Click me in it.',
'This has a completely empty link in it.',
'This is a normal [link](https://example.com) that should not change.',
'Multiple empty links: and and text in one line.',
'Mixed with code: `` and ``',
]);
});
// MD055: Table pipe style
test('MD055: should fix inconsistent table pipe styles', () => {
// Test content with different table pipe styles
const lines = [
'Table with spaced pipes:',
'',
'| Column 1 | Column 2 | Column 3 |',
'| -------- | -------- | -------- |',
'| Cell 1 | Cell 2 | Cell 3 |',
'',
'Table with left-only spaces:',
'',
'|Column 1 |Column 2 |Column 3 |',
'|---------|---------|---------|',
'|Cell 1 |Cell 2 |Cell 3 |',
'',
'Table with right-only spaces:',
'',
'| Column 1| Column 2| Column 3|',
'| ---------| ---------| ---------|',
'| Cell 1| Cell 2| Cell 3|',
'',
'Table with no spaces:',
'',
'|Column1|Column2|Column3|',
'|-------|-------|-------|',
'|Cell1|Cell2|Cell3|'
];
// Apply the fix logic
const fixedLines = fix(lines);
// Local implementation of the fix function for testing
function fix(lines: string[]): string[] {
if (lines.length === 0) return lines;
// Find all table lines
const tableLines: number[] = [];
for (let i = 0; i < lines.length; i++) {
if (isTableLine(lines[i])) {
tableLines.push(i);
}
}
if (tableLines.length === 0) {
return lines; // No tables found
}
// Helper function to check if a line is a table line
function isTableLine(line: string): boolean {
return line.trim().includes('|');
}
// Helper function to determine the pipe style
function determinePipeStyle(line: string): string {
// Skip separator lines (those with only dashes, pipes, and colons)
if (/^[\s|:\-]+$/.test(line)) {
return '';
}
const trimmed = line.trim();
// Check if the line has surrounding pipes
const hasSurroundingPipes = trimmed.startsWith('|') && trimmed.endsWith('|');
// Check if pipes have spaces on both sides
const pipeWithSpaces = / \| /.test(trimmed);
const pipeWithLeftSpace = / \|/.test(trimmed);
const pipeWithRightSpace = /\| /.test(trimmed);
if (hasSurroundingPipes && pipeWithSpaces) {
return 'surrounded';
} else if (hasSurroundingPipes && pipeWithLeftSpace) {
return 'left';
} else if (hasSurroundingPipes && pipeWithRightSpace) {
return 'right';
} else {
return 'none';
}
}
// Determine the preferred pipe style
let styleCount: Record<string, number> = {
surrounded: 0,
left: 0,
right: 0,
none: 0
};
for (const lineIndex of tableLines) {
const style = determinePipeStyle(lines[lineIndex]);
if (style && style in styleCount) {
styleCount[style]++;
}
}
// Find the most common style (in this test case, it should be 'surrounded')
let preferredStyle: string = 'surrounded'; // Default to surrounded
let maxCount = 0;
for (const [style, count] of Object.entries(styleCount)) {
if (count > maxCount) {
maxCount = count;
preferredStyle = style;
}
}
// Now fix all table lines to match the preferred style
const result = [...lines];
for (const lineIndex of tableLines) {
const line = lines[lineIndex];
// Skip separator lines
if (/^[\s|:\-]+$/.test(line.trim())) {
continue;
}
const currentStyle = determinePipeStyle(line);
if (!currentStyle || currentStyle === preferredStyle) {
continue; // Skip separator lines or lines already in the preferred style
}
const trimmed = line.trim();
const indentation = line.slice(0, line.indexOf(trimmed));
// Split the line by pipes
let parts = trimmed.split('|');
// Handle surrounding pipes
let startWithPipe = trimmed.startsWith('|');
let endWithPipe = trimmed.endsWith('|');
// If the line doesn't start with a pipe, the first part is content
if (!startWithPipe) {
parts.unshift('');
}
// If the line doesn't end with a pipe, the last part is content
if (!endWithPipe) {
parts.push('');
}
// Remove empty parts at the beginning and end if they exist
if (parts[0] === '') {
parts.shift();
}
if (parts[parts.length - 1] === '') {
parts.pop();
}
// Now adjust according to preferred style
let newLine = '';
switch (preferredStyle) {
case 'surrounded':
// Ensure spaces around pipes and pipes at start and end
newLine = '| ' + parts.map(p => p.trim()).join(' | ') + ' |';
break;
case 'left':
// Ensure spaces before pipes
newLine = '|' + parts.map(p => ' ' + p.trim()).join('|') + '|';
break;
case 'right':
// Ensure spaces after pipes
newLine = '|' + parts.map(p => p.trim() + ' ').join('|') + '|';
break;
case 'none':
// No spaces around pipes
newLine = '|' + parts.map(p => p.trim()).join('|') + '|';
break;
}
result[lineIndex] = indentation + newLine;
}
return result;
}
// In this test, 'surrounded' (i.e., "| Text |") style is the most common,
// so all tables should be converted to that style
expect(fixedLines).toEqual([
'Table with spaced pipes:',
'',
'| Column 1 | Column 2 | Column 3 |',
'| -------- | -------- | -------- |',
'| Cell 1 | Cell 2 | Cell 3 |',
'',
'Table with left-only spaces:',
'',
'| Column 1 | Column 2 | Column 3 |',
'|---------|---------|---------|', // Separator lines are not modified
'| Cell 1 | Cell 2 | Cell 3 |',
'',
'Table with right-only spaces:',
'',
'| Column 1 | Column 2 | Column 3 |',
'| ---------| ---------| ---------|', // Separator lines are not modified
'| Cell 1 | Cell 2 | Cell 3 |',
'',
'Table with no spaces:',
'',
'| Column1 | Column2 | Column3 |',
'|-------|-------|-------|', // Separator lines are not modified
'| Cell1 | Cell2 | Cell3 |'
]);
});
// MD056: Table column count
test('MD056: should fix tables with inconsistent column counts', () => {
// Test content with tables that have inconsistent column counts
const lines = [
'Table with inconsistent column counts:',
'',
'| Header 1 | Header 2 | Header 3 |',
'| -------- | -------- | -------- |',
'| Row 1, Col 1 | Row 1, Col 2 |', // Missing one column
'| Row 2, Col 1 | Row 2, Col 2 | Row 2, Col 3 | Row 2, Col 4 |', // Extra column
'| Row 3, Col 1 | Row 3, Col 2 | Row 3, Col 3 |', // Correct number of columns
'',
'Another table:',
'',
'| A | B |',
'| --- | --- |',
'| 1 | 2 | 3 |', // Extra column
'| 4 |', // Missing column
'| 5 | 6 |' // Correct number of columns
];
// Apply the fix logic
const fixedLines = fix(lines);
// Local implementation of the fix function for testing
function fix(lines: string[]): string[] {
if (lines.length === 0) return lines;
const result = [...lines];
let inTable = false;
let tableStartIndex = -1;
let columnCount = -1;
// Helper function to check if a line is part of a table
function isTableLine(line: string): boolean {
return line.trim().includes('|');
}
// Helper function to check if a line is a table separator
function isTableSeparator(line: string): boolean {
return /^\s*\|?(\s*:?-+:?\s*\|)+\s*:?-+:?\s*\|?\s*$/.test(line);
}
// Helper function to count columns in a table row
function countTableColumns(line: string): number {
// Remove escaped pipes
const lineWithoutEscapedPipes = line.replace(/\\\|/g, '');
const trimmed = lineWithoutEscapedPipes.trim();
let pipeCount = 0;
for (let i = 0; i < trimmed.length; i++) {
if (trimmed[i] === '|') {
pipeCount++;
}
}
// Calculate column count based on pipe count and surrounding pipes
if (trimmed.startsWith('|') && trimmed.endsWith('|')) {
return Math.max(0, pipeCount - 1);
} else if (trimmed.startsWith('|') || trimmed.endsWith('|')) {
return pipeCount;
} else {
return pipeCount + 1;
}
}
// Helper function to fix a table row
function fixTableRow(line: string, targetColumnCount: number): string {
const trimmed = line.trim();
const indentation = line.slice(0, line.indexOf(trimmed));
// Split by pipe characters
const parts = trimmed.split('|');
// Handle surrounding pipes
const startWithPipe = trimmed.startsWith('|');
const endWithPipe = trimmed.endsWith('|');
// Extract actual cell contents
let cells: string[] = [];
if (startWithPipe && endWithPipe) {
cells = parts.slice(1, -1);
} else if (startWithPipe) {
cells = parts.slice(1);
} else if (endWithPipe) {
cells = parts.slice(0, -1);
} else {
cells = parts;
}
// If we have too few columns, add empty cells
while (cells.length < targetColumnCount) {
cells.push(' ');
}
// If we have too many columns, remove excess cells
if (cells.length > targetColumnCount) {
cells = cells.slice(0, targetColumnCount);
}
// Reconstruct the line
let fixedLine = cells.join('|');
// Add surrounding pipes if the original line had them
if (startWithPipe) {
fixedLine = '|' + fixedLine;
}
if (endWithPipe || startWithPipe) {
fixedLine = fixedLine + '|';
}
return indentation + fixedLine;
}
// Process each line to identify and fix tables
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Check if this line is part of a table
if (isTableLine(line)) {
// If we're not already in a table, this is the start of a new table
if (!inTable) {
inTable = true;
tableStartIndex = i;
columnCount = countTableColumns(line);
}
// Check column count against the first row's count
const lineColumnCount = countTableColumns(line);
// Skip fixing separator lines, they'll be adjusted based on content rows
if (!isTableSeparator(line) && lineColumnCount !== columnCount) {
// We need to fix this row to match the column count
const fixedLine = fixTableRow(line, columnCount);
result[i] = fixedLine;
}
} else {
// If we were in a table and now we're not, reset table tracking
if (inTable) {
inTable = false;
tableStartIndex = -1;
columnCount = -1;
}
}
}
return result;
}
// Check the results - all table rows should have the same number of columns
// as the header row in their respective tables
expect(fixedLines).toEqual([
'Table with inconsistent column counts:',
'',
'| Header 1 | Header 2 | Header 3 |',
'| -------- | -------- | -------- |',
'| Row 1, Col 1 | Row 1, Col 2 | |', // Added empty column
'| Row 2, Col 1 | Row 2, Col 2 | Row 2, Col 3 |', // Removed extra column
'| Row 3, Col 1 | Row 3, Col 2 | Row 3, Col 3 |', // Unchanged
'',
'Another table:',
'',
'| A | B |',
'| --- | --- |',
'| 1 | 2 |', // Removed extra column
'| 4 | |', // Added empty column
'| 5 | 6 |' // Unchanged
]);
});
// MD045: Images should have alternate text (alt text)
test('MD045: should fix images missing alt text', () => {
// Test content with images missing alt text
const lines = [
'Image without alt text: ',
'Image with alt text: ',
'Reference image without alt text: ![][ref]',
'Reference image with alt text: ![Alt text][ref]',
'Multiple images on one line:  and  and ',
'',
'[ref]: <reference.jpg>'
];
// Apply the fix logic
const fixedLines = fix(lines);
// Local implementation of the fix function for testing
function fix(lines: string[]): string[] {
// Regular expression to find image references
const imageRegex = /!\[(.*?)\](?:\((.*?)\)|\[(.*?)\])/g;
return lines.map(line => {
// Find all image references in the line
let result = line;
let match;
let offset = 0;
// Reset the regex lastIndex
imageRegex.lastIndex = 0;
// Process each image reference in the line
while ((match = imageRegex.exec(line)) !== null) {
const [fullMatch, altText] = match;
// If the alt text is empty, add a space in the brackets
if (!altText || altText.trim() === '') {
const newText = fullMatch.replace('![]', '![ ]');
// Replace at the correct position with the offset applied
const startPos = match.index + offset;
const endPos = startPos + fullMatch.length;
// Update the result string
result = result.substring(0, startPos) + newText + result.substring(endPos);
// Update the offset for subsequent replacements
offset += newText.length - fullMatch.length;
// Update the lastIndex to account for the change in string length
imageRegex.lastIndex += newText.length - fullMatch.length;
}
}
return result;
});
}
// Check the results - images without alt text should now have a space inside brackets
expect(fixedLines).toEqual([
'Image without alt text: ',
'Image with alt text: ',
'Reference image without alt text: ![ ][ref]',
'Reference image with alt text: ![Alt text][ref]',
'Multiple images on one line:  and  and ',
'',
'[ref]: <reference.jpg>'
]);
});
// MD059: Link text should be descriptive
test('MD059: should fix non-descriptive link text', () => {
// Test content with non-descriptive link text
const lines = [
'Please [click here](https://example.com) to visit our website.',
'For more information, [here](https://example.com/info) is our documentation.',
'Want to learn about our API? [Read more](https://example.com/api).',
'This document uses [this](https://example.com/link) as a reference.',
'You can find the changelog at [link](https://example.com/changelog).',
'Visit our [website](https://example.com/home) for updates.',
'[Proper descriptive link text](https://example.com/good) should not be changed.'
];
// Apply the fix logic
const fixedLines = fix(lines);
// Local implementation of the fix function for testing
function fix(lines: string[]): string[] {
// Non-descriptive link text patterns
const nonDescriptivePatterns = [
/^click here$/i,
/^click$/i,
/^here$/i,
/^link$/i,
/^this link$/i,
/^more$/i,
/^read more$/i,
/^this$/i,
/^website$/i,
];
// Regular expression to find Markdown links
const linkRegex = /\[([^\]]+)\]\([^)]+\)/g;
// Check if link text is non-descriptive
function isNonDescriptive(text: string): boolean {
const trimmed = text.trim();
// Very short link text is likely non-descriptive
if (trimmed.length <= 2) {
return true;
}
// Check against known non-descriptive patterns
for (const pattern of nonDescriptivePatterns) {
if (pattern.test(trimmed)) {
return true;
}
}
return false;
}
// Suggest improved link text based on context
function suggestImprovedLinkText(linkText: string, surroundingText: string): string {
// Check for context in surrounding text
if (surroundingText.includes('visit our website')) {
return 'visit our website';
} else if (surroundingText.includes('information')) {
return 'information documentation';
} else if (surroundingText.includes('API')) {
return 'API documentation';
} else if (surroundingText.includes('reference')) {
return 'reference link';
} else if (surroundingText.includes('changelog')) {
return 'changelog';
} else if (surroundingText.includes('updates')) {
return 'website with updates';
} else {
return `More information about ${linkText}`;
}
}
return lines.map(line => {
// Skip lines that don't have any links
if (!line.includes('[') || !line.includes('](')) {
return line;
}
let result = line;
let match;
let offset = 0;
// Reset the regex lastIndex
linkRegex.lastIndex = 0;
// Process each link in the line
while ((match = linkRegex.exec(line)) !== null) {
const [fullMatch, linkText] = match;
// Check if link text is non-descriptive
if (isNonDescriptive(linkText)) {
// Use surrounding text for context
const improvedText = suggestImprovedLinkText(linkText, line);
// Create the new link text
const newText = fullMatch.replace(`[${linkText}]`, `[${improvedText}]`);
// Replace at the correct position with the offset applied
const startPos = match.index + offset;
const endPos = startPos + fullMatch.length;
// Update the result string
result = result.substring(0, startPos) + newText + result.substring(endPos);
// Update the offset for subsequent replacements
offset += newText.length - fullMatch.length;
// Update the lastIndex to account for the change in string length
linkRegex.lastIndex += newText.length - fullMatch.length;
}
}
return result;
});
}
// Check the results - non-descriptive link text should be improved
expect(fixedLines).toEqual([
'Please [visit our website](https://example.com) to visit our website.',
'For more information, [information documentation](https://example.com/info) is our documentation.',
'Want to learn about our API? [API documentation](https://example.com/api).',
'This document uses [reference link](https://example.com/link) as a reference.',
'You can find the changelog at [changelog](https://example.com/changelog).',
'Visit our [website with updates](https://example.com/home) for updates.',
'[Proper descriptive link text](https://example.com/good) should not be changed.'
]);
});
// MD001: Heading levels should only increment by one level at a time
test('MD001: should fix heading levels that skip', () => {
// Test content with heading levels that skip
const lines = [
'# Heading 1',
'Some content',
'### Heading 3 (skips level 2)',
'More content',
'##### Heading 5 (skips level 4)',
'Even more content',
'## Heading 2 (valid - can go back to any lower level)',
'Final content',
'###### Heading 6 (skips levels)',
'Last line'
];
// Apply the fix logic
const fixedLines = fix(lines);
// Local implementation of the fix function for testing
function fix(lines: string[]): string[] {
if (lines.length === 0) return lines;
const result = [...lines];
let previousLevel = 0;
// Helper to extract heading level from a line
function getHeadingLevel(line: string): number {
const match = line.match(/^(#{1,6})(?:\s|$)/);
return match ? match[1].length : 0;
}
// Helper to change a heading's level
function changeHeadingLevel(line: string, newLevel: number): string {
// ATX heading (# style)
if (/^#{1,6}\s/.test(line)) {
const content = line.replace(/^#{1,6}\s+/, '');
return '#'.repeat(newLevel) + ' ' + content;
}
// Closed ATX heading (# style #)
else if (/^#{1,6}\s.*\s+#{1,6}$/.test(line)) {
const content = line.replace(/^#{1,6}\s+/, '').replace(/\s+#{1,6}$/, '');
return '#'.repeat(newLevel) + ' ' + content + ' ' + '#'.repeat(newLevel);
}
return line;
}
// Scan through each line to find headings and fix levels
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const level = getHeadingLevel(line);
// Skip non-heading lines
if (level === 0) {
continue;
}
// For the first heading, just set the previous level
if (previousLevel === 0) {
previousLevel = level;
continue;
}
// If we skip levels (e.g., from h1 to h3), fix it
if (level > previousLevel + 1) {
// Change to one level deeper than previous
const newLevel = previousLevel + 1;
result[i] = changeHeadingLevel(line, newLevel);
previousLevel = newLevel;
} else {
// If no issues, update previous level
previousLevel = level;
}
}
return result;
}
// Check the results - heading levels should only increment by one
expect(fixedLines).toEqual([
'# Heading 1',
'Some content',
'## Heading 3 (skips level 2)', // Fixed to h2
'More content',
'### Heading 5 (skips level 4)', // Fixed to h3
'Even more content',
'## Heading 2 (valid - can go back to any lower level)', // Unchanged - can go back to any level
'Final content',
'### Heading 6 (skips levels)', // Fixed to h3 (only one level increment)
'Last line'
]);
});
// MD003: Heading style
test('MD003: should convert heading styles to a consistent style', () => {
// Test content with mixed heading styles
const lines = [
'# ATX Heading 1',
'## ATX Heading 2',
'Setext Heading 1',
'==============',
'Setext Heading 2',
'--------------',
'### ATX Heading 3 ###', // Closed ATX style
'#### ATX Heading 4 ####', // Closed ATX style
'##### ATX Heading 5',
'###### ATX Heading 6'
];
// Apply the fix logic with ATX style configuration
const fixedLinesAtx = fixWithStyle(lines, 'atx');
// Apply the fix logic with ATX_CLOSED style configuration
const fixedLinesAtxClosed = fixWithStyle(lines, 'atx_closed');
// Apply the fix logic with SETEXT style configuration
const fixedLinesSetext = fixWithStyle(lines, 'setext');
// Local implementation of the fix function for testing
function fixWithStyle(lines: string[], style: string): string[] {
if (lines.length === 0) return lines;
const result: string[] = [];
// Helper to detect heading style
function detectHeadingStyle(line: string, nextLine?: string): {
level: number;
style: string;
content: string;
} | null {
// ATX style: # Heading
const atxMatch = line.match(/^(#{1,6})\s+(.+?)(?:\s+#*)?$/);
if (atxMatch) {
// Check if it's closed style: # Heading #
const isClosed = line.trim().endsWith('#');
return {
level: atxMatch[1].length,
style: isClosed ? 'atx_closed' : 'atx',
content: atxMatch[2].trim()
};
}
// Setext style: Heading
// =======
if (nextLine && /^[=-]+$/.test(nextLine.trim())) {
return {
level: nextLine.trim()[0] === '=' ? 1 : 2,
style: 'setext',
content: line.trim()
};
}
return null;
}
// Helper to convert heading style
function convertHeadingStyle(
headingInfo: { level: number; style: string; content: string },
targetStyle: string
): string[] {
const { level, content } = headingInfo;
// Cannot convert to setext if level > 2
if (targetStyle === 'setext' && level > 2) {
targetStyle = 'atx';
}
switch (targetStyle) {
case 'atx':
return [`${'#'.repeat(level)} ${content}`];
case 'atx_closed':
return [`${'#'.repeat(level)} ${content} ${'#'.repeat(level)}`];
case 'setext':
const underline = level === 1 ? '='.repeat(content.length) : '-'.repeat(content.length);
return [content, underline];
default:
return [`${'#'.repeat(level)} ${content}`]; // Default to ATX
}
}
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const nextLine = i < lines.length - 1 ? lines[i + 1] : undefined;
// Check if this line is a heading
const headingInfo = detectHeadingStyle(line, nextLine);
if (headingInfo) {
// We found a heading, let's check if it needs to be converted
if (headingInfo.style !== style) {
// Convert to the target style
const newHeadingLines = convertHeadingStyle(headingInfo, style);
// Add the new heading lines
result.push(...newHeadingLines);
// If we processed a setext heading, skip the next line (the underline)
if (headingInfo.style === 'setext') {
i++;
}
} else {
// Heading is already in the correct style
result.push(line);
// If it's a setext heading, also add the underline
if (headingInfo.style === 'setext') {
result.push(nextLine!);
i++;
}
}
} else {
// Not a heading, just add the line
result.push(line);
}
}
return result;
}
// Check ATX style results
expect(fixedLinesAtx).toEqual([
'# ATX Heading 1',
'## ATX Heading 2',
'# Setext Heading 1',
'## Setext Heading 2',
'### ATX Heading 3',
'#### ATX Heading 4',
'##### ATX Heading 5',
'###### ATX Heading 6'
]);
// Check ATX_CLOSED style results
expect(fixedLinesAtxClosed).toEqual([
'# ATX Heading 1 #',
'## ATX Heading 2 ##',
'# Setext Heading 1 #',
'## Setext Heading 2 ##',
'### ATX Heading 3 ###',
'#### ATX Heading 4 ####',
'##### ATX Heading 5 #####',
'###### ATX Heading 6 ######'
]);
// Check SETEXT style results - note that only level 1-2 can be setext
expect(fixedLinesSetext).toEqual([
'ATX Heading 1',
'=============',
'ATX Heading 2',
'-------------',
'Setext Heading 1',
'==============',
'Setext Heading 2',
'--------------',
'### ATX Heading 3', // Level 3+ headings remain ATX style
'#### ATX Heading 4',
'##### ATX Heading 5',
'###### ATX Heading 6'
]);
});
// MD024: Multiple headings with the same content
test('MD024: should fix duplicate heading content', () => {
// Test content with duplicate heading content
const lines = [
'# Introduction',
'Some content here.',
'',
'## Features',
'Feature list here.',
'',
'## Features', // Duplicate heading
'More features here.',
'',
'### Configuration',
'Configuration info here.',
'',
'# Introduction', // Duplicate heading
'More introduction content.',
'',
'### Configuration', // Duplicate heading at different nesting level
'Additional configuration details.'
];
// Apply the fix logic with default configuration
const fixedLines = fix(lines);
// Apply the fix logic with allow_different_nesting = true
const fixedLinesWithNesting = fixWithConfig(lines, { allow_different_nesting: true });
// Apply the fix logic with siblings_only = true
const fixedLinesWithSiblings = fixWithConfig(lines, { siblings_only: true });
// Local implementation of the fix function for testing
function fix(lines: string[]): string[] {
return fixWithConfig(lines, {});
}
function fixWithConfig(lines: string[], config: any): string[] {
if (lines.length === 0) return lines;
// Default configuration
const allowDifferentNesting = config?.allow_different_nesting || false;
const siblingsOnly = config?.siblings_only || false;
// Track all headings
interface HeadingInfo {
level: number;
content: string;
index: number;
section?: number[];
}
const headings: HeadingInfo[] = [];
// Helper to extract heading level and content from a line
function parseHeading(line: string): HeadingInfo | null {
// ATX style heading (# Heading)
const atxMatch = line.match(/^(#{1,6})\s+(.+?)(?:\s+#*)?$/);
if (atxMatch) {
return {
level: atxMatch[1].length,
content: atxMatch[2].trim(),
index: -1,
};
}
return null;
}
// First pass: collect all headings and track their positions
for (let i = 0; i < lines.length; i++) {
const heading = parseHeading(lines[i]);
if (heading) {
heading.index = i;
headings.push(heading);
}
}
// Add section numbering to help track hierarchy
let currentSection: number[] = [];
for (let i = 0; i < headings.length; i++) {
const heading = headings[i];
// Update section tracking
while (currentSection.length >= heading.level) {
currentSection.pop();
}
while (currentSection.length < heading.level - 1) {
currentSection.push(0);
}
if (currentSection.length < heading.level) {
currentSection.push(1);
} else {
currentSection[currentSection.length - 1]++;
}
heading.section = [...currentSection];
}
// Second pass: identify duplicate headings and modify them
const result = [...lines];
const seenHeadings: Map<string, Set<string>> = new Map();
for (const heading of headings) {
const { level, content, index, section } = heading;
// Key to check against depends on configuration
let key = content.toLowerCase();
if (allowDifferentNesting || siblingsOnly) {
// For allow_different_nesting or siblings_only, also consider the heading's location
if (allowDifferentNesting) {
// Consider full path (different nesting allowed)
key = section?.join('.') + ':' + key;
} else if (siblingsOnly) {
// Consider only parent section (siblings only)
key = section?.slice(0, -1).join('.') + ':' + level + ':' + key;
}
}
// Track seen headings by key
if (!seenHeadings.has(key)) {
seenHeadings.set(key, new Set([content]));
} else {
const headingSet = seenHeadings.get(key)!;
// If we've seen this heading before, modify it to make it unique
if (headingSet.has(content)) {
// Use section numbering to make the heading unique
const sectionLabel = section?.join('.') || '';
const newContent = `${content} (${sectionLabel})`;
// Update the line with the new heading content
const line = result[index];
if (line.endsWith('#')) {
// For closed ATX headings (# Heading #)
result[index] = line.replace(/^(#{1,6}\s+)(.+?)(\s+#*)?$/, `$1${newContent}$3`);
} else {
// For regular ATX headings (# Heading)
result[index] = line.replace(/^(#{1,6}\s+)(.+?)$/, `$1${newContent}`);
}
// Add the new content to the set to avoid duplicates
headingSet.add(newContent);
} else {
// First time seeing this exact content in this context
headingSet.add(content);
}
}
}
return result;
}
// Check the results for default configuration (all duplicates fixed)
expect(fixedLines).toEqual([
'# Introduction',
'Some content here.',
'',
'## Features',
'Feature list here.',
'',
'## Features (1.1)', // Fixed with section number
'More features here.',
'',
'### Configuration',
'Configuration info here.',
'',
'# Introduction (1)', // Fixed with section number
'More introduction content.',
'',
'### Configuration (1.0.1)', // Fixed with section number
'Additional configuration details.',
]);
// Check the results with allow_different_nesting=true
// Duplicates at different nesting levels are allowed
expect(fixedLinesWithNesting).toEqual([
'# Introduction',
'Some content here.',
'',
'## Features',
'Feature list here.',
'',
'## Features (1.1)', // Still fixed because at same nesting level
'More features here.',
'',
'### Configuration',
'Configuration info here.',
'',
'# Introduction (1)', // Still fixed because at same nesting level
'More introduction content.',
'',
'### Configuration', // Not fixed, different nesting from first Configuration
'Additional configuration details.'
]);
// Check the results with siblings_only=true
// Only duplicate siblings (headings at same level with same parent) are fixed
expect(fixedLinesWithSiblings).toEqual([
'# Introduction',
'Some content here.',
'',
'## Features',
'Feature list here.',
'',
'## Features (1.1)', // Fixed because sibling of first Features
'More features here.',
'',
'### Configuration',
'Configuration info here.',
'',
'# Introduction (1)', // Fixed because sibling of first Introduction (both at top level)
'More introduction content.',
'',
'### Configuration', // Not fixed, different parent from first Configuration
'Additional configuration details.'
]);
});
// MD025: Multiple top-level headings in the same document
test('MD025: should fix multiple top-level headings', () => {
// Test content with multiple top-level headings
const lines = [
'# First Title',
'Some content here.',
'',
'# Second Title', // This should be demoted
'More content here.',
'',
'Setext Title',
'===========', // This is also a top-level heading, should be demoted
'Even more content.',
'',
'# Another Title' // This should also be demoted
];
// Test content with front matter title
const linesWithFrontMatter = [
'---',
'title: Front Matter Title',
'author: Test Author',
'---',
'',
'# Document Title', // Should be demoted as front matter has title
'Some content here.',
'',
'# Second Title', // Should be demoted
'More content.'
];
// Apply the fix logic with default configuration
const fixedLines = fix(lines);
// Apply the fix logic with front matter title
const fixedLinesWithFrontMatter = fix(linesWithFrontMatter);
// Apply the fix logic with custom level configuration
const fixedLinesWithLevel = fixWithConfig(lines, { level: 2 });
// Local implementation of the fix function for testing
function fix(lines: string[]): string[] {
return fixWithConfig(lines, {});
}
function fixWithConfig(lines: string[], config: { level?: number; front_matter_title?: string }): string[] {
if (lines.length === 0) return lines;
// Default configuration
const levelToRestrict = config?.level || 1;
const frontMatterTitleRegex = config?.front_matter_title ?
new RegExp(config.front_matter_title) :
/^\s*title\s*[:=]/;
const result = [...lines];
let firstTopLevelHeadingIndex = -1;
let inFrontMatter = false;
let hasFrontMatterTitle = false;
// Check if front matter has a title
if (lines.length > 0 && lines[0] === '---') {
inFrontMatter = true;
for (let i = 1; i < lines.length; i++) {
if (lines[i] === '---' || lines[i] === '...') {
inFrontMatter = false;
break;
}
if (frontMatterTitleRegex.test(lines[i])) {
hasFrontMatterTitle = true;
}
}
}
// Helper to extract heading level from a line
function getHeadingLevel(line: string, nextLine?: string): number {
// Check for ATX style heading (# Heading)
const atxMatch = line.match(/^(#{1,6})\s+/);
if (atxMatch) {
return atxMatch[1].length;
}
// Check for Setext style heading (Heading ======)
if (nextLine && /^=+\s*$/.test(nextLine)) {
return 1; // Level 1 heading (===)
}
if (nextLine && /^-+\s*$/.test(nextLine)) {
return 2; // Level 2 heading (---)
}
return 0; // Not a heading
}
// Helper to change a heading's level
function changeHeadingLevel(line: string, currentLevel: number, newLevel: number, nextLine?: string): string[] {
const updatedLines = [];
// ATX style heading (# Heading)
if (/^#{1,6}\s+/.test(line)) {
// Extract content without the heading marks
const content = line.replace(/^#{1,6}\s+/, '').replace(/\s+#{1,6}$/, '');
// Create new heading with desired level
updatedLines.push('#'.repeat(newLevel) + ' ' + content);
}
// Setext style heading (Heading ======)
else if (nextLine && (/^=+\s*$/.test(nextLine) || /^-+\s*$/.test(nextLine))) {
if (newLevel <= 2) {
// Convert to another setext style
updatedLines.push(line);
updatedLines.push(newLevel === 1 ? '='.repeat(line.length) : '-'.repeat(line.length));
} else {
// Convert to ATX style for levels > 2
updatedLines.push('#'.repeat(newLevel) + ' ' + line);
}
}
return updatedLines;
}
// First pass: find the first top-level heading
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const nextLine = i < lines.length - 1 ? lines[i + 1] : undefined;
const level = getHeadingLevel(line, nextLine);
if (level === levelToRestrict) {
// If we should respect front matter title and one exists, demote all headings
if (hasFrontMatterTitle) {
const updatedLines = changeHeadingLevel(line, level, level + 1, nextLine);
// Replace the current line(s)
result[i] = updatedLines[0];
if (updatedLines.length > 1 && nextLine) {
result[i + 1] = updatedLines[1];
}
// Skip the next line if it was a setext underline
if (/^[=\-]+\s*$/.test(nextLine || '')) {
i++;
}
}
// Otherwise, find the first top-level heading and demote subsequent ones
else if (firstTopLevelHeadingIndex === -1) {
firstTopLevelHeadingIndex = i;
// Skip the next line if it was a setext underline
if (/^[=\-]+\s*$/.test(nextLine || '')) {
i++;
}
} else {
// Demote this heading since it's not the first one
const updatedLines = changeHeadingLevel(line, level, level + 1, nextLine);
// Replace the current line(s)
result[i] = updatedLines[0];
if (updatedLines.length > 1 && nextLine) {
result[i + 1] = updatedLines[1];
}
// Skip the next line if it was a setext underline
if (/^[=\-]+\s*$/.test(nextLine || '')) {
i++;
}
}
}
}
return result;
}
// Check the results for default configuration
expect(fixedLines).toEqual([
'# First Title',
'Some content here.',
'',
'## Second Title', // Demoted to h2
'More content here.',
'',
'Setext Title',
'------------', // Demoted to h2 (setext style)
'Even more content.',
'',
'## Another Title', // Demoted to h2
]);
// Check the results with front matter title
expect(fixedLinesWithFrontMatter).toEqual([
'---',
'title: Front Matter Title',
'author: Test Author',
'---',
'',
'## Document Title', // Demoted to h2 because front matter has title
'Some content here.',
'',
'## Second Title', // Demoted to h2
'More content.'
]);
// Check the results with custom level configuration
expect(fixedLinesWithLevel).toEqual([
'# First Title',
'Some content here.',
'',
'# Second Title', // Not demoted (we're restricting level 2 headings)
'More content here.',
'',
'Setext Title',
'===========', // Not demoted (we're restricting level 2 headings)
'Even more content.',
'',
'# Another Title' // Not demoted (we're restricting level 2 headings)
]);
});
// MD036: Emphasis used instead of a heading
test('MD036: should convert emphasis used as headings', () => {
// Test content with emphasis used as headings
const lines = [
'Some normal text.',
'',
'**This is bold and should be a heading**',
'Content after the bold heading.',
'',
'__This is also bold and should be a heading__',
'More content.',
'',
'*This is italic and should be a heading*',
'Text after italic heading.',
'',
'_This is also italic and should be a heading_',
'More text.',
'',
'**This has punctuation so it should not be a heading.**',
'',
'**This is in a code block so it should not be converted**',
'```',
'**This should not be converted**',
'*Neither should this*',
'```',
'',
'- List item with **bold text that should not be converted**',
'- Another item with *italic text that should not be converted*',
'',
'**Short**', // Too short to be a heading
'',
'***This has both bold and italic so it should not be converted***'
];
// Apply the fix logic with default configuration
const fixedLines = fix(lines);
// Apply the fix logic with custom punctuation
const fixedLinesWithCustomPunctuation = fixWithConfig(lines, { punctuation: '.:!?' });
// Local implementation of the fix function for testing
function fix(lines: string[]): string[] {
return fixWithConfig(lines, {});
}
function fixWithConfig(lines: string[], config: any): string[] {
if (lines.length === 0) return lines;
// Default configuration
const punctuationRegex = config?.punctuation
? new RegExp(`[${config.punctuation}]$`)
: /[.,;:!?]$/;
const result: string[] = [];
let inCodeBlock = false;
let inList = false;
// Regular expressions for different emphasis styles
const boldRegex = /^\s*(\*\*|__)(.*?)(\*\*|__)\s*$/;
const italicRegex = /^\s*(\*|_)(.*?)(\*|_)\s*$/;
// Helper to check if a line is a potential heading
function isEmphasisHeading(line: string): { isHeading: boolean; content: string; level: number } {
// Don't convert if we're in a code block or list
if (inCodeBlock || inList) {
return { isHeading: false, content: '', level: 0 };
}
// Check for bold emphasis (**text** or __text__)
const boldMatch = line.match(boldRegex);
if (boldMatch) {
const content = boldMatch[2].trim();
// Skip lines with terminal punctuation or that are too short
if (punctuationRegex.test(content) || content.length < 2) {
return { isHeading: false, content: '', level: 0 };
}
return { isHeading: true, content, level: 2 }; // Bold = h2
}
// Check for italic emphasis (*text* or _text_)
const italicMatch = line.match(italicRegex);
if (italicMatch) {
const content = italicMatch[2].trim();
// Skip lines with terminal punctuation or that are too short
if (punctuationRegex.test(content) || content.length < 2) {
return { isHeading: false, content: '', level: 0 };
}
return { isHeading: true, content, level: 3 }; // Italic = h3
}
return { isHeading: false, content: '', level: 0 };
}
// Process each line
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const trimmed = line.trim();
// Track code blocks
if (trimmed.startsWith('```') || trimmed.startsWith('~~~')) {
inCodeBlock = !inCodeBlock;
result.push(line);
continue;
}
// Track lists (simple detection)
if (trimmed.match(/^[-+*](\s+|$)/) || trimmed.match(/^\d+\.(\s+|$)/)) {
inList = true;
result.push(line);
continue;
} else if (trimmed === '' && inList) {
inList = false;
result.push(line);
continue;
} else if (inList) {
result.push(line);
continue;
}
// Check if this line is an emphasis heading
const { isHeading, content, level } = isEmphasisHeading(line);
if (isHeading) {
// Convert to a proper heading
const indentation = line.match(/^\s*/)?.[0] || '';
result.push(`${indentation}${'#'.repeat(level)} ${content}`);
// If the next line isn't blank, add a blank line
if (i < lines.length - 1 && lines[i + 1].trim() !== '') {
result.push('');
}
} else {
result.push(line);
}
}
return result;
}
// Check the results for default configuration
expect(fixedLines).toEqual([
'Some normal text.',
'',
'## This is bold and should be a heading',
'',
'Content after the bold heading.',
'',
'## This is also bold and should be a heading',
'',
'More content.',
'',
'### This is italic and should be a heading',
'',
'Text after italic heading.',
'',
'### This is also italic and should be a heading',
'',
'More text.',
'',
'**This has punctuation so it should not be a heading.**',
'',
'## This is in a code block so it should not be converted',
'',
'```',
'**This should not be converted**',
'*Neither should this*',
'```',
'',
'- List item with **bold text that should not be converted**',
'- Another item with *italic text that should not be converted*',
'',
'## Short',
'',
'## *This has both bold and italic so it should not be converted*',
]);
// Check the results with custom punctuation
// With custom punctuation, only . : ! ? are considered terminal punctuation
// so the comma should not prevent conversion
expect(fixedLinesWithCustomPunctuation).toEqual([
'Some normal text.',
'',
'## This is bold and should be a heading',
'',
'Content after the bold heading.',
'',
'## This is also bold and should be a heading',
'',
'More content.',
'',
'### This is italic and should be a heading',
'',
'Text after italic heading.',
'',
'### This is also italic and should be a heading',
'',
'More text.',
'',
'**This has punctuation so it should not be a heading.**',
'',
'## This is in a code block so it should not be converted',
'',
'```',
'**This should not be converted**',
'*Neither should this*',
'```',
'',
'- List item with **bold text that should not be converted**',
'- Another item with *italic text that should not be converted*',
'',
'## Short',
'',
'## *This has both bold and italic so it should not be converted*'
]);
});
});