UNPKG

@fsegurai/manifest-generator

Version:

A simple manifest and search-index generator based on project documentation.

326 lines (271 loc) 9.04 kB
import fs from 'fs'; import path from 'path'; /** * Format a filename or directory name into a readable title * @param {string} name - The name to format * @returns {string} The formatted title */ function formatTitle(name) { return name.replace(/\.md$/, '').replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); } /** * Parse frontmatter from markdown content * @param {string} content - The markdown content * @returns {Record<string, any>} The parsed frontmatter data */ function parseFrontmatter(content) { const match = content.match(/^---\s*([\s\S]*?)\s*---/); if (!match) return {}; const lines = match[1].split('\n'); const data = {}; for (const line of lines) { const [key, ...rest] = line.split(':'); if (!key || rest.length === 0) continue; const rawValue = rest.join(':').trim(); if (rawValue.startsWith('[') && rawValue.endsWith(']')) { try { data[key.trim()] = JSON.parse(rawValue); } catch { data[key.trim()] = rawValue; } } else if (rawValue === 'true' || rawValue === 'false') { data[key.trim()] = rawValue === 'true'; } else if (!isNaN(Number(rawValue))) { data[key.trim()] = Number(rawValue); } else { data[key.trim()] = rawValue.replace(/^["']|["']$/g, ''); } } return data; } /** * Walk through docs directory and build navigation structure * @param {string} dir - The directory to walk * @param {string} relative - The relative path * @param {Array} searchIndex - The search index to populate * @returns {Array} The navigation items */ function walkDocs(dir, relative = '', searchIndex) { const entries = fs.readdirSync(dir, { withFileTypes: true }); return entries .filter(entry => !entry.name.startsWith('.') && entry.name !== 'manifest.json') .map(entry => { const fullPath = path.join(dir, entry.name); const relPath = path.join(relative, entry.name); if (entry.isDirectory()) { const children = walkDocs(fullPath, relPath, searchIndex); if (children.length === 0) return null; return { title: formatTitle(entry.name), children, }; } if (entry.isFile() && entry.name.endsWith('.md')) { const content = fs.readFileSync(fullPath, 'utf8'); const meta = parseFrontmatter(content); if (meta.draft || meta.hidden) return null; const cleanPath = relPath.replace(/\.md$/, '').replace(/\\/g, '/'); const item = { title: meta.title || formatTitle(entry.name), path: cleanPath, tags: meta.tags || [], }; // Add to the search index searchIndex.push({ title: item.title, path: cleanPath, tags: item.tags, }); return item; } return null; }) .filter(Boolean); } /** * Generate documentation manifests for all projects * @param {string} docsRoot - The root directory containing documentation projects */ function generateDocsManifests(docsRoot) { const projects = fs .readdirSync(docsRoot, { withFileTypes: true }) .filter(entry => entry.isDirectory()) .map(entry => entry.name); projects.forEach(projectName => { const projectPath = path.join(docsRoot, projectName); const searchIndex = []; const manifest = walkDocs(projectPath, '', searchIndex); fs.writeFileSync( path.join(projectPath, 'manifest.json'), JSON.stringify(manifest, null, 2), 'utf8', ); fs.writeFileSync( path.join(projectPath, 'search-index.json'), JSON.stringify(searchIndex, null, 2), 'utf8', ); console.log(`✅ Generated manifest and search index for: ${projectName}`); }); } /** * Generate manifest for a single project * @param {string} projectPath - The path to the project documentation * @returns {Object} The generated manifest and search index */ function generateManifest(projectPath) { const searchIndex = []; const manifest = walkDocs(projectPath, '', searchIndex); return { manifest, searchIndex, }; } /** * Auto-discover documentation projects in a directory * @param {string} rootDir - The root directory to search * @param {Object} options - Discovery options * @returns {Array} Array of discovered project paths */ function discoverProjects(rootDir, options = {}) { const { docsSubfolder = 'docs' } = options; const discovered = []; try { const entries = fs.readdirSync(rootDir, { withFileTypes: true }); for (const entry of entries) { if (!entry.isDirectory() || entry.name.startsWith('.')) continue; const projectPath = path.join(rootDir, entry.name); const docsPath = path.join(projectPath, docsSubfolder); // Check if this directory has docs if (fs.existsSync(docsPath)) { const stats = fs.statSync(docsPath); if (stats.isDirectory()) { discovered.push({ name: entry.name, projectPath, docsPath, type: 'subfolder', }); } } // Also check if the project directory itself has .md files const hasMdFiles = fs.readdirSync(projectPath, { withFileTypes: true }) .some(file => file.isFile() && file.name.endsWith('.md')); if (hasMdFiles) { discovered.push({ name: entry.name, projectPath, docsPath: projectPath, type: 'direct', }); } } } catch (error) { console.warn(`Warning: Could not scan directory ${rootDir}: ${error.message}`); } return discovered; } /** * Generate manifests with automatic discovery and flexible options * @param {string} rootDir - The root directory to process * @param {Object} options - Processing options */ function generateManifestsWithDiscovery(rootDir, options = {}) { const { project = null, route = null, outputDir = null, docsSubfolder = 'docs', autoDetect = true, } = options; if (route) { // Process specific route if (!fs.existsSync(route)) { throw new Error(`Route not found: ${route}`); } const result = generateManifest(route); const outputPath = outputDir || path.dirname(route); fs.writeFileSync( path.join(outputPath, 'manifest.json'), JSON.stringify(result.manifest, null, 2), 'utf8', ); fs.writeFileSync( path.join(outputPath, 'search-index.json'), JSON.stringify(result.searchIndex, null, 2), 'utf8', ); console.log(`✅ Generated manifest for route: ${route}`); return [{ name: path.basename(route), processed: true }]; } if (project) { // Process specific project const projectPath = path.join(rootDir, project); const docsPath = path.join(projectPath, docsSubfolder); if (!fs.existsSync(projectPath)) { throw new Error(`Project not found: ${project}`); } const targetPath = fs.existsSync(docsPath) ? docsPath : projectPath; const result = generateManifest(targetPath); const outputPath = outputDir || projectPath; fs.writeFileSync( path.join(outputPath, 'manifest.json'), JSON.stringify(result.manifest, null, 2), 'utf8', ); fs.writeFileSync( path.join(outputPath, 'search-index.json'), JSON.stringify(result.searchIndex, null, 2), 'utf8', ); console.log(`✅ Generated manifest for project: ${project}`); return [{ name: project, processed: true }]; } // Auto-discover and process all projects if (autoDetect) { const discovered = discoverProjects(rootDir, { docsSubfolder }); const results = []; if (discovered.length === 0) { console.log('⚠️ No documentation projects found.'); return results; } console.log(`📁 Discovered ${discovered.length} project(s):`); discovered.forEach(proj => { console.log(` - ${proj.name} (${proj.type}): ${proj.docsPath}`); }); for (const proj of discovered) { try { const result = generateManifest(proj.docsPath); const outputPath = outputDir || proj.projectPath; fs.writeFileSync( path.join(outputPath, 'manifest.json'), JSON.stringify(result.manifest, null, 2), 'utf8', ); fs.writeFileSync( path.join(outputPath, 'search-index.json'), JSON.stringify(result.searchIndex, null, 2), 'utf8', ); console.log(`✅ Generated manifest for: ${proj.name}`); results.push({ name: proj.name, processed: true }); } catch (error) { console.error(`❌ Error processing ${proj.name}: ${error.message}`); results.push({ name: proj.name, processed: false, error: error.message }); } } return results; } // Fallback to original behavior generateDocsManifests(rootDir); return []; } export { formatTitle, parseFrontmatter, walkDocs, generateDocsManifests, generateManifest, discoverProjects, generateManifestsWithDiscovery, };