Created
July 3, 2025 17:42
-
-
Save amxv/08352da27a669d1c9a1ae7adf400c2c7 to your computer and use it in GitHub Desktop.
docmd process docs live
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| { | |
| "name": "@repo/docmd", | |
| "private": true, | |
| "scripts": { | |
| "process-docs": "node scripts/process-docs.js", | |
| "watch:docs": "onchange '../../docs/**/*' -- pnpm process-docs", | |
| "build": "pnpm process-docs && docmd build", | |
| "dev": "pnpm process-docs && concurrently \"docmd dev\" \"pnpm watch:docs\"", | |
| "dev:simple": "pnpm process-docs && docmd dev" | |
| }, | |
| "dependencies": { | |
| "@mgks/docmd": "^0.1.4" | |
| }, | |
| "devDependencies": { | |
| "concurrently": "^9.2.0", | |
| "onchange": "^7.1.0" | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env node | |
| const fs = require('fs'); | |
| const path = require('path'); | |
| // Path to the docs directory (relative to the script location) | |
| const DOCS_DIR = path.resolve(__dirname, '../../../docs'); | |
| const CONFIG_FILE = path.resolve(__dirname, '../config.js'); | |
| // Debounce delay in milliseconds | |
| const DEBOUNCE_DELAY = 300; | |
| /** | |
| * Sleep for specified milliseconds | |
| */ | |
| function sleep(ms) { | |
| return new Promise(resolve => setTimeout(resolve, ms)); | |
| } | |
| /** | |
| * Validate if a file has reasonable content | |
| */ | |
| function validateFileContent(content) { | |
| // Check if file is empty or just whitespace | |
| if (!content || content.trim().length === 0) { | |
| return false; | |
| } | |
| // Check if file is too small (likely incomplete) | |
| if (content.length < 10) { | |
| return false; | |
| } | |
| // Check if file contains only frontmatter dashes (incomplete file) | |
| const trimmedContent = content.trim(); | |
| if (trimmedContent === '---' || trimmedContent === '---\n---') { | |
| return false; | |
| } | |
| return true; | |
| } | |
| /** | |
| * Recursively find all markdown files in a directory | |
| */ | |
| function findMarkdownFiles(dir, files = []) { | |
| const entries = fs.readdirSync(dir); | |
| for (const entry of entries) { | |
| const fullPath = path.join(dir, entry); | |
| const stat = fs.statSync(fullPath); | |
| if (stat.isDirectory()) { | |
| // Skip node_modules and other common directories | |
| if (!['node_modules', '.git', 'dist', 'build'].includes(entry)) { | |
| findMarkdownFiles(fullPath, files); | |
| } | |
| } else if (stat.isFile() && entry.endsWith('.md')) { | |
| files.push(fullPath); | |
| } | |
| } | |
| return files; | |
| } | |
| /** | |
| * Extract the first H1 title from markdown content | |
| */ | |
| function extractH1Title(content) { | |
| const lines = content.split('\n'); | |
| for (const line of lines) { | |
| const trimmed = line.trim(); | |
| if (trimmed.startsWith('# ')) { | |
| return trimmed.substring(2).trim(); | |
| } | |
| } | |
| return null; | |
| } | |
| /** | |
| * Parse front matter from markdown content | |
| */ | |
| function parseFrontMatter(content) { | |
| const lines = content.split('\n'); | |
| if (lines[0] !== '---') { | |
| return { frontMatter: {}, contentStart: 0 }; | |
| } | |
| let endIndex = -1; | |
| for (let i = 1; i < lines.length; i++) { | |
| if (lines[i] === '---') { | |
| endIndex = i; | |
| break; | |
| } | |
| } | |
| if (endIndex === -1) { | |
| return { frontMatter: {}, contentStart: 0 }; | |
| } | |
| const frontMatterLines = lines.slice(1, endIndex); | |
| const frontMatter = {}; | |
| for (const line of frontMatterLines) { | |
| const colonIndex = line.indexOf(':'); | |
| if (colonIndex > 0) { | |
| const key = line.substring(0, colonIndex).trim(); | |
| let value = line.substring(colonIndex + 1).trim(); | |
| // Remove quotes if present | |
| if ((value.startsWith('"') && value.endsWith('"')) || | |
| (value.startsWith("'") && value.endsWith("'"))) { | |
| value = value.slice(1, -1); | |
| } | |
| frontMatter[key] = value; | |
| } | |
| } | |
| return { frontMatter, contentStart: endIndex + 1 }; | |
| } | |
| /** | |
| * Generate front matter string from object | |
| */ | |
| function generateFrontMatter(frontMatter) { | |
| if (Object.keys(frontMatter).length === 0) { | |
| return ''; | |
| } | |
| const lines = ['---']; | |
| for (const [key, value] of Object.entries(frontMatter)) { | |
| lines.push(`${key}: "${value}"`); | |
| } | |
| lines.push('---'); | |
| return lines.join('\n') + '\n'; | |
| } | |
| /** | |
| * Get title from filename (fallback when no H1 found) | |
| */ | |
| function getTitleFromFilename(filePath) { | |
| const basename = path.basename(filePath, '.md'); | |
| return basename.charAt(0).toUpperCase() + basename.slice(1).replace(/[-_]/g, ' '); | |
| } | |
| /** | |
| * Process a single markdown file for frontmatter | |
| */ | |
| function processMarkdownFile(filePath) { | |
| const content = fs.readFileSync(filePath, 'utf8'); | |
| // Validate file content before processing | |
| if (!validateFileContent(content)) { | |
| console.log(`β οΈ Skipping ${filePath} - content appears incomplete or empty`); | |
| return { updated: false, title: null, skipped: true }; | |
| } | |
| const { frontMatter, contentStart } = parseFrontMatter(content); | |
| // Check if file already has proper frontmatter title | |
| const existingTitle = frontMatter.title; | |
| const h1Title = extractH1Title(content); | |
| // If file already has a frontmatter title, don't touch it | |
| if (existingTitle) { | |
| return { updated: false, title: existingTitle }; | |
| } | |
| let targetTitle; | |
| let shouldRemoveH1 = false; | |
| // Determine what the title should be | |
| if (h1Title) { | |
| targetTitle = h1Title; | |
| shouldRemoveH1 = true; // Remove H1 since we're adding it to frontmatter | |
| } else { | |
| // No H1 found, use filename as title | |
| targetTitle = getTitleFromFilename(filePath); | |
| } | |
| // Set the frontmatter title | |
| frontMatter.title = targetTitle; | |
| // Reconstruct the file content | |
| const lines = content.split('\n'); | |
| const bodyLines = lines.slice(contentStart); | |
| let finalBodyLines = bodyLines; | |
| // Remove the first H1 heading if needed | |
| if (shouldRemoveH1) { | |
| let h1Removed = false; | |
| finalBodyLines = bodyLines.filter(line => { | |
| const trimmed = line.trim(); | |
| // Only remove the first H1 heading that matches our target title | |
| if (!h1Removed && trimmed.startsWith('# ') && trimmed.substring(2).trim() === targetTitle) { | |
| h1Removed = true; | |
| return false; // Skip this line | |
| } | |
| return true; | |
| }); | |
| } | |
| const bodyContent = finalBodyLines.join('\n'); | |
| const newContent = generateFrontMatter(frontMatter) + bodyContent; | |
| // Write the updated content | |
| fs.writeFileSync(filePath, newContent, 'utf8'); | |
| const actions = [`set title: "${targetTitle}"`]; | |
| if (shouldRemoveH1) { | |
| actions.push('removed H1 heading'); | |
| } | |
| console.log(`Updated ${filePath} (${actions.join(', ')})`); | |
| return { updated: true, title: targetTitle }; | |
| } | |
| /** | |
| * Extract title from markdown frontmatter (for navigation) | |
| */ | |
| function extractTitleFromFrontmatter(content) { | |
| // Look for frontmatter between --- lines | |
| if (content.startsWith('---\n')) { | |
| const endIndex = content.indexOf('\n---\n', 4); | |
| if (endIndex !== -1) { | |
| const frontmatter = content.substring(4, endIndex); | |
| const lines = frontmatter.split('\n'); | |
| for (const line of lines) { | |
| if (line.includes('title:')) { | |
| let title = line.split('title:')[1].trim(); | |
| // Remove quotes if present | |
| if ((title.startsWith('"') && title.endsWith('"')) || | |
| (title.startsWith("'") && title.endsWith("'"))) { | |
| title = title.slice(1, -1); | |
| } | |
| return title; | |
| } | |
| } | |
| } | |
| } | |
| return null; | |
| } | |
| /** | |
| * Convert filename to URL path | |
| */ | |
| function fileToPath(filePath, docsDir) { | |
| const relativePath = path.relative(docsDir, filePath); | |
| const pathWithoutExt = relativePath.replace('.md', ''); | |
| // Handle index files | |
| if (pathWithoutExt === 'index') { | |
| return '/'; | |
| } | |
| if (pathWithoutExt.endsWith('/index')) { | |
| return '/' + pathWithoutExt.replace('/index', ''); | |
| } | |
| return '/' + pathWithoutExt; | |
| } | |
| /** | |
| * Convert directory name to title | |
| */ | |
| function dirToTitle(dirName) { | |
| return dirName.charAt(0).toUpperCase() + dirName.slice(1).replace(/[-_]/g, ' '); | |
| } | |
| /** | |
| * Get order number from title or filename | |
| */ | |
| function getOrderNumber(title) { | |
| // Look for patterns like "01-", "1.", "1_", etc. | |
| const firstChar = title.charAt(0); | |
| if (firstChar >= '0' && firstChar <= '9') { | |
| let numberStr = ''; | |
| let i = 0; | |
| while (i < title.length && title.charAt(i) >= '0' && title.charAt(i) <= '9') { | |
| numberStr += title.charAt(i); | |
| i++; | |
| } | |
| if (numberStr && (title.charAt(i) === '-' || title.charAt(i) === '.' || title.charAt(i) === '_')) { | |
| return parseInt(numberStr); | |
| } | |
| } | |
| return 999; // No number prefix | |
| } | |
| /** | |
| * Sort items by file creation time (newest first), with numbered prefixes taking precedence | |
| */ | |
| function sortItems(items) { | |
| return items.sort((a, b) => { | |
| const orderA = getOrderNumber(a.title); | |
| const orderB = getOrderNumber(b.title); | |
| // If both have number prefixes, sort by number first | |
| if (orderA !== 999 && orderB !== 999) { | |
| return orderA - orderB; | |
| } | |
| // If only one has a number prefix, it comes first | |
| if (orderA !== 999 && orderB === 999) { | |
| return -1; | |
| } | |
| if (orderA === 999 && orderB !== 999) { | |
| return 1; | |
| } | |
| // For items without number prefixes, sort by creation time (newest first) | |
| try { | |
| const timeA = getItemCreationTime(a); | |
| const timeB = getItemCreationTime(b); | |
| if (timeA && timeB) { | |
| return timeB - timeA; // Newest first (descending order) | |
| } | |
| } catch (error) { | |
| console.warn('Error getting creation time for sorting:', error.message); | |
| } | |
| // Fallback to alphabetical if creation times can't be determined | |
| return a.title.localeCompare(b.title); | |
| }); | |
| } | |
| /** | |
| * Get creation time for a navigation item | |
| */ | |
| function getItemCreationTime(item) { | |
| if (!item.filePath) { | |
| return null; | |
| } | |
| try { | |
| const stats = fs.statSync(item.filePath); | |
| // For directories, find the newest file creation time within the directory | |
| if (stats.isDirectory()) { | |
| return getNewestFileTimeInDirectory(item.filePath); | |
| } | |
| // For files, return the creation time (birthtime) | |
| return stats.birthtime.getTime(); | |
| } catch (error) { | |
| console.warn(`Could not get creation time for ${item.filePath}:`, error.message); | |
| return null; | |
| } | |
| } | |
| /** | |
| * Get the newest file creation time within a directory (recursively) | |
| */ | |
| function getNewestFileTimeInDirectory(dirPath) { | |
| let newestTime = 0; | |
| try { | |
| const entries = fs.readdirSync(dirPath, { withFileTypes: true }); | |
| for (const entry of entries) { | |
| const fullPath = path.join(dirPath, entry.name); | |
| if (entry.isDirectory()) { | |
| // Skip problematic directories | |
| if (['node_modules', '.git', 'dist', 'build'].includes(entry.name)) { | |
| continue; | |
| } | |
| const dirTime = getNewestFileTimeInDirectory(fullPath); | |
| if (dirTime > newestTime) { | |
| newestTime = dirTime; | |
| } | |
| } else if (entry.isFile() && entry.name.endsWith('.md')) { | |
| const stats = fs.statSync(fullPath); | |
| const fileTime = stats.birthtime.getTime(); | |
| if (fileTime > newestTime) { | |
| newestTime = fileTime; | |
| } | |
| } | |
| } | |
| } catch (error) { | |
| console.warn(`Could not scan directory ${dirPath} for newest file:`, error.message); | |
| } | |
| return newestTime; | |
| } | |
| /** | |
| * Clean title by removing numbered prefixes | |
| */ | |
| function cleanTitle(title) { | |
| const orderNum = getOrderNumber(title); | |
| if (orderNum !== 999) { | |
| // Find where the actual title starts | |
| let i = 0; | |
| while (i < title.length && title.charAt(i) >= '0' && title.charAt(i) <= '9') { | |
| i++; | |
| } | |
| if (i < title.length && (title.charAt(i) === '-' || title.charAt(i) === '.' || title.charAt(i) === '_')) { | |
| i++; // Skip the separator | |
| if (title.charAt(i) === ' ') i++; // Skip space if present | |
| return title.substring(i); | |
| } | |
| } | |
| return title; | |
| } | |
| /** | |
| * Recursively scan directory and build navigation structure | |
| */ | |
| function scanDirectory(dirPath, docsDir, fileContents) { | |
| const items = []; | |
| try { | |
| const entries = fs.readdirSync(dirPath, { withFileTypes: true }); | |
| for (const entry of entries) { | |
| const fullPath = path.join(dirPath, entry.name); | |
| if (entry.isDirectory()) { | |
| // Skip the misc directory | |
| if (entry.name === 'misc') { | |
| continue; | |
| } | |
| // Handle subdirectories | |
| const children = scanDirectory(fullPath, docsDir, fileContents); | |
| if (children.length > 0) { | |
| items.push({ | |
| title: cleanTitle(dirToTitle(entry.name)), | |
| path: '#', // Parent items need a path property for docmd template | |
| filePath: fullPath, // Include file system path for sorting | |
| children: sortItems(children) | |
| }); | |
| } else { | |
| // Empty folder - link directly to misc/empty-folder | |
| items.push({ | |
| title: cleanTitle(dirToTitle(entry.name)), | |
| path: '/misc/empty-folder', | |
| filePath: fullPath // Include file system path for sorting | |
| }); | |
| } | |
| } else if (entry.isFile() && entry.name.endsWith('.md')) { | |
| // Use the file content we already have from frontmatter processing | |
| const content = fileContents.get(fullPath); | |
| if (content) { | |
| const title = extractTitleFromFrontmatter(content); | |
| if (title) { | |
| items.push({ | |
| title: cleanTitle(title), | |
| path: fileToPath(fullPath, docsDir), | |
| filePath: fullPath // Include file system path for sorting | |
| }); | |
| } | |
| } | |
| } | |
| } | |
| } catch (error) { | |
| console.warn(`Could not scan directory ${dirPath}:`, error.message); | |
| } | |
| return items; | |
| } | |
| /** | |
| * Generate navigation array using already processed file contents | |
| */ | |
| function generateNavigation(fileContents) { | |
| console.log('π Generating navigation from processed files...'); | |
| const navigation = []; | |
| // Always include Home as the first item | |
| navigation.push({ title: 'Home', path: '/', icon: 'home' }); | |
| // Scan for other pages and directories | |
| const scannedItems = scanDirectory(DOCS_DIR, DOCS_DIR, fileContents); | |
| const sortedItems = sortItems(scannedItems); | |
| // Add non-home items to navigation | |
| for (const item of sortedItems) { | |
| if (item.path !== '/') { // Skip the index.md since we already have Home | |
| navigation.push(item); | |
| } | |
| } | |
| return navigation; | |
| } | |
| /** | |
| * Clean navigation items by removing internal properties | |
| */ | |
| function cleanNavigationForConfig(items) { | |
| return items.map(item => { | |
| const cleaned = { | |
| title: item.title, | |
| path: item.path | |
| }; | |
| // Include icon if present | |
| if (item.icon) { | |
| cleaned.icon = item.icon; | |
| } | |
| // Recursively clean children | |
| if (item.children && item.children.length > 0) { | |
| cleaned.children = cleanNavigationForConfig(item.children); | |
| } | |
| return cleaned; | |
| }); | |
| } | |
| /** | |
| * Update config.js with new navigation | |
| */ | |
| function updateConfig(navigation) { | |
| console.log('π Updating config.js...'); | |
| try { | |
| // Clean navigation items to remove internal properties | |
| const cleanedNavigation = cleanNavigationForConfig(navigation); | |
| let configContent = fs.readFileSync(CONFIG_FILE, 'utf8'); | |
| const lines = configContent.split('\n'); | |
| // Find the navigation section | |
| let navigationStartIndex = -1; | |
| let navigationEndIndex = -1; | |
| for (let i = 0; i < lines.length; i++) { | |
| if (lines[i].includes('navigation:')) { | |
| navigationStartIndex = i; | |
| // Find the end of the navigation array | |
| let bracketCount = 0; | |
| let inArray = false; | |
| for (let j = i; j < lines.length; j++) { | |
| const line = lines[j]; | |
| for (let k = 0; k < line.length; k++) { | |
| if (line[k] === '[') { | |
| inArray = true; | |
| bracketCount++; | |
| } else if (line[k] === ']') { | |
| bracketCount--; | |
| if (bracketCount === 0 && inArray) { | |
| navigationEndIndex = j; | |
| break; | |
| } | |
| } | |
| } | |
| if (navigationEndIndex !== -1) break; | |
| } | |
| break; | |
| } | |
| } | |
| if (navigationStartIndex === -1 || navigationEndIndex === -1) { | |
| console.error('β Could not find navigation array in config.js'); | |
| process.exit(1); | |
| } | |
| // Create the new navigation section | |
| const navigationStr = JSON.stringify(cleanedNavigation, null, 4); | |
| const indentedNavigation = navigationStr | |
| .split('\n') | |
| .map((line, index) => index === 0 ? ` navigation: ${line}` : ` ${line}`) | |
| .join('\n'); | |
| // Replace the navigation section | |
| const newLines = [ | |
| ...lines.slice(0, navigationStartIndex), | |
| indentedNavigation + ',', | |
| ...lines.slice(navigationEndIndex + 1) | |
| ]; | |
| const newContent = newLines.join('\n'); | |
| fs.writeFileSync(CONFIG_FILE, newContent); | |
| console.log('β Navigation updated successfully!'); | |
| } catch (error) { | |
| console.error('β Error updating config.js:', error.message); | |
| process.exit(1); | |
| } | |
| } | |
| /** | |
| * Main function that combines both frontmatter processing and navigation generation | |
| */ | |
| async function main() { | |
| console.log('π Processing docs: adding frontmatter and generating navigation...\n'); | |
| // Add debounce delay to allow file operations to complete | |
| if (DEBOUNCE_DELAY > 0) { | |
| console.log(`β³ Waiting ${DEBOUNCE_DELAY}ms for file operations to complete...`); | |
| await sleep(DEBOUNCE_DELAY); | |
| } | |
| if (!fs.existsSync(DOCS_DIR)) { | |
| console.log('docs directory not found, creating it...'); | |
| fs.mkdirSync(DOCS_DIR, { recursive: true }); | |
| return; | |
| } | |
| const markdownFiles = findMarkdownFiles(DOCS_DIR); | |
| if (markdownFiles.length === 0) { | |
| console.log('No markdown files found in docs directory.'); | |
| return; | |
| } | |
| console.log(`Found ${markdownFiles.length} markdown file(s), processing frontmatter...`); | |
| // Process frontmatter and collect file contents for navigation | |
| const fileContents = new Map(); | |
| let frontmatterUpdated = 0; | |
| let skippedFiles = 0; | |
| for (const file of markdownFiles) { | |
| const result = processMarkdownFile(file); | |
| if (result.updated) { | |
| frontmatterUpdated++; | |
| } else if (result.skipped) { | |
| skippedFiles++; | |
| } | |
| // Store the updated content for navigation processing (only if not skipped) | |
| if (!result.skipped) { | |
| try { | |
| const content = fs.readFileSync(file, 'utf8'); | |
| fileContents.set(file, content); | |
| } catch (error) { | |
| console.warn(`Could not read ${file} for navigation:`, error.message); | |
| } | |
| } | |
| } | |
| if (skippedFiles > 0) { | |
| console.log(`\nβ οΈ Skipped ${skippedFiles} file(s) due to incomplete content`); | |
| } | |
| console.log(`\nβ Front matter processing complete! (${frontmatterUpdated} files updated)\n`); | |
| // Generate navigation using the processed file contents | |
| const navigation = generateNavigation(fileContents); | |
| if (navigation.length === 0) { | |
| console.warn('β οΈ No navigation items generated'); | |
| return; | |
| } | |
| updateConfig(navigation); | |
| console.log('\nπ Docs processing complete!'); | |
| } | |
| // Run the script | |
| if (require.main === module) { | |
| main().catch(error => { | |
| console.error('β Error processing docs:', error); | |
| process.exit(1); | |
| }); | |
| } | |
| module.exports = { main }; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment