diff --git a/docs/userGuide/puml.md b/docs/userGuide/puml.md index d46a132c3f..99c44a5e70 100644 --- a/docs/userGuide/puml.md +++ b/docs/userGuide/puml.md @@ -34,12 +34,15 @@ Name | Type | Default | Description --- | --- | --- | --- alt | `string` | | **This must be specified.**
The alternative text of the diagram. height | `string` | | The height of the diagram in pixels. -src | `string` | | **This must be specified.**
The URL of the diagram.
The URL can be specified as absolute or relative references. More info in: _[Intra-Site Links]({{baseUrl}}/userGuide/formattingContents.html#intraSiteLinks)_ +src | `string` | |
The URL of the diagram if your PUML input is in another file.
The URL can be specified as absolute or relative references. More info in: _[Intra-Site Links]({{baseUrl}}/userGuide/formattingContents.html#intraSiteLinks)_ +name | `string` | | The name of the output file. width | `string` | | The width of the diagram in pixels.
If both width and height are specified, width takes priority over height. It is to maintain the diagram's aspect ratio. ### Example +You could have your PUML be written in a separate file or inline. + @@ -57,17 +60,33 @@ return success @enduml ``` -_Markbind page_: +_Markbind page separate file_: ``` ``` +_Markbind page inline_: +````` + + +```puml +@startuml +alice -> bob ++ : hello +bob -> bob ++ : self call +bob -> bib ++ #005500 : hello +bob -> george ** : create +return done +return rc +bob -> george !! : delete +return success +@enduml +``` +````` - diff --git a/package-lock.json b/package-lock.json index 9bc479320c..1917a44816 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1933,6 +1933,11 @@ "which": "^1.2.9" } }, + "crypto-js": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-3.1.8.tgz", + "integrity": "sha1-cV8HC/YBTyrpkqmLOSkli3E/CNU=" + }, "css-select": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/css-select/-/css-select-1.2.0.tgz", @@ -3226,8 +3231,7 @@ }, "ansi-regex": { "version": "2.1.1", - "bundled": true, - "optional": true + "bundled": true }, "aproba": { "version": "1.2.0", @@ -3245,13 +3249,11 @@ }, "balanced-match": { "version": "1.0.0", - "bundled": true, - "optional": true + "bundled": true }, "brace-expansion": { "version": "1.1.11", "bundled": true, - "optional": true, "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -3264,18 +3266,15 @@ }, "code-point-at": { "version": "1.1.0", - "bundled": true, - "optional": true + "bundled": true }, "concat-map": { "version": "0.0.1", - "bundled": true, - "optional": true + "bundled": true }, "console-control-strings": { "version": "1.1.0", - "bundled": true, - "optional": true + "bundled": true }, "core-util-is": { "version": "1.0.2", @@ -3378,8 +3377,7 @@ }, "inherits": { "version": "2.0.3", - "bundled": true, - "optional": true + "bundled": true }, "ini": { "version": "1.3.5", @@ -3389,7 +3387,6 @@ "is-fullwidth-code-point": { "version": "1.0.0", "bundled": true, - "optional": true, "requires": { "number-is-nan": "^1.0.0" } @@ -3402,20 +3399,17 @@ "minimatch": { "version": "3.0.4", "bundled": true, - "optional": true, "requires": { "brace-expansion": "^1.1.7" } }, "minimist": { "version": "0.0.8", - "bundled": true, - "optional": true + "bundled": true }, "minipass": { "version": "2.3.5", "bundled": true, - "optional": true, "requires": { "safe-buffer": "^5.1.2", "yallist": "^3.0.0" @@ -3432,7 +3426,6 @@ "mkdirp": { "version": "0.5.1", "bundled": true, - "optional": true, "requires": { "minimist": "0.0.8" } @@ -3505,8 +3498,7 @@ }, "number-is-nan": { "version": "1.0.1", - "bundled": true, - "optional": true + "bundled": true }, "object-assign": { "version": "4.1.1", @@ -3516,7 +3508,6 @@ "once": { "version": "1.4.0", "bundled": true, - "optional": true, "requires": { "wrappy": "1" } @@ -3592,8 +3583,7 @@ }, "safe-buffer": { "version": "5.1.2", - "bundled": true, - "optional": true + "bundled": true }, "safer-buffer": { "version": "2.1.2", @@ -3623,7 +3613,6 @@ "string-width": { "version": "1.0.2", "bundled": true, - "optional": true, "requires": { "code-point-at": "^1.0.0", "is-fullwidth-code-point": "^1.0.0", @@ -3641,7 +3630,6 @@ "strip-ansi": { "version": "3.0.1", "bundled": true, - "optional": true, "requires": { "ansi-regex": "^2.0.0" } @@ -3680,13 +3668,11 @@ }, "wrappy": { "version": "1.0.2", - "bundled": true, - "optional": true + "bundled": true }, "yallist": { "version": "3.0.3", - "bundled": true, - "optional": true + "bundled": true } } }, diff --git a/package.json b/package.json index fb90a2a33c..26764464d9 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "cheerio": "^0.22.0", "chokidar": "^3.3.0", "commander": "^3.0.2", + "crypto-js": "^3.1.0", "fastmatter": "^2.1.1", "figlet": "^1.2.4", "find-up": "^4.1.0", diff --git a/src/Page.js b/src/Page.js index 7dc91c9a70..23e4b75aef 100644 --- a/src/Page.js +++ b/src/Page.js @@ -761,6 +761,7 @@ class Page { rootPath: this.rootPath, userDefinedVariablesMap: this.userDefinedVariablesMap, headerIdMap: this.headerIdMap, + resultPath: this.resultPath, }; return new Promise((resolve, reject) => { markbinder.includeFile(this.sourcePath, fileConfig) diff --git a/src/lib/markbind/src/constants.js b/src/lib/markbind/src/constants.js index 758777b634..203ec89ff9 100644 --- a/src/lib/markbind/src/constants.js +++ b/src/lib/markbind/src/constants.js @@ -2,6 +2,7 @@ module.exports = { // src/lib/markbind/src/parser.js ATTRIB_INCLUDE_PATH: 'include-path', ATTRIB_CWF: 'cwf', + ATTRIB_RESULT_PATH: 'resultPath', BOILERPLATE_FOLDER_NAME: '_markbind/boilerplates', diff --git a/src/lib/markbind/src/lib/markdown-it/index.js b/src/lib/markbind/src/lib/markdown-it/index.js index 4d7c4d7cf4..75875c6d30 100644 --- a/src/lib/markbind/src/lib/markdown-it/index.js +++ b/src/lib/markbind/src/lib/markdown-it/index.js @@ -42,6 +42,31 @@ markdownIt.renderer.rules.table_close = (tokens, idx) => { return ''; }; +// rewritten markdown-it renderer.js rule to add a div containing the language of the code block +markdownIt.renderer.rules.fence = (tokens, idx, options, env, slf) => { + var token = tokens[idx], + info = token.info ? markdownIt.utils.unescapeAll(token.info).trim() : '', + langName = '', + highlighted; + + if (info) { + langName = info.split(/\s+/g)[0]; + } + + const widgetHandler = env[langName]; + if (widgetHandler !== undefined) { + return widgetHandler(token.content); + } + + if (options.highlight) { + highlighted = options.highlight(token.content, langName) || escapeHtml(token.content); + } else { + highlighted = escapeHtml(token.content); + } + + return highlighted + '\n'; +} + // highlight inline code markdownIt.renderer.rules.code_inline = (tokens, idx, options, env, slf) => { const token = tokens[idx]; diff --git a/src/lib/markbind/src/parser.js b/src/lib/markbind/src/parser.js index 7a8fc3a11c..b9426d2b79 100644 --- a/src/lib/markbind/src/parser.js +++ b/src/lib/markbind/src/parser.js @@ -8,6 +8,7 @@ const url = require('url'); const pathIsInside = require('path-is-inside'); const slugify = require('@sindresorhus/slugify'); const componentParser = require('./parsers/componentParser'); +const widget = require('../../../widgets/widgets'); const _ = {}; _.clone = require('lodash/clone'); @@ -383,6 +384,10 @@ class Parser { element.children = element.children.map(e => self._preprocess(e, context, config)); } } + + + widget.preprocessWidget(config, context, element); + return element; } @@ -512,6 +517,8 @@ class Parser { break; } + widget.parseWidget(config, context, element); + componentParser.parseComponents(element, this._onError); if (element.children) { @@ -568,6 +575,7 @@ class Parser { } return processed; }); + resolve(cheerio.html(nodes)); }); diff --git a/src/lib/markbind/src/utils.js b/src/lib/markbind/src/utils.js index 4e6eee399f..619dc8c2cb 100644 --- a/src/lib/markbind/src/utils.js +++ b/src/lib/markbind/src/utils.js @@ -1,5 +1,6 @@ const fs = require('fs'); const path = require('path'); +const cheerio = require('cheerio'); const { markdownFileExts } = require('./constants'); @@ -100,4 +101,21 @@ module.exports = { return text.join('').trim(); }, + + extractCodeElement(element, codeLanguage = '') { + let codeBlockElement = cheerio.parseHTML('
', true)[0]; + + element.children.forEach((child) => { + if (child.name === 'pre' && child.children.length >= 1) { + const [nestedChild] = child.children; + if (nestedChild.name !== 'code') { + return; + } + [codeBlockElement] = nestedChild.children; + codeBlockElement.data = `\`\`\`${codeLanguage}\n${codeBlockElement.data}\n\`\`\``; + } + }); + + return codeBlockElement; + }, }; diff --git a/src/plugins/default/markbind-plugin-plantuml.js b/src/plugins/default/markbind-plugin-plantuml.js deleted file mode 100644 index a7d87b7a1f..0000000000 --- a/src/plugins/default/markbind-plugin-plantuml.js +++ /dev/null @@ -1,116 +0,0 @@ -/** - * Parses PlantUML diagrams - * Replaces tags with tags with the appropriate src attribute and generates the diagrams - * by running the JAR executable - */ -const cheerio = module.parent.require('cheerio'); -const fs = require('fs'); -const path = require('path'); -const { exec } = require('child_process'); -const logger = require('../../util/logger'); - -const JAR_PATH = path.resolve(__dirname, 'plantuml.jar'); - -const { - ERR_PROCESSING, - ERR_READING, -} = require('../../constants'); - -// Tracks diagrams that have already been processed -const processedDiagrams = new Set(); - -/** - * Generates diagram and replaces src attribute of puml tag - * @param src src attribute of the puml tag - * @param cwf original file that contains the puml tag, we resolve relative src to this file - * @param config sourcePath and resultPath from parser context - * @returns {string} resolved src attribute - */ -function generateDiagram(src, cwf, config) { - const { sourcePath, resultPath } = config; - const _cwf = cwf || sourcePath; - - // For replacing img.src - const diagramSrc = src.replace('.puml', '.png'); - // Path of the .puml file - const rawDiagramPath = path.resolve(path.dirname(_cwf), src); - // Path of the .png to be generated - const outputFilePath = path.resolve(resultPath, path.relative(sourcePath, rawDiagramPath) - .replace('.puml', '.png')); - // Output dir for the png file - const outputDir = path.dirname(outputFilePath); - - // Tracks built files to avoid accessing twice - if (processedDiagrams.has(outputFilePath)) { return diagramSrc; } - processedDiagrams.add(outputFilePath); - - // Creates output dir if it doesn't exist - if (!fs.existsSync(outputDir)) { - fs.mkdirSync(outputDir, { recursive: true }); - } - - // Read diagram file, launch PlantUML jar - fs.readFile(rawDiagramPath, (err, data) => { - if (err) { - logger.debug(err); - logger.error(`${ERR_READING} ${rawDiagramPath}`); - return; - } - - const umlCode = data.toString(); - - // Java command to launch PlantUML jar - const cmd = `java -jar "${JAR_PATH}" -pipe > "${outputFilePath}"`; - const childProcess = exec(cmd); - - let errorLog = ''; - - childProcess.stdin.write( - umlCode, - (e) => { - if (e) { - logger.debug(e); - logger.error(`${ERR_PROCESSING} ${rawDiagramPath}`); - } - childProcess.stdin.end(); - }, - ); - - childProcess.on('error', (error) => { - logger.debug(error); - logger.error(`${ERR_PROCESSING} ${rawDiagramPath}`); - }); - - 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); - }); - }); - - return diagramSrc; -} - -module.exports = { - preRender: (content, pluginContext, frontmatter, config) => { - // Clear tags processed before for live reload - processedDiagrams.clear(); - // Processes all tags - const $ = cheerio.load(content, { xmlMode: true }); - $('puml').each((i, tag) => { - // eslint-disable-next-line no-param-reassign - tag.name = 'pic'; - const { src, cwf } = tag.attribs; - // eslint-disable-next-line no-param-reassign - tag.attribs.src = generateDiagram(src, cwf, config); - }); - - return $.html(); - }, - getSources: () => ({ - tagMap: [['puml', 'src']], - }), -}; diff --git a/src/plugins/default/plantuml.jar b/src/widgets/plantuml.jar similarity index 100% rename from src/plugins/default/plantuml.jar rename to src/widgets/plantuml.jar diff --git a/src/widgets/puml.js b/src/widgets/puml.js new file mode 100644 index 0000000000..aa461ab018 --- /dev/null +++ b/src/widgets/puml.js @@ -0,0 +1,159 @@ +const path = require('path'); +const cheerio = require('cheerio'); +const cryptoJS = require('crypto-js'); +const fs = require('fs'); +const { exec } = require('child_process'); + +const parserUtil = require('../lib/markbind/src/utils'); +const logger = require('../util/logger'); +const fsUtil = require('../util/fsUtil'); +const md = require('../lib/markbind/src/lib/markdown-it'); +const { + ATTRIB_RESULT_PATH, +} = require('../lib/markbind/src/constants'); + +const _ = {}; +_.hasIn = require('lodash/hasIn'); + +const PLANTUML_JAR_NAME = 'plantuml.jar'; +const PUML_NAME = 'puml'; + +function getFileName(context, content) { + if (context.name !== undefined) { + return `${context.name}.png`; + } + + if (context.src !== undefined) { + const fileName = fsUtil.removeExtension(context.src); + return `${fileName}.png`; + } + + const hashedContent = cryptoJS.MD5(content).toString(); + return `${hashedContent}.png`; +} + +function generateElement(context) { + const element = cheerio.parseHTML('')[0]; + const entries = Object.entries(context); + entries.forEach(([key, val]) => { + if (key === ATTRIB_RESULT_PATH) { + return; + } + element.attribs[key] = val; + }); + + return element; +} + +/* +* Curry function takes in context returning a widgetHandler function +* PUML widgetHandler takes in raw PUML contents and returns img with puml +*/ +function parseAndRender(context) { + return function (content) { + const outputDir = path.dirname(context.resultPath); + const fileName = getFileName(context, content); + // Path of the .puml file + const outputPath = path.join(outputDir, path.basename(fileName)); + const JAR_PATH = path.resolve(__dirname, PLANTUML_JAR_NAME); + + // Creates output dir if it doesn't exist + if (!fs.existsSync(outputDir)) { + fs.mkdirSync(outputDir, { recursive: true }); + } + + // Java command to launch PlantUML jar + const cmd = `java -jar "${JAR_PATH}" -pipe > "${outputPath}"`; + const childProcess = exec(cmd); + + let errorLog = ''; + childProcess.stdin.write( + content, + (e) => { + if (e) { + logger.debug(e); + } + childProcess.stdin.end(); + }, + ); + + childProcess.on('error', (error) => { + logger.debug(error); + }); + + 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); + }); + + const elementContext = context; + elementContext.src = fileName; + const element = generateElement(elementContext); + + return cheerio.html(element); + }; +} + +class Puml { + constructor() { + this.name = PUML_NAME; + } + + preprocess(config, context, pumlElement) { // eslint-disable-line class-methods-use-this + const element = pumlElement; + if (!_.hasIn(element.attribs, 'src')) { + return element; + } + const resultPath = path.resolve(path.dirname(config.resultPath), element.attribs.src); + const pumlContext = { + resultPath, + ...element.attribs, + }; + + // Path of the .puml file + const rawDiagramPath = path.resolve(path.dirname(context.cwf), pumlContext.src); + const pumlContent = fs.readFileSync(rawDiagramPath, 'utf8'); + const generatePUML = parseAndRender(pumlContext); + + const childrenDOM = generatePUML(pumlContent); + element.name = 'div'; + element.attribs = {}; + + element.children = cheerio.parseHTML(childrenDOM); + element.children.cwf = context.cwf; + return element; + } + + parse(config, context, pumlElement) { + const element = pumlElement; + + const pumlContext = { + resultPath: config.resultPath, + ...element.attribs, + }; + + element.name = 'div'; + element.attribs = {}; + cheerio.prototype.options.xmlMode = false; + const generatePUML = parseAndRender(pumlContext); + const widgetHandler = {}; + widgetHandler[this.name] = generatePUML; + + const pumlContent = parserUtil.extractCodeElement(element, this.name); + + element.children = cheerio.parseHTML(md.render(cheerio.html(pumlContent), widgetHandler), true); + return element; + } +} + +/* +* Parse and render PUML widget +*/ +module.exports = { + parseAndRender, + Puml, +}; diff --git a/src/widgets/widgets.js b/src/widgets/widgets.js new file mode 100644 index 0000000000..801d48ec22 --- /dev/null +++ b/src/widgets/widgets.js @@ -0,0 +1,28 @@ +const pumlWidget = require('./puml'); + +const WIDGETS = { + puml: new pumlWidget.Puml(), +}; + +function preprocessWidget(config, context, element) { + const widget = WIDGETS[element.name]; + if (widget === undefined) { + return element; + } + + return widget.preprocess(config, context, element); +} + +function parseWidget(config, context, element) { + const widget = WIDGETS[element.name]; + if (widget === undefined) { + return element; + } + + return widget.parse(config, context, element); +} + +module.exports = { + preprocessWidget, + parseWidget, +}; diff --git a/test/functional/test_site/expected/b169e11db4f52a01eb352071b156c59b.png b/test/functional/test_site/expected/b169e11db4f52a01eb352071b156c59b.png new file mode 100644 index 0000000000..e6e95dbbe9 Binary files /dev/null and b/test/functional/test_site/expected/b169e11db4f52a01eb352071b156c59b.png differ diff --git a/test/functional/test_site/expected/index.html b/test/functional/test_site/expected/index.html index dc18a1dd28..b9598c2667 100644 --- a/test/functional/test_site/expected/index.html +++ b/test/functional/test_site/expected/index.html @@ -469,33 +469,48 @@

Markbind Plugin Pre-render -

+

Test PlantUML live reload with include

PlantUML Test

-

Sequence Diagram +

Sequence Diagram

+
-

-

Use Case Diagram +

+

Use Case Diagram

+
-

-

Class Diagram +

+

Class Diagram

+
-

-

Activity Diagram +

+

Activity Diagram

+
-

-

Component Diagram +

+

Component Diagram

+
-

-

State Diagram +

+

State Diagram

+
-

-

Object Diagram +

+

Object Diagram

+
-

+
+

PlantUML Inline Test

+
+ +
+
+ +

Level 2 header (inside headingSearchIndex) with no-index attribute should not be indexed

Level 6 header (outside headingSearchIndex) with always-index attribute should be indexed
diff --git a/test/functional/test_site/expected/inline-output.png b/test/functional/test_site/expected/inline-output.png new file mode 100644 index 0000000000..32a71b61dc Binary files /dev/null and b/test/functional/test_site/expected/inline-output.png differ diff --git a/test/functional/test_site/expected/testPlantUML.html b/test/functional/test_site/expected/testPlantUML.html index f766091d47..859cb60bfd 100644 --- a/test/functional/test_site/expected/testPlantUML.html +++ b/test/functional/test_site/expected/testPlantUML.html @@ -26,27 +26,41 @@

PlantUML Test

-

Sequence Diagram +

Sequence Diagram

+
-

-

Use Case Diagram +

+

Use Case Diagram

+
-

-

Class Diagram +

+

Class Diagram

+
-

-

Activity Diagram +

+

Activity Diagram

+
-

-

Component Diagram +

+

Component Diagram

+
-

-

State Diagram +

+

State Diagram

+
-

-

Object Diagram +

+

Object Diagram

+
-

+
+

PlantUML Inline Test

+
+ +
+
+ +