diff --git a/.gitignore b/.gitignore index b37b36bc..a24cf03a 100644 --- a/.gitignore +++ b/.gitignore @@ -62,6 +62,5 @@ z*/ # Patch archives (exclude from VCS) .claude/ -.husky/ .patch/ .vscode/ diff --git a/eslint.config.mjs b/eslint.config.mjs index f9f161b1..88160010 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -11,6 +11,7 @@ export default [ 'dist/**', 'coverage/**', '**/*.min.js', + '.patch/**', 'test/template-test-generator/**', 'test/template-test-generator/**/*.js', 'test/template-test-generator/**/*.md', diff --git a/tools/markdown/check-md-conformance.js b/tools/markdown/check-md-conformance.js index 1ed91383..008e5256 100644 --- a/tools/markdown/check-md-conformance.js +++ b/tools/markdown/check-md-conformance.js @@ -1,4 +1,3 @@ -#!/usr/bin/env node /** * MD Conformance Checker (CommonMark-oriented) * @@ -67,7 +66,7 @@ function isFenceStart(line) { function fenceLanguage(line) { const m = line.match(/^\s*```\s*([a-zA-Z0-9_+-]+)?/); - return m ? (m[1] || '') : ''; + return m ? m[1] || '' : ''; } function isBlank(line) { @@ -84,17 +83,16 @@ function checkFile(filePath) { let fenceStartLine = -1; // Pass 1: fence tracking to avoid interpreting list/table inside code blocks - const excluded = new Array(lines.length).fill(false); - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; + const excluded = Array.from({ length: lines.length }).fill(false); + for (const [i, line] of lines.entries()) { if (isFenceStart(line)) { - if (!inFence) { - inFence = true; - fenceStartLine = i; - } else { + if (inFence) { // closing fence inFence = false; fenceStartLine = -1; + } else { + inFence = true; + fenceStartLine = i; } excluded[i] = true; continue; @@ -109,7 +107,19 @@ function checkFile(filePath) { if (excluded[i]) { if (isFenceStart(lines[i])) { // Fence boundary - if (!inFence) { + if (inFence) { + // closing + inFence = false; + // blank line after? + const next = i + 1; + if (next < lines.length && !isBlank(lines[next])) { + violations.push({ + type: 'fence-blank-after', + line: i + 1, + message: 'Missing blank line after code fence', + }); + } + } else { // opening inFence = true; // language present? @@ -130,18 +140,6 @@ function checkFile(filePath) { message: 'Missing blank line before code fence', }); } - } else { - // closing - inFence = false; - // blank line after? - const next = i + 1; - if (next < lines.length && !isBlank(lines[next])) { - violations.push({ - type: 'fence-blank-after', - line: i + 1, - message: 'Missing blank line after code fence', - }); - } } } continue; @@ -211,8 +209,7 @@ function checkFile(filePath) { const start = i; // scan forward while lines look like table lines let end = start; - while (end < lines.length && isTableLine(lines[end])) end++; - + while (end < lines.length && !excluded[end] && isTableLine(lines[end])) end++; // Require immediate previous line to be blank const prev = start - 1; if (prev >= 0 && !isBlank(lines[prev])) { @@ -292,3 +289,16 @@ if (require.main === module) { } module.exports = { checkFile }; +{ + console.log(`\n- ${path.relative(process.cwd(), file)}`); + for (const v of violations) { + console.log(` L${v.line.toString().padStart(4, ' ')} ${v.type} ${v.message}`); + } + process.exit(1); +} + +if (require.main === module) { + main(); +} + +module.exports = { checkFile }; diff --git a/tools/markdown/fix-fence-languages.js b/tools/markdown/fix-fence-languages.js index c49330b8..a9454198 100644 --- a/tools/markdown/fix-fence-languages.js +++ b/tools/markdown/fix-fence-languages.js @@ -1,4 +1,3 @@ -#!/usr/bin/env node /** * Fix Fence Languages - Add language identifiers to code fences * @@ -14,6 +13,7 @@ * 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'); @@ -26,19 +26,17 @@ const DRY_RUN = process.argv.includes('--dry-run'); */ 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)) { + 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(']'))) { + if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']'))) { try { JSON.parse(trimmed); return 'json'; @@ -46,36 +44,36 @@ function detectLanguage(content) { // 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)) { + 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)) { + 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'; } @@ -93,47 +91,51 @@ function fixFile(filePath) { // Track any outer fence (of any backtick length >=3) to avoid touching nested content const fenceStack = []; - // State for a target triple-backtick fence without language that we intend to fix + // 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 (let i = 0; i < lines.length; i++) { - const line = lines[i]; - + 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*)(`{3})(\s*)$/); + const closeMatch = line.match(/^(\s*)(`+)(\s*)$/); if (closeMatch) { - // Closing the target fence - const language = detectLanguage(fenceContent.join('\n')); - const fixedOpenLine = `${fixOpenIndent}\`\`\`${language}`; + 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); - newLines.push(...fenceContent); - newLines.push(line); + newLines.push(fixedOpenLine, ...fenceContent, line); - fixes.push({ - line: fixFenceStart + 1, - original: '```', - fixed: fixedOpenLine, - detectedLanguage: language, - contentPreview: fenceContent.slice(0, 2).join('\n').substring(0, 60) + '...' - }); + 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 = ''; - fenceContent = []; - continue; - } else { - fenceContent.push(line); - continue; + 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) @@ -141,37 +143,44 @@ function fixFile(filePath) { if (fenceLineMatch) { const indent = fenceLineMatch[1] || ''; const ticks = fenceLineMatch[2] || ''; - const rest = (fenceLineMatch[3] || '').trim(); - const hasLanguage = rest.length > 0; // simplistic but effective for our cases + 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 && fenceStack[fenceStack.length - 1].ticks === ticks) { - // Closing existing fence scope - fenceStack.pop(); - newLines.push(line); - continue; + 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) { - newLines.push(line); - // Start a nested fence scope if this appears to be an opening fence + // Start a nested fence scope fenceStack.push({ ticks }); + newLines.push(line); continue; } // Outside any fence - if (ticks === '```' && !hasLanguage) { - // Target: opening triple backtick without language; begin fixing mode + 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 (with language or more backticks): treat as an outer fence start + // Any other fence: treat as an outer fence start fenceStack.push({ ticks }); newLines.push(line); continue; @@ -187,7 +196,7 @@ function fixFile(filePath) { filePath, fixes: [], modified: false, - newContent: content + newContent: content, }; } @@ -195,7 +204,7 @@ function fixFile(filePath) { filePath, fixes, modified, - newContent: newLines.join('\n') + (content.endsWith('\n') ? '\n' : '') + newContent: newLines.join('\n') + (content.endsWith('\n') ? '\n' : ''), }; } @@ -203,67 +212,67 @@ function fixFile(filePath) { * Main execution */ function main() { - const args = process.argv.slice(2).filter(arg => arg !== '--dry-run'); - + 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] [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);