BMAD-METHOD/tools/markdown/fix-fence-languages.js

290 lines
7.8 KiB
JavaScript

/**
* Fix Fence Languages - Add language identifiers to code fences
*
* This script detects fenced code blocks without language identifiers
* and adds appropriate languages based on content heuristics.
*
* Usage:
* node tools/markdown/fix-fence-languages.js [--dry-run] <file1> [file2...]
*
* Options:
* --dry-run Show what would be fixed without modifying files
*
* Exit codes:
* 0 -> No issues found or all fixed successfully
* 1 -> Issues found (dry-run mode) or errors during fix
* 2 -> Invalid usage (missing file arguments)
*/
const fs = require('node:fs');
const path = require('node:path');
const DRY_RUN = process.argv.includes('--dry-run');
/**
* Detect language from fence content using simple heuristics
*/
function detectLanguage(content) {
const trimmed = content.trim();
// Empty fence
if (!trimmed) return 'text';
// YAML detection
if (/^[a-zA-Z_][a-zA-Z0-9_-]*:\s*/.test(trimmed) || /^---\s*$/m.test(trimmed)) {
return 'yaml';
}
// JSON detection
if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']'))) {
try {
JSON.parse(trimmed);
return 'json';
} catch {
// Not valid JSON, continue
}
}
// Shell/Bash detection
if (
/^(npm|yarn|pnpm|git|node|npx|cd|mkdir|rm|cp|mv|ls|cat|echo|export|source|\$)\s/.test(trimmed) ||
/^\$/.test(trimmed) ||
/^#!\/bin\/(ba)?sh/.test(trimmed)
) {
return 'bash';
}
// JavaScript/TypeScript detection
if (/^(import|export|const|let|var|function|class|async|await)\s/.test(trimmed) || /^\/\//.test(trimmed) || /^\/\*/.test(trimmed)) {
return 'javascript';
}
// XML/HTML detection
if (/^<[a-zA-Z][^>]*>/.test(trimmed)) {
return 'xml';
}
// Markdown detection (for nested examples)
if (/^#{1,6}\s/.test(trimmed) || /^\[.*\]\(.*\)/.test(trimmed)) {
return 'markdown';
}
// Flow/diagram detection (arrows, boxes)
if (/[→↓←↑]/.test(trimmed) || /[┌┐└┘├┤┬┴┼─│]/.test(trimmed)) {
return 'text';
}
// Default to text for unknown content
return 'text';
}
/**
* Fix a single file
*/
function fixFile(filePath) {
const content = fs.readFileSync(filePath, 'utf8');
const lines = content.split(/\r?\n/);
const fixes = [];
let modified = false;
// Track any outer fence (of any backtick length >=3) to avoid touching nested content
const fenceStack = [];
// State for a target fence (3+ backticks) without language that we intend to fix
let fixing = false;
let fixFenceStart = -1;
let fixOpenIndent = '';
let fixOpenTicks = '';
let fixOpenLen = 0;
let fenceContent = [];
const newLines = [];
for (const [i, line] of lines.entries()) {
// If we are currently fixing a fence (collecting content until closing ```)
if (fixing) {
const closeMatch = line.match(/^(\s*)(`+)(\s*)$/);
if (closeMatch) {
const closeTicks = closeMatch[2] || '';
// Only treat as closing if the number of backticks is >= opening length
if (closeTicks.length >= fixOpenLen) {
// Closing the target fence
const language = detectLanguage(fenceContent.join('\n'));
const fixedOpenLine = `${fixOpenIndent}\`\`\`${language}`;
newLines.push(fixedOpenLine, ...fenceContent, line);
fixes.push({
line: fixFenceStart + 1,
original: fixOpenTicks,
fixed: fixedOpenLine,
detectedLanguage: language,
contentPreview: fenceContent.slice(0, 2).join('\n').slice(0, 60) + '...',
});
modified = true;
fixing = false;
fixFenceStart = -1;
fixOpenIndent = '';
fixOpenTicks = '';
fixOpenLen = 0;
fenceContent = [];
continue;
}
}
// Not a valid closing line yet; keep collecting content
fenceContent.push(line);
continue;
}
// Not currently fixing; detect any fence line (opening or closing)
const fenceLineMatch = line.match(/^(\s*)(`{3,})(.*)$/);
if (fenceLineMatch) {
const indent = fenceLineMatch[1] || '';
const ticks = fenceLineMatch[2] || '';
const ticksLen = ticks.length;
const rest = fenceLineMatch[3] || '';
const restTrim = rest.trim();
const hasLanguage = restTrim.length > 0; // simplistic but effective for our cases
// Determine if this is a closing fence for the current outer fence
if (fenceStack.length > 0) {
const top = fenceStack.at(-1);
if (restTrim === '' && ticksLen >= top.ticks.length) {
// Closing existing fence scope
fenceStack.pop();
newLines.push(line);
continue;
}
}
// If inside any outer fence, don't attempt to fix nested fences
if (fenceStack.length > 0) {
// Start a nested fence scope
fenceStack.push({ ticks });
newLines.push(line);
continue;
}
// Outside any fence
if (ticksLen >= 3 && restTrim === '') {
// Opening fence without language (3+ backticks): begin fixing mode
fixing = true;
fixFenceStart = i;
fixOpenIndent = indent;
fixOpenTicks = ticks;
fixOpenLen = ticksLen;
fenceContent = [];
// Do not push the original opening line; we'll emit the fixed one at close
continue;
}
// Any other fence: treat as an outer fence start
fenceStack.push({ ticks });
newLines.push(line);
continue;
}
// Regular non-fence line
newLines.push(line);
}
// If we ended while "fixing" and never saw a closing fence, abort changes for safety
if (fixing) {
return {
filePath,
fixes: [],
modified: false,
newContent: content,
};
}
return {
filePath,
fixes,
modified,
newContent: newLines.join('\n') + (content.endsWith('\n') ? '\n' : ''),
};
}
/**
* Main execution
*/
function main() {
const args = process.argv.slice(2).filter((arg) => arg !== '--dry-run');
if (args.length === 0) {
console.error('Usage: node tools/markdown/fix-fence-languages.js [--dry-run] <file1> [file2...]');
process.exit(2);
}
const results = [];
let totalFixes = 0;
for (const filePath of args) {
const absPath = path.resolve(filePath);
if (!fs.existsSync(absPath)) {
console.error(`File not found: ${absPath}`);
continue;
}
if (!absPath.toLowerCase().endsWith('.md')) {
console.error(`Skipping non-markdown file: ${absPath}`);
continue;
}
const result = fixFile(absPath);
if (result.fixes.length > 0) {
results.push(result);
totalFixes += result.fixes.length;
}
}
// Print results
if (results.length === 0) {
console.log('✓ No fence language issues found');
process.exit(0);
}
if (DRY_RUN) {
console.log(`\n🔍 DRY RUN: Found ${totalFixes} fence(s) without language in ${results.length} file(s)\n`);
} else {
console.log(`\n🔧 Fixing ${totalFixes} fence(s) in ${results.length} file(s)\n`);
}
for (const result of results) {
console.log(`📄 ${path.relative(process.cwd(), result.filePath)}`);
for (const fix of result.fixes) {
console.log(` L${fix.line.toString().padStart(4, ' ')} ${fix.original.trim() || '```'}`);
console.log(`\`\`\`${fix.detectedLanguage}`);
console.log(` Content: ${fix.contentPreview}`);
}
console.log('');
// Apply fixes if not dry-run
if (!DRY_RUN) {
fs.writeFileSync(result.filePath, result.newContent, 'utf8');
console.log(` ✓ Fixed and saved\n`);
}
}
if (DRY_RUN) {
console.log('💡 Run without --dry-run to apply these fixes\n');
process.exit(1);
} else {
console.log('✓ All fixes applied successfully\n');
process.exit(0);
}
}
if (require.main === module) {
main();
}
module.exports = { detectLanguage, fixFile };