Skip to content

Instantly share code, notes, and snippets.

@amxv
Created July 3, 2025 17:42
Show Gist options
  • Select an option

  • Save amxv/08352da27a669d1c9a1ae7adf400c2c7 to your computer and use it in GitHub Desktop.

Select an option

Save amxv/08352da27a669d1c9a1ae7adf400c2c7 to your computer and use it in GitHub Desktop.
docmd process docs live
{
"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"
}
}
#!/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