markmv
Version:
TypeScript CLI for markdown file operations with intelligent link refactoring
462 lines (460 loc) • 18.3 kB
JavaScript
import { existsSync, statSync } from 'node:fs';
import { basename, join, relative, resolve } from 'node:path';
import { glob } from 'glob';
import { FileUtils } from '../utils/file-utils.js';
import { TocGenerator } from '../utils/toc-generator.js';
/**
* CLI command handler for generating documentation indexes.
*
* Creates organized documentation indexes from markdown files using various strategies. Supports
* multiple index types including links, imports, embeds, and hybrid modes.
*
* @example
* ```bash
* # Generate a links-based index
* markmv index --type links --strategy directory
*
* # Generate with custom template
* markmv index docs/ --type hybrid --template custom.md
*
* # Dry run with verbose output
* markmv index --dry-run --verbose
* ```;
*
* @param directory - Target directory for index generation
* @param cliOptions - Command options specifying index parameters
*
* @group Commands
*/
export async function indexCommand(directory, cliOptions) {
const options = {
type: cliOptions.type || 'links',
strategy: cliOptions.strategy || 'directory',
location: cliOptions.location || 'root',
name: cliOptions.name || 'index.md',
embedStyle: cliOptions.embedStyle || 'obsidian',
dryRun: cliOptions.dryRun || false,
verbose: cliOptions.verbose || false,
noTraverseUp: cliOptions.noTraverseUp || false,
generateToc: cliOptions.generateToc || false,
tocOptions: {
minDepth: cliOptions.tocMinDepth || 1,
maxDepth: cliOptions.tocMaxDepth || 6,
includeLineNumbers: cliOptions.tocIncludeLineNumbers || false,
},
...(cliOptions.template && { template: cliOptions.template }),
...(cliOptions.maxDepth !== undefined && { maxDepth: cliOptions.maxDepth }),
...(cliOptions.boundary && { boundary: cliOptions.boundary }),
};
if (cliOptions.json) {
return generateIndexFilesJson(options, directory || '.');
}
else {
return generateIndexFiles(options, directory || '.');
}
}
/** Generate index files for markdown documentation (JSON output) */
async function generateIndexFilesJson(options, directory) {
const targetDir = resolve(directory);
if (!existsSync(targetDir)) {
throw new Error(`Directory not found: ${targetDir}`);
}
if (!statSync(targetDir).isDirectory()) {
throw new Error(`Path is not a directory: ${targetDir}`);
}
try {
// Discover markdown files
const files = await discoverMarkdownFiles(targetDir, options);
// Organize files based on strategy
const organizedFiles = organizeFiles(files, options);
// Convert to JSON output
const jsonOutput = {
directory: targetDir,
options: {
type: options.type,
strategy: options.strategy,
location: options.location,
},
totalFiles: files.length,
organizedFiles: Object.fromEntries(Array.from(organizedFiles.entries()).map(([key, groupFiles]) => [
key,
groupFiles.map((file) => ({
path: file.path,
relativePath: file.relativePath,
title: file.metadata.title || file.relativePath,
})),
])),
files: files.map((file) => ({
path: file.path,
relativePath: file.relativePath,
title: file.metadata.title || file.relativePath,
})),
};
console.log(JSON.stringify(jsonOutput, null, 2));
}
catch (error) {
if (error instanceof Error) {
throw new Error(`Failed to generate index: ${error.message}`);
}
throw error;
}
}
/** Generate index files for markdown documentation */
async function generateIndexFiles(options, directory) {
const targetDir = resolve(directory);
if (!existsSync(targetDir)) {
throw new Error(`Directory not found: ${targetDir}`);
}
if (!statSync(targetDir).isDirectory()) {
throw new Error(`Path is not a directory: ${targetDir}`);
}
if (options.verbose) {
console.log(`Generating indexes in: ${targetDir}`);
console.log(`Type: ${options.type}, Strategy: ${options.strategy}, Location: ${options.location}`);
}
try {
// Discover markdown files
const files = await discoverMarkdownFiles(targetDir, options);
// Organize files based on strategy
const organizedFiles = organizeFiles(files, options);
// Generate index files based on location strategy
const indexPaths = determineIndexLocations(targetDir, files, options);
// Generate each index file
for (const indexPath of indexPaths) {
const relevantFiles = getRelevantFilesForIndex(indexPath, organizedFiles, options);
const indexContent = await generateIndexContent(indexPath, relevantFiles, options);
if (options.dryRun) {
console.log(`Would create: ${indexPath}`);
if (options.verbose) {
console.log('Content:');
console.log(indexContent);
console.log('---');
}
}
else {
await writeIndexFile(indexPath, indexContent, options);
console.log(`Generated: ${relative(process.cwd(), indexPath)}`);
}
}
}
catch (error) {
console.error('Error generating indexes:', error);
throw error;
}
}
/** Discover all markdown files in the target directory */
async function discoverMarkdownFiles(targetDir, options) {
// Determine the effective boundary for file scanning
const effectiveBoundary = options.boundary ? resolve(options.boundary) : targetDir;
// Build glob pattern based on maxDepth option
let globPattern;
if (options.maxDepth !== undefined) {
// Create depth-limited pattern
const depthPattern = Array.from({ length: options.maxDepth }, () => '*').join('/');
globPattern = join(targetDir, depthPattern, '*.md').replace(/\\/g, '/');
}
else {
globPattern = join(targetDir, '**/*.md').replace(/\\/g, '/');
}
const globOptions = {
ignore: ['**/node_modules/**'],
};
// Only set cwd if noTraverseUp is enabled
if (options.noTraverseUp) {
globOptions.cwd = targetDir;
}
const filePaths = await glob(globPattern, globOptions);
// Filter files to respect boundary constraints and convert Path objects to strings
const boundaryFilePaths = filePaths
.map((filePath) => {
const pathStr = typeof filePath === 'string' ? filePath : filePath.toString();
return resolve(pathStr); // Ensure consistent absolute paths
})
.filter((filePath) => {
const resolvedPath = filePath;
// Ensure file is within the boundary directory
if (options.boundary) {
const relativeToBoundary = relative(effectiveBoundary, resolvedPath);
if (relativeToBoundary.startsWith('..')) {
return false; // File is outside boundary
}
}
// Ensure file is within or below target directory when noTraverseUp is enabled
if (options.noTraverseUp) {
const relativeToTarget = relative(targetDir, resolvedPath);
if (relativeToTarget.startsWith('..')) {
return false; // File is above target directory
}
}
return true;
});
const files = [];
for (const filePath of boundaryFilePaths) {
// Skip existing index files if they match our naming pattern
const fileName = basename(filePath);
if (fileName === options.name) {
continue;
}
try {
const content = await FileUtils.readTextFile(filePath);
const metadata = extractFrontmatter(content);
files.push({
path: filePath,
relativePath: relative(targetDir, filePath).replace(/\\/g, '/'),
metadata,
content,
});
}
catch (error) {
if (options.verbose) {
console.warn(`Warning: Could not read file ${filePath}:`, error);
}
}
}
return files;
}
/** Extract frontmatter metadata from markdown content */
function extractFrontmatter(content) {
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
if (!frontmatterMatch) {
return {};
}
try {
const frontmatter = frontmatterMatch[1];
const metadata = {};
// Simple YAML parsing for common fields
const lines = frontmatter.split('\n');
for (const line of lines) {
const match = line.match(/^(\w+):\s*(.+)$/);
if (match) {
const [, key, value] = match;
switch (key) {
case 'title':
metadata.title = value.replace(/['"]/g, '');
break;
case 'description':
metadata.description = value.replace(/['"]/g, '');
break;
case 'category':
metadata.category = value.replace(/['"]/g, '');
break;
case 'order':
metadata.order = Number.parseInt(value, 10);
break;
case 'tags': {
// Handle array format: [tag1, tag2] or simple string
const tagMatch = value.match(/\[(.*)\]/);
if (tagMatch) {
metadata.tags = tagMatch[1].split(',').map((t) => t.trim().replace(/['"]/g, ''));
}
else {
metadata.tags = [value.replace(/['"]/g, '')];
}
break;
}
}
}
}
return metadata;
}
catch {
return {};
}
}
/** Organize files based on the specified strategy */
function organizeFiles(files, options) {
const organized = new Map();
for (const file of files) {
let groupKey;
switch (options.strategy) {
case 'directory': {
// Group by immediate parent directory
const pathParts = file.relativePath.split('/');
groupKey = pathParts.length > 1 ? pathParts[0] : 'root';
break;
}
case 'metadata':
// Group by category from frontmatter
groupKey = file.metadata.category || 'uncategorized';
break;
case 'manual':
// For now, treat as directory-based, but this could be extended
// to read configuration from a special file
groupKey = file.relativePath.split('/')[0] || 'root';
break;
default:
groupKey = 'all';
}
if (!organized.has(groupKey)) {
organized.set(groupKey, []);
}
const group = organized.get(groupKey);
if (group) {
group.push(file);
}
}
// Sort files within each group
for (const [_groupKey, groupFiles] of organized) {
groupFiles.sort((a, b) => {
// Sort by order if specified in metadata
if (a.metadata.order !== undefined && b.metadata.order !== undefined) {
return a.metadata.order - b.metadata.order;
}
// Fall back to alphabetical by title or filename
const aTitle = a.metadata.title || a.relativePath;
const bTitle = b.metadata.title || b.relativePath;
return aTitle.localeCompare(bTitle);
});
}
return organized;
}
/** Determine where index files should be created based on location strategy */
function determineIndexLocations(targetDir, files, options) {
const locations = [];
switch (options.location) {
case 'root':
locations.push(join(targetDir, options.name));
break;
case 'all': {
// Get all unique directories
const directories = new Set();
directories.add(targetDir); // Root directory
for (const file of files) {
const fileDir = join(targetDir, file.relativePath.split('/').slice(0, -1).join('/'));
directories.add(fileDir);
}
for (const dir of directories) {
locations.push(join(dir, options.name));
}
break;
}
case 'branch': {
// Only directories that contain subdirectories
const branchDirs = new Set();
branchDirs.add(targetDir); // Always include root
for (const file of files) {
const pathParts = file.relativePath.split('/');
if (pathParts.length > 2) {
// Has subdirectories
const branchDir = join(targetDir, pathParts[0]);
branchDirs.add(branchDir);
}
}
for (const dir of branchDirs) {
locations.push(join(dir, options.name));
}
break;
}
case 'existing': {
// Only where index files already exist
for (const file of files) {
const dir = join(targetDir, file.relativePath.split('/').slice(0, -1).join('/'));
const potentialIndex = join(dir, options.name);
if (existsSync(potentialIndex)) {
locations.push(potentialIndex);
}
}
// Always check root
const rootIndex = join(targetDir, options.name);
if (existsSync(rootIndex)) {
locations.push(rootIndex);
}
break;
}
}
return [...new Set(locations)]; // Remove duplicates
}
/** Get files relevant to a specific index location */
function getRelevantFilesForIndex(_indexPath, organizedFiles, _options) {
// For now, return all organized files
// This could be refined to only include files in the same directory tree
return organizedFiles;
}
/** Generate the content for an index file */
async function generateIndexContent(indexPath, organizedFiles, options) {
const now = new Date().toISOString();
const indexDir = indexPath.replace(/\\/g, '/').split('/').slice(0, -1).join('/');
const tocGenerator = new TocGenerator();
let content = `---
generated: true
generator: markmv-index
type: ${options.type}
strategy: ${options.strategy}
updated: ${now}
---
# Documentation Index
`;
for (const [groupName, files] of organizedFiles) {
if (files.length === 0)
continue;
// Capitalize and format group name
const displayName = groupName
.split('-')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
content += `## ${displayName}\n\n`;
for (const file of files) {
const relativePath = relative(indexDir, file.path).replace(/\\/g, '/');
const title = file.metadata.title || file.relativePath.split('/').pop()?.replace('.md', '') || 'Untitled';
const description = file.metadata.description;
switch (options.type) {
case 'links':
content += `- [${title}](${relativePath})`;
if (description) {
content += ` - ${description}`;
}
content += '\n';
// Add TOC if enabled and file has headings
if (options.generateToc) {
const tocResult = await tocGenerator.generateToc(file.content, options.tocOptions);
if (tocResult.toc && tocResult.headings.length > 0) {
content += ` - Table of Contents:\n`;
const indentedToc = tocResult.toc
.split('\n')
.map((line) => ` ${line}`)
.join('\n');
content += `${indentedToc}\n`;
}
}
break;
case 'import':
content += `### ${title}\n`;
content += `@${relativePath}\n\n`;
break;
case 'embed':
content += `### ${title}\n`;
if (options.embedStyle === 'obsidian') {
content += `![[${relativePath}]]\n\n`;
}
else {
content += `\n\n`;
}
break;
case 'hybrid':
content += `### [${title}](${relativePath})\n`;
if (description) {
content += `> ${description}\n\n`;
}
else {
content += '\n';
}
// Add TOC if enabled and file has headings
if (options.generateToc) {
const tocResult = await tocGenerator.generateToc(file.content, options.tocOptions);
if (tocResult.toc && tocResult.headings.length > 0) {
content += `#### Table of Contents\n\n`;
content += `${tocResult.toc}\n\n`;
}
}
break;
}
}
content += '\n';
}
return content;
}
/** Write the index file to disk */
async function writeIndexFile(indexPath, content, _options) {
await FileUtils.writeTextFile(indexPath, content, { createDirectories: true });
}
//# sourceMappingURL=index.js.map