All files / src/plugins/default markbind-plugin-plantuml.ts

71.64% Statements 48/67
42.85% Branches 15/35
37.5% Functions 3/8
71.64% Lines 48/67

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188                                                2x 2x   2x   2x                 2x   2x               3x     3x         3x 3x     3x     3x     3x 3x       3x 3x                     3x           3x       3x             2x 10x     2x                                 2x 60x 57x   3x                 3x         3x 1x 1x       1x 1x 1x             1x 1x   2x   2x 1x 1x     1x   1x   1x 1x 1x     2x     3x   3x 3x                
/**
 * Parses PlantUML diagrams
 * Replaces <puml> tags with <pic> tags with the appropriate src attribute and generates the diagrams
 * by running the JAR executable
 */
import cheerio from 'cheerio';
import fs from 'fs';
import path from 'path';
import { exec } from 'child_process';
import { createHash } from 'crypto';
import { fileURLToPath } from 'url';
 
import * as fsUtil from '../../utils/fsUtil.js';
import * as logger from '../../utils/logger.js';
import * as urlUtil from '../../utils/urlUtil.js';
import { PluginContext } from '../Plugin.js';
import { NodeProcessorConfig } from '../../html/NodeProcessor.js';
import { MbNode } from '../../utils/node.js';
import { instance as LockManager } from '../../utils/LockManager.js';
 
interface DiagramStatus {
  hashKey: string;
}
 
const __filepath = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filepath);
 
const JAR_PATH = path.resolve(__dirname, 'plantuml.jar');
 
const PUML_EXT = '.png';
 
/**
* This Map maintains a record of processed diagrams. When a diagram is generated or regenerated,
* it's added to this map. Subsequently, if a PUML or non-PUML file is edited, leading to a hot reload,
* the generateDiagram function can avoid redundant regeneration by checking this map.
* If the diagram's identifier is present in the map,
* the generation process is bypassed, thus preventing duplicates.
 */
const processedDiagrams = new Map<string, DiagramStatus>();
 
let graphvizCheckCompleted = false;
 
/**
 * Generates diagram and returns the file name of the diagram
 * @param imageOutputPath output path of the diagram to be generated
 * @param content puml dsl used to generate the puml diagram
 */
function generateDiagram(imageOutputPath: string, content: string) {
  const hashKey = createHash('md5').update(imageOutputPath + content).digest('hex').toString();
 
  // Avoid generating twice
  Iif (processedDiagrams.has(imageOutputPath) && processedDiagrams.get(imageOutputPath)?.hashKey === hashKey) {
    return;
  }
 
  // Creates output dir if it doesn't exist
  const outputDir = path.dirname(imageOutputPath);
  Iif (!fs.existsSync(outputDir)) {
    fs.mkdirSync(outputDir, { recursive: true });
  }
  const lockId = LockManager.createLock();
 
  // Add new diagram to the map
  processedDiagrams.set(imageOutputPath, { hashKey });
 
  // Java command to launch PlantUML jar
  const cmd = `java -jar "${JAR_PATH}" -nometadata -pipe > "${imageOutputPath}"`;
  const childProcess = exec(cmd, {
    cwd: outputDir, // Invoke image generation in the same directory to avoid file inclusion issues
  });
 
  let errorLog = '';
  childProcess.stdin?.write(
    content,
    (e) => {
      Iif (e) {
        logger.debug(e as unknown as string);
        logger.error(`Error generating ${imageOutputPath}`);
      }
      childProcess.stdin?.end();
    },
  );
 
  childProcess.on('error', (error) => {
    logger.debug(error as unknown as string);
    logger.error(`Error generating ${imageOutputPath}`);
    LockManager.deleteLock(lockId);
  });
 
  childProcess.stderr?.on('data', (errorMsg) => {
    errorLog += errorMsg;
  });
 
  childProcess.on('exit', () => {
    // This goes to the log file, but not shown on the console
    logger.debug(errorLog);
    LockManager.deleteLock(lockId);
  });
}
 
const beforeSiteGenerate = () => {
  graphvizCheckCompleted = false;
};
 
const tagConfig = {
  puml: {
    isSpecial: true,
    attributes: [
      {
        name: 'name',
        isRelative: true,
      },
      {
        name: 'src',
        isRelative: true,
        isSourceFile: true,
      },
    ],
  },
};
 
const processNode = (_pluginContext: PluginContext, node: MbNode, config: NodeProcessorConfig) => {
  if (node.name !== 'puml') {
    return;
  }
  Iif (process.platform !== 'win32' && config.plantumlCheck && !graphvizCheckCompleted) {
    exec(`java -jar "${JAR_PATH}" -testdot`, (_error, _stdout, stderr) => {
      Iif (stderr.includes('Error: No dot executable found')) {
        logger.warn('You are using PlantUML diagrams but Graphviz is not installed!');
      }
    });
    graphvizCheckCompleted = true;
  }
 
  node.name = 'pic';
 
  let pumlContent;
  let pathFromRootToImage;
 
  if (node.attribs.src) {
    const srcWithoutBaseUrl = urlUtil.stripBaseUrl(node.attribs.src, config.baseUrl);
    const srcWithoutLeadingSlash = srcWithoutBaseUrl.startsWith('/')
      ? srcWithoutBaseUrl.substring(1)
      : srcWithoutBaseUrl;
 
    const rawPath = path.resolve(config.rootPath, srcWithoutLeadingSlash);
    try {
      pumlContent = fs.readFileSync(rawPath, 'utf8');
    } catch (err) {
      logger.debug(err as string);
      logger.error(`Error reading ${rawPath} for <puml> tag`);
      return;
    }
 
    pathFromRootToImage = fsUtil.setExtension(srcWithoutLeadingSlash, PUML_EXT);
    node.attribs.src = fsUtil.ensurePosix(fsUtil.setExtension(node.attribs.src, PUML_EXT));
  } else {
    pumlContent = cheerio(node).text();
 
    if (node.attribs.name) {
      const nameWithoutBaseUrl = urlUtil.stripBaseUrl(node.attribs.name, config.baseUrl);
      const nameWithoutLeadingSlash = nameWithoutBaseUrl.startsWith('/')
        ? nameWithoutBaseUrl.substring(1)
        : nameWithoutBaseUrl;
      pathFromRootToImage = fsUtil.ensurePosix(fsUtil.setExtension(nameWithoutLeadingSlash, PUML_EXT));
 
      delete node.attribs.name;
    } else {
      const normalizedContent = pumlContent.replace(/\r\n/g, '\n');
      const hashedContent = createHash('md5').update(normalizedContent).digest('hex').toString();
      pathFromRootToImage = `${hashedContent}${PUML_EXT}`;
    }
 
    node.attribs.src = `${config.baseUrl}/${pathFromRootToImage}`;
  }
 
  node.children = [];
 
  const imageOutputPath = path.resolve(config.outputPath, pathFromRootToImage);
  generateDiagram(imageOutputPath, pumlContent);
};
 
export {
  tagConfig,
  beforeSiteGenerate,
  processNode,
};