/** * Plan navigation system - detects plan structure and generates navigation * Delegates parsing to shared plan-table-parser module */ const fs = require('fs'); const path = require('path'); const { parsePlanPhases, normalizeStatus, filenameToTitle } = require('../../../_shared/lib/plan-table-parser.cjs'); /** Escape HTML special characters to prevent XSS */ function escapeHtml(str) { if (!str) return ''; return str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } /** Generate a slug from text for anchor IDs */ function slugify(text) { return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); } /** * Detect if a file is part of a plan directory * @param {string} filePath * @returns {{isPlan: boolean, planDir: string, planFile: string, phases: Array}} */ function detectPlan(filePath) { const dir = path.dirname(filePath); const planFile = path.join(dir, 'plan.md'); if (!fs.existsSync(planFile)) return { isPlan: false }; const files = fs.readdirSync(dir); const phases = files .filter(f => f.startsWith('phase-') && f.endsWith('.md')) .sort((a, b) => { const matchA = a.match(/phase-(\d+)([a-z]?)/); const matchB = b.match(/phase-(\d+)([a-z]?)/); const numA = parseInt(matchA?.[1] || '0', 10); const numB = parseInt(matchB?.[1] || '0', 10); if (numA !== numB) return numA - numB; return (matchA?.[2] || '').localeCompare(matchB?.[2] || ''); }); return { isPlan: true, planDir: dir, planFile, phases: phases.map(f => path.join(dir, f)) }; } /** * Parse plan.md delegating to shared parser with anchor generation * @param {string} planFilePath * @returns {Array} */ function parsePlanTable(planFilePath) { const content = fs.readFileSync(planFilePath, 'utf8'); const dir = path.dirname(planFilePath); const phases = parsePlanPhases(content, dir, { generateAnchors: true, slugify }); // Enhancement: resolve files from "Phase Files" section for heading-based phases if (phases.length > 0) { const phaseFilesSection = content.match(/##\s*Phase\s*Files[\s\S]*?(?=##|$)/i); if (phaseFilesSection) { const linkRegex = /\d+\.\s*\[([^\]]+)\]\(([^)]+\.md)\)/g; let linkMatch; while ((linkMatch = linkRegex.exec(phaseFilesSection[0])) !== null) { const [, , linkPath] = linkMatch; const phaseNum = parseInt(linkMatch[1].match(/phase-0?(\d+)/i)?.[1] || '0', 10); const phase = phases.find(p => p.phase === phaseNum); if (phase && (!phase.file || phase.file === planFilePath)) { phase.file = path.resolve(dir, linkPath); phase.anchor = null; } } } } // Filter out inline-only phases (no separate file) return phases.filter(p => p.file && p.file !== planFilePath); } /** Get navigation context for a file */ function getNavigationContext(filePath) { const planInfo = detectPlan(filePath); if (!planInfo.isPlan) return { planInfo, currentIndex: -1, prev: null, next: null, allPhases: [] }; const phaseMeta = parsePlanTable(planInfo.planFile); const allPhases = [{ phase: 0, phaseId: '0', name: 'Plan Overview', status: 'overview', file: planInfo.planFile }, ...phaseMeta]; const normalizedPath = path.normalize(filePath); const currentIndex = allPhases.findIndex(p => path.normalize(p.file) === normalizedPath); const prev = currentIndex > 0 ? allPhases[currentIndex - 1] : null; const next = currentIndex < allPhases.length - 1 && currentIndex >= 0 ? allPhases[currentIndex + 1] : null; return { planInfo, currentIndex, prev, next, allPhases }; } /** Get status badge HTML for a phase group */ function getGroupBadge(phases) { const completed = phases.filter(p => p.status === 'completed').length; const inProgress = phases.filter(p => p.status === 'in-progress').length; if (completed === phases.length) return ''; if (inProgress > 0) return ''; return ''; } /** Render a single phase item as HTML */ function renderPhaseItem(phase, index, currentIndex, normalizedCurrentPath) { const isActive = index === currentIndex; const statusClass = phase.status.replace(/\s+/g, '-'); const normalizedPhasePath = path.normalize(phase.file); const isSameFile = normalizedPhasePath === normalizedCurrentPath; const fileExists = fs.existsSync(phase.file); // M7: escape statusClass and phase.anchor to prevent XSS in HTML attributes const safeStatusClass = escapeHtml(statusClass); const safeAnchor = phase.anchor ? escapeHtml(phase.anchor) : null; if (!fileExists) { return `
  • ${escapeHtml(phase.name)} Planned
  • `; } let href, isInlineSection = false; if (isSameFile && safeAnchor) { href = `#${safeAnchor}`; isInlineSection = true; } else if (safeAnchor) { href = `/view?file=${encodeURIComponent(phase.file)}#${safeAnchor}`; } else { href = `/view?file=${encodeURIComponent(phase.file)}`; } const dataAnchor = safeAnchor ? `data-anchor="${safeAnchor}"` : ''; const inlineSectionClass = isInlineSection ? 'inline-section' : ''; const typeIcon = isInlineSection ? `` : ``; return `
  • ${typeIcon}${escapeHtml(phase.name)}
  • `; } /** Generate navigation sidebar HTML */ function generateNavSidebar(filePath) { const { planInfo, currentIndex, allPhases } = getNavigationContext(filePath); if (!planInfo.isPlan) return ''; const planName = path.basename(planInfo.planDir); const normalizedCurrentPath = path.normalize(filePath); // Flat list when <= 15 phases (no accordion grouping needed) if (allPhases.length <= 15) { const items = allPhases.map((phase, index) => renderPhaseItem(phase, index, currentIndex, normalizedCurrentPath)).join(''); return ``; } // Accordion groups for large plans (> 15 phases) const groups = []; let currentGroup = [], groupStart = 0; allPhases.forEach((phase, index) => { if (currentGroup.length === 0) groupStart = phase.phase; currentGroup.push({ phase, index }); if (currentGroup.length === 10 || index === allPhases.length - 1 || (phase.phase % 10 === 0 && phase.phase !== groupStart)) { groups.push({ start: groupStart, end: phase.phase, phases: [...currentGroup] }); currentGroup = []; } }); const groupsHtml = groups.map(group => { const groupId = `phase-group-${group.start}-${group.end}`; const groupLabel = group.start === 0 ? 'Overview' : group.start === group.end ? `Phase ${group.start}` : `Phases ${group.start}-${group.end}`; const badge = getGroupBadge(group.phases.map(p => p.phase)); const items = group.phases.map(({ phase, index }) => renderPhaseItem(phase, index, currentIndex, normalizedCurrentPath)).join(''); return `
    `; }).join(''); return ``; } /** Generate prev/next navigation footer HTML */ function generateNavFooter(filePath) { const { prev, next } = getNavigationContext(filePath); if (!prev && !next) return ''; const prevExists = prev && fs.existsSync(prev.file); const nextExists = next && fs.existsSync(next.file); const prevHtml = prev ? (prevExists ? `${escapeHtml(prev.name)}` : `${escapeHtml(prev.name)}Planned`) : ''; const nextHtml = next ? (nextExists ? `${escapeHtml(next.name)}` : `${escapeHtml(next.name)}Planned`) : ''; return ``; } module.exports = { detectPlan, parsePlanTable, getNavigationContext, generateNavSidebar, generateNavFooter };