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
315 lines (314 loc) • 14.1 kB
JavaScript
import path from 'path';
import chalk from 'chalk';
import remarkParse from 'remark-parse';
import remarkStringify from 'remark-stringify';
import { visit } from 'unist-util-visit';
import { unified } from 'unified';
import { toString } from 'mdast-util-to-string';
export var OutputType;
(function (OutputType) {
OutputType["md"] = "md";
OutputType["pdf"] = "pdf";
OutputType["site"] = "site";
})(OutputType || (OutputType = {}));
export class ProcessorBase {
safeFiles;
logger;
mermaid;
constructor(safeFiles, logger, mermaid) {
this.safeFiles = safeFiles;
this.logger = logger;
this.mermaid = mermaid;
}
collectMermaidLinks(markdownContent, contentLocation, buildConfig) {
const mmdFiles = [];
visit(markdownContent, 'link', (node, index, parent) => {
const itemPath = node.url;
if (!itemPath.startsWith('http') &&
itemPath.endsWith('.mmd') &&
typeof index === 'number' &&
parent) {
const absolutePath = path.resolve(contentLocation, itemPath);
const destPath = absolutePath
.replace(buildConfig.rootFolder, buildConfig.distFolder)
.replace('.mmd', '.svg');
const relativePath = path.relative(path.resolve(buildConfig.distFolder), destPath);
mmdFiles.push({
node,
absolutePath,
destPath,
relativePath,
index,
parent,
content: 'loadFromFile',
});
}
});
return mmdFiles;
}
collectMermaidCodeBlocks(markdownContent, contentLocation, buildConfig) {
const mmdFiles = [];
visit(markdownContent, 'code', (node, index, parent) => {
if (node.lang === 'mermaid' && typeof index === 'number' && parent) {
const absolutePath = path.resolve(contentLocation);
const destPath = path.resolve(absolutePath.replace(buildConfig.rootFolder, buildConfig.distFolder));
const relativePath = path.relative(path.resolve(buildConfig.distFolder), destPath);
mmdFiles.push({
node,
absolutePath,
destPath,
relativePath,
index,
parent,
content: node.value,
});
}
});
return mmdFiles;
}
async processMermaidFile(file, buildConfig, contentLocation) {
try {
let content = file.content;
if (content === 'loadFromFile') {
if (!(await this.safeFiles.pathExists(file.absolutePath))) {
this.logger.warn(`Linked mermaid file not found: ${file.absolutePath}`);
return;
}
content = (await this.safeFiles.readFileAsString(file.absolutePath)) ?? '';
}
else {
const uniqueName = await this.mermaid.generateUniqueMmdFilename(file.destPath);
file.destPath = path.join(file.destPath, uniqueName);
}
if (!content.trim()) {
this.logger.warn(`Mermaid content empty at ${file.absolutePath}`);
return;
}
await this.mermaid.diagramFromMermaidString(content, file.destPath);
let imgUrl = path.relative(path.resolve(buildConfig.distFolder), file.destPath);
if (buildConfig.generateWebsite) {
imgUrl = path.relative(path.resolve(contentLocation.replace(buildConfig.rootFolder, buildConfig.distFolder)), file.destPath);
}
file.parent.children.splice(file.index, 1, {
type: 'paragraph',
children: [
{
type: 'image',
alt: path.basename(file.destPath),
url: imgUrl,
},
],
});
}
catch (error) {
this.logger.error(`Failed to process Mermaid file: ${file.absolutePath}`, error);
}
}
async convertMermaidToImages(markdownContent, contentLocation, buildConfig) {
const mmdFiles = [
...this.collectMermaidLinks(markdownContent, contentLocation, buildConfig),
...this.collectMermaidCodeBlocks(markdownContent, contentLocation, buildConfig),
];
for (const file of mmdFiles) {
await this.processMermaidFile(file, buildConfig, contentLocation);
}
}
async embedLinkedMermaidFiles(markdownContent, contentLocation, buildConfig) {
const mmdLinks = [];
visit(markdownContent, 'link', (node, index, parent) => {
const itemPath = node.url;
if (node.type === 'link' &&
!itemPath.startsWith('http') &&
itemPath.endsWith('.mmd') && // INclude mermaid
typeof index === 'number' &&
parent) {
const absolutePath = path.resolve(contentLocation, itemPath);
const destPath = absolutePath.replace(buildConfig.rootFolder, buildConfig.distFolder);
const relativePath = path.relative(path.resolve(buildConfig.distFolder), destPath);
mmdLinks.push({
node,
absolutePath,
destPath,
relativePath,
index,
parent,
});
}
});
for (const mmdLink of mmdLinks) {
try {
if (!(await this.safeFiles.pathExists(mmdLink.absolutePath))) {
this.logger.warn(`Linked mermaid file not found: ${mmdLink.absolutePath}`);
continue;
}
const mermaidContent = await this.safeFiles.readFileAsString(mmdLink.absolutePath);
if (!mermaidContent) {
this.logger.warn(`Linked mermaid file appears to be empty: ${mmdLink.absolutePath}`);
continue;
}
mmdLink.parent.children.splice(mmdLink.index, 1, {
type: 'code',
lang: 'mermaid',
value: mermaidContent,
});
this.logger.info(`Embedded mermaid source from ${mmdLink.absolutePath}`);
}
catch (error) {
this.logger.error(`Failed to process linked Mermaid file: ${mmdLink.absolutePath}`, error);
}
}
}
async copyLinkedFiles(markdownContent, contentLocation, buildConfig) {
const linkedFiles = [];
// linked images
visit(markdownContent, 'image', (node) => {
const imagePath = node.url;
if (!imagePath.startsWith('http')) {
const absolutePath = path.resolve(contentLocation, imagePath);
const destPath = absolutePath.replace(buildConfig.rootFolder, buildConfig.distFolder);
const relativePath = path.relative(path.resolve(buildConfig.distFolder), destPath);
linkedFiles.push({
node,
absolutePath,
destPath,
relativePath,
});
}
});
// linked files (non-image files)
visit(markdownContent, 'link', (node, index, parent) => {
const itemPath = node.url;
if (node.type === 'link' &&
!itemPath.startsWith('http') &&
!itemPath.endsWith('.mmd') && // EXclude mermaid
typeof index === 'number' &&
parent) {
const absolutePath = path.resolve(contentLocation, itemPath);
const destPath = absolutePath.replace(buildConfig.rootFolder, buildConfig.distFolder);
const relativePath = path.relative(path.resolve(buildConfig.distFolder), destPath);
linkedFiles.push({
node,
absolutePath,
destPath,
relativePath,
});
}
});
// copy found files
for (const linkedItem of linkedFiles) {
try {
if (!(await this.safeFiles.pathExists(linkedItem.absolutePath))) {
this.logger.warn(`Linked file not found: ${linkedItem.absolutePath}`);
continue;
}
await this.safeFiles.copyFile(linkedItem.absolutePath, linkedItem.destPath);
linkedItem.node.url = linkedItem.relativePath;
if (linkedItem.node.type === 'image') {
linkedItem.node.alt =
linkedItem.node.alt ??
path.basename(linkedItem.absolutePath, path.extname(linkedItem.absolutePath));
}
else if (!linkedItem.node.url.endsWith('.md')) {
// non-md links should not be compiled (https://docsify.js.org/#/helpers?id=ignore-to-compile-link)
linkedItem.node.url = `${linkedItem.node.url.replace(/\s/g, '%20')} ':ignore'`;
}
this.logger.info(`Copied file to ${linkedItem.relativePath}`);
}
catch (err) {
this.logger.error(`Failed to copy linked item ${linkedItem.absolutePath}`, err);
}
}
}
async prepareMarkdownContent(content, mdName, contentLocation, buildConfig) {
const markdownContent = unified().use(remarkParse).parse(content);
const linkHandler = (node, _parent, _context) => {
const linkText = toString(node);
const url = node.url;
return `[${linkText}](${url})`;
};
this.logger.info(`Copying linked files in ${contentLocation} ...`);
await this.copyLinkedFiles(markdownContent, contentLocation, buildConfig);
if (buildConfig.embedMermaidDiagrams) {
this.logger.info(`Converting linked Mermaid files to embedded code blocks in ${mdName} ...`);
await this.embedLinkedMermaidFiles(markdownContent, contentLocation, buildConfig);
}
else {
this.logger.info(`Converting Mermaid documents in ${mdName} to linked SVG images ...`);
await this.convertMermaidToImages(markdownContent, contentLocation, buildConfig);
}
this.logger.info('... Done!');
return unified()
.use(remarkStringify, { handlers: { link: linkHandler } })
.stringify(markdownContent);
}
async processMarkdownDocument(item, buildConfig) {
const texts = [];
if (Array.isArray(item.mdFiles)) {
for (const mdFile of item.mdFiles) {
try {
const processed = await this.prepareMarkdownContent(mdFile.content, mdFile.name, item.dir, buildConfig);
texts.push(processed);
}
catch (error) {
this.logger.error(`Failed to process markdown file: ${item.dir}/${mdFile.name}`, error);
}
}
}
return '\n\n' + texts.join('\n\n');
}
async buildDocumentBody(tree, buildConfig) {
let docBody = '';
for (const item of tree) {
const name = this.safeFiles.getFolderName(item.dir, buildConfig.rootFolder, buildConfig.homepageName);
docBody += `\n\n# ${name}`;
if (name !== buildConfig.homepageName) {
docBody += `\n\n[${buildConfig.homepageName}](#${encodeURI(buildConfig.projectName).replace(/%20/g, '-')})`;
}
docBody += '\n\n';
docBody += await this.processMarkdownDocument(item, buildConfig);
}
return docBody;
}
generateDocumentHeader(tree, buildConfig) {
const docHeader = `# ${buildConfig.projectName}\n\n`;
const toc = tree
.map((item) => {
const indent = ' '.repeat(item.level);
return `${indent}* [${item.name}](#${encodeURI(item.name).replace(/%20/g, '-')})`;
})
.join('\n');
return `${docHeader}\n${toc}\n\n---`;
}
async prepareOutputFolder(type, buildConfig, cleanBeforeBuild = true) {
const targetFolderBase = buildConfig.distFolder;
if (!targetFolderBase?.trim?.() || targetFolderBase === 'undefined') {
this.logger.error(`Please run \`config\` before attempting to run \`${type}\`.`);
return false;
}
const targetFolder = path.join(targetFolderBase);
if (cleanBeforeBuild) {
if (!(await this.safeFiles.emptySubFolder(targetFolder))) {
this.logger.error(`Failed to empty the target folder: ${targetFolder}`);
return false;
}
}
const webTheme = buildConfig.webTheme.trim();
if (webTheme && !webTheme.startsWith('http')) {
const themePath = path.resolve(buildConfig.rootFolder, webTheme);
const themeExists = await this.safeFiles.pathExists(themePath);
if (!themeExists) {
this.logger.error(`Could not find docsufy theme (CSS) file at ${themePath}`);
return false;
}
const destPath = path.resolve(themePath.replace(buildConfig.rootFolder, buildConfig.distFolder));
await this.safeFiles.copyFile(themePath, destPath);
}
this.logger.log(chalk.green(`\nBuilding ${type.toUpperCase()} documentation in ./${targetFolder}`));
return true;
}
async generateSourceTree(buildConfig) {
const tree = await this.safeFiles.generateTree(buildConfig.rootFolder, buildConfig.rootFolder, buildConfig.homepageName);
this.logger.log(chalk.magenta(`Parsed ${tree.length} folders.\n`));
return tree;
}
}