440 lines
11 KiB
JavaScript
440 lines
11 KiB
JavaScript
/**
|
|
* Markdown-Formatter - Format and beautify markdown documents
|
|
* Vernox v1.0 - Autonomous Revenue Agent
|
|
*/
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
// Pattern matches
|
|
const PATTERNS = {
|
|
trailingWhitespace: /[ \t]+$/gm,
|
|
multipleBlankLines: /\n{3,}/g,
|
|
inconsistentListDash: /^\s{0,2}([*-])\s/g,
|
|
inconsistentListAsterisk: /^\s{0,2}([*])\s/g,
|
|
inconsistentListPlus: /^\s{0,2}([+])\s/g,
|
|
inconsistentListTask: /^\s{0,2}(- \[ )\]\s/g,
|
|
atxHeading: /^#{1,2}\s[^#]+/,
|
|
setextHeading: /^#{1,2}\s[=-]+/,
|
|
codeBlockFenced: /^```/g,
|
|
codeBlockIndented: /^ {4}/,
|
|
strikethrough: /~~(.+?)~~/g,
|
|
autoLink: /<https?:\/\/[^>\s]+>/gi
|
|
};
|
|
|
|
// Constants for style guides
|
|
const STYLE_GUIDES = {
|
|
commonmark: {
|
|
headingLevels: ['#', '##', '###'],
|
|
emphasis: {
|
|
underscore: '__text__',
|
|
asterisk: '**text**'
|
|
},
|
|
links: {
|
|
inline: '[text](url)',
|
|
reference: '[text][id]'
|
|
}
|
|
},
|
|
github: {
|
|
headingLevels: ['#', '##', '###'],
|
|
emphasis: {
|
|
underscore: '__text__',
|
|
asterisk: '**text**'
|
|
},
|
|
links: {
|
|
inline: '[text](url)',
|
|
reference: '[text][id]'
|
|
},
|
|
codeBlocks: {
|
|
fenced: '```',
|
|
indented: ' '
|
|
},
|
|
lists: {
|
|
unordered: '-',
|
|
ordered: '1.',
|
|
task: '- [ ]'
|
|
},
|
|
strikethrough: '~~text~~',
|
|
autoLinks: '<https://url>'
|
|
},
|
|
tables: '| column | column | '
|
|
},
|
|
consistent: {
|
|
headingLevels: ['#', '##', '###'],
|
|
emphasis: {
|
|
underscore: false,
|
|
asterisk: true
|
|
},
|
|
links: {
|
|
inline: '[text](url)'
|
|
},
|
|
lists: {
|
|
unordered: '-',
|
|
ordered: '1.'
|
|
},
|
|
codeBlocks: {
|
|
fenced: '```'
|
|
},
|
|
tables: '| column | column | '
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Format markdown content according to style guide
|
|
*/
|
|
function formatMarkdown(params) {
|
|
const { markdown, style, options = {} } = params;
|
|
|
|
if (!markdown) {
|
|
throw new Error('markdown is required');
|
|
}
|
|
|
|
const styleGuide = STYLE_GUIDES[style] || STYLE_GUIDES.github;
|
|
const opts = { ...STYLE_GUIDES.github, ...options };
|
|
|
|
let formatted = markdown;
|
|
const warnings = [];
|
|
const startTime = Date.now();
|
|
|
|
// 1. Normalize line endings
|
|
formatted = formatted.replace(/\r\n/g, '\n');
|
|
|
|
// 2. Remove trailing whitespace from each line
|
|
const lines = formatted.split('\n');
|
|
formatted = lines.map(line => line.trimRight()).join('\n');
|
|
|
|
// 3. Fix inconsistent list markers
|
|
if (opts.fixLists) {
|
|
const fixedLists = fixListMarkers(lines, styleGuide);
|
|
formatted = fixedLists.markdown;
|
|
if (fixedLists.warnings.length > 0) {
|
|
warnings.push(...fixedLists.warnings);
|
|
}
|
|
}
|
|
|
|
// 4. Normalize heading styles
|
|
if (styleGuide.headingLevels) {
|
|
const fixedHeadings = normalizeHeadings(lines, styleGuide, opts.headingStyle);
|
|
formatted = fixedHeadings.markdown;
|
|
if (fixedHeadings.warnings.length > 0) {
|
|
warnings.push(...fixedHeadings.warnings);
|
|
}
|
|
}
|
|
|
|
// 5. Normalize emphasis
|
|
if (styleGuide.emphasis) {
|
|
const fixedEmphasis = normalizeEmphasis(formatted, styleGuide.emphasis);
|
|
formatted = fixedEmphasis.markdown;
|
|
if (fixedEmphasis.warnings.length > 0) {
|
|
warnings.push(...fixedEmphasis.warnings);
|
|
}
|
|
}
|
|
|
|
// 6. Fix code blocks
|
|
if (styleGuide.codeBlocks) {
|
|
const fixedCode = fixCodeBlocks(formatted, styleGuide.codeBlocks);
|
|
formatted = fixedCode.markdown;
|
|
if (fixedCode.warnings.length > 0) {
|
|
warnings.push(...fixedCode.warnings);
|
|
}
|
|
}
|
|
|
|
// 7. Fix tables
|
|
if (styleGuide.tables) {
|
|
const fixedTables = fixTables(formatted);
|
|
formatted = fixedTables.markdown;
|
|
if (fixedTables.warnings.length > 0) {
|
|
warnings.push(...fixedTables.warnings);
|
|
}
|
|
}
|
|
|
|
// 8. Remove multiple consecutive blank lines
|
|
if (opts.normalizeSpacing) {
|
|
formatted = formatted.replace(PATTERNS.multipleBlankLines, '\n\n\n');
|
|
}
|
|
|
|
// 9. Wrap long lines
|
|
if (opts.maxWidth) {
|
|
const wrappedLines = lines.map(line => wrapLine(line, opts.maxWidth));
|
|
formatted = wrappedLines.join('\n');
|
|
}
|
|
|
|
// 10. Add spacing around emphasis
|
|
if (styleGuide.emphasis && styleGuide.emphasis !== 'none') {
|
|
formatted = addEmphasisSpacing(formatted, styleGuide.emphasis);
|
|
}
|
|
|
|
const endTime = Date.now();
|
|
const stats = {
|
|
originalLength: markdown.length,
|
|
formattedLength: formatted.length,
|
|
warnings: warnings.length
|
|
};
|
|
|
|
return {
|
|
formattedMarkdown: formatted,
|
|
warnings,
|
|
stats,
|
|
lintResult: {
|
|
errors: [],
|
|
warnings: warnings,
|
|
fixed: markdown
|
|
},
|
|
processingTime: endTime - startTime
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Fix inconsistent list markers
|
|
*/
|
|
function fixListMarkers(lines, styleGuide) {
|
|
const warnings = [];
|
|
let markdown = lines.join('\n');
|
|
|
|
// Find and standardize unordered lists
|
|
if (styleGuide.lists) {
|
|
const unorderedPattern = /^\s{0,2}([*-])\s/g;
|
|
const matches = markdown.match(unorderedPattern);
|
|
|
|
if (matches) {
|
|
const replacement = opts.lists === 'asterisk' ? '- ' : opts.lists === 'plus' ? '+ ' : '-';
|
|
markdown = markdown.replace(unorderedPattern, `$1$2$3`);
|
|
} else if (styleGuide.lists === 'dash') {
|
|
markdown = markdown.replace(/^\s{0,2}([*-])\s/g, '$1$2$3');
|
|
}
|
|
}
|
|
|
|
return { markdown, warnings };
|
|
}
|
|
|
|
/**
|
|
* Normalize heading styles
|
|
*/
|
|
function normalizeHeadings(lines, styleGuide, headingStyle) {
|
|
const warnings = [];
|
|
let markdown = lines.join('\n');
|
|
|
|
if (headingStyle === 'atx') {
|
|
// Ensure ATX headings (### instead of #)
|
|
const atxCount = (markdown.match(/#+\s+/g) || []).length;
|
|
if (atxCount > 0) {
|
|
warnings.push(`${atxCount} ATX-style headings found (should use ###)`);
|
|
markdown = markdown.replace(/#+\s+/g, '### ');
|
|
}
|
|
} else if (headingStyle === 'setext') {
|
|
// Ensure Setext headings (==== instead of #)
|
|
const setextCount = (markdown.match(/^={4,}\s/g) || []).length;
|
|
if (setextCount > 0) {
|
|
warnings.push(`${setextCount} Setext headings found (should use #)`);
|
|
markdown = markdown.replace(/^={4,}\s/g, '#');
|
|
}
|
|
} else if (headingStyle === 'underlined') {
|
|
// Ensure underlined headings (=== or ---)
|
|
const underlinePattern = /^(={3,}|-{3,})\n/gm;
|
|
const underlineCount = (markdown.match(underlinePattern) || []).length;
|
|
if (underlineCount > 0) {
|
|
warnings.push(`${underlineCount} underline headings found (should use #)`);
|
|
markdown = markdown.replace(underlinePattern, '#$1');
|
|
}
|
|
}
|
|
|
|
return { markdown, warnings };
|
|
}
|
|
|
|
/**
|
|
* Normalize emphasis
|
|
*/
|
|
function normalizeEmphasis(markdown, emphasis) {
|
|
const warnings = [];
|
|
|
|
if (emphasis === 'asterisk') {
|
|
markdown = markdown.replace(/\*\*(?!.*?\*)/g, '*');
|
|
markdown = markdown.replace(/_{2,}/g, '_');
|
|
markdown = markdown.replace(/__{2,}/g, '__');
|
|
} else if (emphasis === 'underscore') {
|
|
markdown = markdown.replace(/__(.+?)__/g, '*$1*');
|
|
}
|
|
|
|
return { markdown, warnings };
|
|
}
|
|
|
|
/**
|
|
* Fix code blocks
|
|
*/
|
|
function fixCodeBlocks(markdown, codeBlockStyle) {
|
|
const warnings = [];
|
|
|
|
if (codeBlockStyle === 'fenced') {
|
|
const lines = markdown.split('\n');
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const line = lines[i];
|
|
if (/^```\s*$/.test(line) && !line.endsWith('```')) {
|
|
warnings.push(`Unclosed fenced code block at line ${i + 1}`);
|
|
lines[i] = line + '```';
|
|
}
|
|
}
|
|
markdown = lines.join('\n');
|
|
} else if (codeBlockStyle === 'indented') {
|
|
markdown = markdown.replace(/^ {4}/gm, ' ');
|
|
}
|
|
|
|
return { markdown, warnings };
|
|
}
|
|
|
|
/**
|
|
* Fix tables
|
|
*/
|
|
function fixTables(markdown) {
|
|
const warnings = [];
|
|
const headerPattern = /\|[^|\n]+?\|/g;
|
|
markdown = markdown.replace(headerPattern, '| ');
|
|
|
|
return { markdown, warnings };
|
|
}
|
|
|
|
/**
|
|
* Wrap line at maxWidth
|
|
*/
|
|
function wrapLine(line, maxWidth) {
|
|
if (line.length <= maxWidth) return line;
|
|
return line.substring(0, maxWidth) + '\n' + line.substring(maxWidth);
|
|
}
|
|
|
|
/**
|
|
* Add spacing around emphasis
|
|
*/
|
|
function addEmphasisSpacing(markdown, emphasis) {
|
|
if (emphasis === 'asterisk') {
|
|
return markdown.replace(/([^\s\*])(\*)([^\s])/g, '$1 $2$3');
|
|
}
|
|
return markdown;
|
|
}
|
|
|
|
/**
|
|
* Lint markdown for issues
|
|
*/
|
|
function lintMarkdown(params) {
|
|
const { markdown, style, options = {} } = params;
|
|
const styleGuide = STYLE_GUIDES[style] || STYLE_GUIDES.github;
|
|
const opts = { ...STYLE_GUIDES.github, ...options };
|
|
|
|
const warnings = [];
|
|
const errors = [];
|
|
|
|
// Check heading levels
|
|
if (opts.checkHeadingLevels) {
|
|
const lines = markdown.split('\n');
|
|
const headings = lines.filter(line => /^#+\s/.test(line));
|
|
|
|
let prevLevel = 0;
|
|
headings.forEach((heading, index) => {
|
|
const match = heading.match(/^(#{1,2})\s+(.*)/);
|
|
const level = match[1].length;
|
|
|
|
if (index > 0 && level > prevLevel + 1) {
|
|
errors.push({
|
|
type: 'heading_skip',
|
|
message: `Heading skipped ${level - prevLevel - 1} levels at line ${index + 1}`,
|
|
line: heading
|
|
});
|
|
}
|
|
|
|
prevLevel = level;
|
|
});
|
|
}
|
|
|
|
const stats = {
|
|
headingLevels: (markdown.match(/#+/g) || []).length,
|
|
listMarkers: (markdown.match(/[-*+]/g) || []).length,
|
|
emphasisMarkers: (markdown.match(/[*_]/g) || []).length,
|
|
codeBlocks: (markdown.match(/```/g) || []).length / 2,
|
|
tables: (markdown.match(/\|[^|\n]+?\|/g) || []).length / 3
|
|
};
|
|
|
|
return { errors, warnings, stats, suggestions: [] };
|
|
}
|
|
|
|
/**
|
|
* Format multiple markdown files
|
|
*/
|
|
function formatBatch(params) {
|
|
const { markdownFiles, style, options = {} } = params;
|
|
|
|
if (!markdownFiles || !Array.isArray(markdownFiles)) {
|
|
throw new Error('markdownFiles must be an array of file paths');
|
|
}
|
|
|
|
const startTime = Date.now();
|
|
const results = [];
|
|
const totalWarnings = [];
|
|
|
|
for (const filePath of markdownFiles) {
|
|
try {
|
|
const content = fs.readFileSync(filePath, 'utf8');
|
|
const result = formatMarkdown({
|
|
markdown: content,
|
|
style,
|
|
options
|
|
});
|
|
|
|
results.push({
|
|
file: filePath,
|
|
formattedMarkdown: result.formattedMarkdown,
|
|
warnings: result.warnings,
|
|
stats: result.stats
|
|
});
|
|
|
|
totalWarnings.push(...result.warnings);
|
|
} catch (error) {
|
|
results.push({
|
|
file: filePath,
|
|
error: error.message || error
|
|
});
|
|
}
|
|
}
|
|
|
|
const endTime = Date.now();
|
|
|
|
return {
|
|
results,
|
|
totalFiles: markdownFiles.length,
|
|
totalWarnings: totalWarnings.length,
|
|
processingTime: endTime - startTime
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Main function - handles tool invocations
|
|
*/
|
|
function main(action, params) {
|
|
switch (action) {
|
|
case 'formatMarkdown':
|
|
return formatMarkdown(params);
|
|
case 'formatBatch':
|
|
return formatBatch(params);
|
|
case 'lintMarkdown':
|
|
return lintMarkdown(params);
|
|
default:
|
|
throw new Error(`Unknown action: ${action}`);
|
|
}
|
|
}
|
|
|
|
// CLI interface
|
|
if (require.main === module) {
|
|
const args = process.argv.slice(2);
|
|
const action = args[0];
|
|
|
|
try {
|
|
const params = JSON.parse(args[1] || '{}');
|
|
const result = main(action, params);
|
|
console.log(JSON.stringify(result, null, 2));
|
|
} catch (error) {
|
|
console.error(JSON.stringify({
|
|
error: error.message || error
|
|
}, null, 2));
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
module.exports = { main, formatMarkdown, formatBatch, lintMarkdown };
|