UNPKG

c4dslbuilder

Version:

A CLI tool designed to compile a folder structure of markdowns and mermaid files into a site, pdf, single file markdown or a collection of markdowns with links - inspired by c4builder

222 lines (221 loc) 10.2 kB
import path from 'node:path'; import chalk from 'chalk'; import { OutputType, ProcessorBase } from './processor-base.js'; import { SafeFiles } from './safe-files.js'; import { CliLogger } from './cli-logger.js'; import { MermaidProcessor } from './mermaid-processor.js'; import { docsifyTemplate } from '../assets/docsify.template.js'; import { CacheManager } from './cache-manager.js'; import * as Constants from '../types/constants.js'; export class SiteProcessor extends ProcessorBase { safeFiles; logger; mermaid; cache; constructor(safeFiles = new SafeFiles(), logger = new CliLogger(SiteProcessor.name), mermaid = new MermaidProcessor(), cache = new CacheManager(path.resolve(Constants.CACHE_FILENAME), safeFiles, logger)) { super(safeFiles, logger, mermaid); this.safeFiles = safeFiles; this.logger = logger; this.mermaid = mermaid; this.cache = cache; } async shouldProcessItem(item) { if (Array.isArray(item.mdFiles)) { for (const md of item.mdFiles) { const fullPath = path.join(item.dir, md.name); if (await this.cache.hasChanged(fullPath)) return true; } } if (Array.isArray(item.mmdFiles)) { for (const mmd of item.mmdFiles) { const fullPath = path.join(item.dir, mmd.name); if (await this.cache.hasChanged(fullPath)) return true; } } return false; } async markProcessedItem(item) { if (Array.isArray(item.mdFiles)) { for (const md of item.mdFiles) { const fullPath = path.join(item.dir, md.name); await this.cache.markProcessed(fullPath); } } if (Array.isArray(item.mmdFiles)) { for (const mmd of item.mmdFiles) { const fullPath = path.join(item.dir, mmd.name); await this.cache.markProcessed(fullPath); } } } getOutputFileName(buildConfig, dir, name) { const destFldr = path.resolve(buildConfig.distFolder); const destPath = path.resolve(dir.replace(buildConfig.rootFolder, buildConfig.distFolder)); // Only add .md if the file doesn't already have an extension const destFileName = path.join(destPath, path.extname(name) ? name : `${name}.md`); return path.relative(destFldr, destFileName); } async writeUnifiedMarkdown(item, buildConfig) { const destFileName = this.getOutputFileName(buildConfig, item.dir, item.name); let MD = `# ${item.name}\n\n`; MD += await this.processMarkdownDocument(item, buildConfig); await this.safeFiles.ensureDir(path.resolve(item.dir.replace(buildConfig.rootFolder, buildConfig.distFolder))); await this.safeFiles.writeFile(path.resolve(path.join(buildConfig.distFolder, destFileName)), MD); } async findOpenApiSpec(rootFolder) { const files = await this.safeFiles.readDir(rootFolder); for (const file of files) { const ext = path.extname(file).toLowerCase(); if (ext === '.yml' || ext === '.yaml') { const content = await this.safeFiles.readFileAsString(path.join(rootFolder, file)); if (content) { const firstLine = content.trim().split('\n')[0].trim(); if (firstLine.startsWith('openapi:') || firstLine.startsWith('swagger:')) { return file; } } } } return null; } async generateOpenApiPage(buildConfig, specFileName) { const specSrc = path.join(buildConfig.rootFolder, specFileName); const specDest = path.join(buildConfig.distFolder, specFileName); await this.safeFiles.copyFile(specSrc, specDest); const html = `<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>OpenAPI Documentation</title> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui.css"> </head> <body> <div id="swagger-ui"></div> <script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui-bundle.js"></script> <script> SwaggerUIBundle({ url: './${specFileName}', dom_id: '#swagger-ui' }); </script> </body> </html>`; await this.safeFiles.writeFile(path.join(buildConfig.distFolder, 'openapi.html'), html); this.logger.log(chalk.green(`OpenAPI documentation generated from ${specFileName}`)); } async writeSidebar(tree, buildConfig, openApiSpec) { // Find the root branch (contains root-level files) const rootBranch = tree.find((item) => item.level === 0); // Get subfolders (all other items) const subfolders = tree.filter((item) => item.level > 0); // Build sidebar: root files first, then subfolders let sidebar = ''; // Add root-level files (no indentation) if (rootBranch) { const rootFiles = [ ...(rootBranch.mdFiles || []).map((f) => ({ name: f.name, type: 'md' })), ...(rootBranch.mmdFiles || []).map((f) => ({ name: f.name, type: 'mmd' })), ]; rootFiles.forEach((file) => { const mdPath = this.getOutputFileName(buildConfig, rootBranch.dir, file.name); sidebar += `* [${file.name}](${encodeURI(mdPath)})\n`; }); } // Add subfolders with proper indentation subfolders.forEach((item) => { const indent = ' '.repeat(item.level - 1); // Adjust indentation const mdPath = this.getOutputFileName(buildConfig, item.dir, item.name); sidebar += `${indent}* [${item.name}](${encodeURI(mdPath)})\n`; }); if (openApiSpec) { sidebar += `\n* [OpenAPI](openapi.html ':ignore')`; } await this.safeFiles.ensureDir(path.resolve(buildConfig.distFolder)); await this.safeFiles.writeFile(path.resolve(path.join(buildConfig.distFolder, '_sidebar.md')), sidebar.trim()); } buildDocsifyOptions(buildConfig) { const homepageName = buildConfig.homepageName.trim() || 'home'; return { name: buildConfig.projectName, repo: buildConfig.repoName, loadSidebar: true, auto2top: true, homepage: `${homepageName}.md`, stylesheet: buildConfig.webTheme, supportSearch: buildConfig.webSearch, mermaidConfig: { querySelector: '.mermaid' }, authHash: buildConfig.passwordProtected ? buildConfig.passwordHash : undefined, logo: buildConfig.logo || undefined, logoAlign: buildConfig.logo ? buildConfig.logoAlign : undefined, logoPosition: buildConfig.logo ? buildConfig.logoPosition : undefined, }; } async loadDocsifyTemplate(buildConfig, docOptions) { if (buildConfig.docsifyTemplate === '') { return docsifyTemplate(docOptions); } try { const templateModulePath = path.resolve(buildConfig.docsifyTemplate); const templateModule = await import(templateModulePath); if (typeof templateModule.docsifyTemplate === 'function') { return templateModule.docsifyTemplate(docOptions); } this.logger.warn(`Custom docsify template module at ${buildConfig.docsifyTemplate} does not export a valid 'docsifyTemplate' function. Using default.`); return docsifyTemplate(docOptions); } catch (error) { this.logger.error(`Error loading custom docsify template at ${buildConfig.docsifyTemplate}. Using default.`, error); return docsifyTemplate(docOptions); } } async copyLogo(buildConfig) { if (!buildConfig.logo) return; const logoSrc = path.join(buildConfig.rootFolder, buildConfig.logo); const logoDest = path.join(buildConfig.distFolder, buildConfig.logo); if (!(await this.safeFiles.pathExists(logoSrc))) { this.logger.warn(`Logo file not found at ${logoSrc}`); return; } await this.safeFiles.ensureDir(path.dirname(logoDest)); await this.safeFiles.copyFile(logoSrc, logoDest); } async generateSiteFromTree(tree, buildConfig) { const openApiSpec = await this.findOpenApiSpec(buildConfig.rootFolder); await this.writeSidebar(tree, buildConfig, openApiSpec || undefined); for (const item of tree) { const filesChanged = await this.shouldProcessItem(item); if (filesChanged) { await this.writeUnifiedMarkdown(item, buildConfig); await this.markProcessedItem(item); } else { this.logger.info(`Skipping unchanged item: ${item.name}`); } } const docOptions = this.buildDocsifyOptions(buildConfig); const docTemplate = await this.loadDocsifyTemplate(buildConfig, docOptions); await this.safeFiles.ensureDir(path.resolve(buildConfig.distFolder)); await this.safeFiles.writeFile(path.join(buildConfig.distFolder, 'index.html'), docTemplate); await this.safeFiles.writeFile(path.join(buildConfig.distFolder, '.nojekyll'), ''); await this.copyLogo(buildConfig); if (openApiSpec) { await this.generateOpenApiPage(buildConfig, openApiSpec); } } async clearCache() { await this.cache.clearCache(); } async prepareSite(buildConfig, cleanBeforeBuild = true) { if (!(await this.prepareOutputFolder(OutputType.site, buildConfig, cleanBeforeBuild))) { this.logger.warn('Output folder preparation failed.'); return; } if (cleanBeforeBuild) { await this.cache.clearCache(); } await this.cache.loadCache(); const tree = await this.generateSourceTree(buildConfig); await this.generateSiteFromTree(tree, buildConfig); this.logger.log(chalk.green(`\nSITE documentation generated successfully!`)); } }