From c703c9a0d3e3c3f03fee5e80d1ec94cd5ff2cfce Mon Sep 17 00:00:00 2001 From: fardog Date: Sat, 20 Dec 2014 17:18:38 -0800 Subject: [PATCH 1/5] Adds CLI for rendering mermaid files --- bin/mermaid.js | 27 ++++++ lib/cli.js | 135 +++++++++++++++++++++++++++ lib/index.js | 40 ++++++++ lib/phantomscript.js | 213 +++++++++++++++++++++++++++++++++++++++++++ package.json | 10 +- 5 files changed, 424 insertions(+), 1 deletion(-) create mode 100755 bin/mermaid.js create mode 100644 lib/cli.js create mode 100644 lib/index.js create mode 100644 lib/phantomscript.js diff --git a/bin/mermaid.js b/bin/mermaid.js new file mode 100755 index 000000000..4a39d8baf --- /dev/null +++ b/bin/mermaid.js @@ -0,0 +1,27 @@ +#!/usr/bin/env node + +var fs = require('fs') + , chalk = require('chalk') + , error = chalk.bold.red + , cli = require('../lib/cli.js') + , lib = require('../lib') + +cli.parse(process.argv.slice(2), function(err, message, options) { + if (err) { + console.error( + error('\nYou had errors in your syntax. Use --help for further information.') + ) + err.forEach(function (e) { + console.error(e.message) + }) + + return + } + else if (message) { + console.log(message) + + return + } + + lib.process(options.files, options) +}) diff --git a/lib/cli.js b/lib/cli.js new file mode 100644 index 000000000..14ee904da --- /dev/null +++ b/lib/cli.js @@ -0,0 +1,135 @@ +var fs = require('fs') + , exec = require('child_process').exec + , chalk = require('chalk') + , which = require('which') + , parseArgs = require('minimist') + , semver = require('semver') + +var PHANTOM_VERSION = "^1.9.0" + +var info = chalk.blue.bold + , note = chalk.green.bold + +var cli = function(options) { + this.options = { + alias: { + help: 'h' + , png: 'p' + , outputDir: 'o' + , svg: 's' + , verbose: 'v' + , phantomPath: 'e' + } + , 'boolean': ['help', 'png', 'svg'] + , 'string': ['outputDir'] + } + + this.errors = [] + this.message = null + + this.helpMessage = [ + , info('Usage: mermaid [options] ...') + , "" + , "file The mermaid description file to be rendered" + , "" + , "Options:" + , " -s --svg Output SVG instead of PNG (experimental)" + , " -p --png If SVG was selected, and you also want PNG, set this flag" + , " -o --outputDir Directory to save files, will be created automatically, defaults to `cwd`" + , " -e --phantomPath Specify the path to the phantomjs executable" + , " -h --help Show this message" + , " -v --verbose Show logging" + , " --version Print version and quit" + ] + + return this +} + +cli.prototype.parse = function(argv, next) { + var options = parseArgs(argv, this.options) + , phantom + + if (options.version) { + var pkg = require('../package.json') + this.message = "" + pkg.version + } + else if (options.help) { + this.message = this.helpMessage.join('\n') + } + else { + options.files = options._ + + if (!options.files.length) { + this.errors.push(new Error("You must specify at least one source file.")) + } + + // ensure that parameter-expecting options have parameters + ;['outputDir', 'phantomPath'].forEach(function(i) { + if(typeof options[i] !== 'undefined') { + if (typeof options[i] !== 'string' || options[i].length < 1) { + this.errors.push(new Error(i + " expects a value.")) + } + } + }.bind(this)) + } + + if (options.svg && !options.png) { + options.png = false + } + else { + options.png = true + } + + // If phantom hasn't been specified, see if we can find it + if (!options.phantomPath) { + try { + var phantom = require('phantomjs') + options.phantomPath = phantom.path + } catch (e) { + try { + options.phantomPath = which.sync('phantomjs') + } catch (e) { + if (!options.phantomPath) { + var err = [ + "Cannot find phantomjs in your PATH. If phantomjs is installed" + , "you may need to specify its path manually with the '-e' option." + , "If it is not installed, you should view the README for further" + , "details." + ] + + this.errors.push(new Error(err.join('\n'))) + next( + this.errors.length > 0 ? this.errors : null + , this.message + , options) + + return + } + } + } + } + + // If we have phantompath, see if its version satisfies our requirements + exec(options.phantomPath + ' --version', function(err, stdout, stderr) { + if (err) { + this.errors.push( + new Error("Could not find phantomjs at the specified path.") + ) + } + else if (!semver.satisfies(stdout, PHANTOM_VERSION)) { + this.message = note( + 'mermaid requires phantomjs ' + + PHANTOM_VERSION + + ' to be installed, found version ' + + stdout + ) + } + next(this.errors.length > 0 ? this.errors : null, this.message, options) + }.bind(this)) + +} + + +module.exports = function() { + return new cli() +}() diff --git a/lib/index.js b/lib/index.js new file mode 100644 index 000000000..9ed12c796 --- /dev/null +++ b/lib/index.js @@ -0,0 +1,40 @@ +var os = require('os') + , fs = require('fs') + , path = require('path') + , spawn = require('child_process').spawn + +var mkdirp = require('mkdirp') + +var phantomscript = path.join(__dirname, 'phantomscript.js') + +module.exports = { process: processMermaid } + +function processMermaid(files, _options, _next) { + var options = _options || {} + , outputDir = options.outputDir || process.cwd() + , next = _next || function() {} + , phantomArgs = [ + phantomscript + , outputDir + , options.png + , options.svg + , options.verbose + ] + + files.forEach(function(file) { + phantomArgs.push(file) + }) + + mkdirp(outputDir, function(err) { + if (err) { + throw err + return + } + phantom = spawn(options.phantomPath, phantomArgs) + + phantom.on('exit', next) + + phantom.stderr.pipe(process.stderr) + phantom.stdout.pipe(process.stdout) + }) +} diff --git a/lib/phantomscript.js b/lib/phantomscript.js new file mode 100644 index 000000000..a624870af --- /dev/null +++ b/lib/phantomscript.js @@ -0,0 +1,213 @@ +/** + * Credits: + * - SVG Processing from the NYTimes svg-crowbar, under an MIT license + * https://github.com/NYTimes/svg-crowbar + * - Thanks to the grunticon project for some guidance + * https://github.com/filamentgroup/grunticon + */ + +phantom.onError = function(msg, trace) { + var msgStack = ['PHANTOM ERROR: ' + msg] + if (trace && trace.length) { + msgStack.push('TRACE:') + trace.forEach(function(t) { + msgStack.push( + ' -> ' + + (t.file || t.sourceURL) + + ': ' + + t.line + + (t.function ? ' (in function ' + t.function +')' : '') + ) + }) + } + system.stderr.write(msgStack.join('\n')) + phantom.exit(1) +} + +var system = require('system') + , fs = require('fs') + , webpage = require('webpage') + +var page = webpage.create() + , files = phantom.args.slice(4, phantom.args.length) + , options = { + outputDir: phantom.args[0] + , png: phantom.args[1] === 'true' ? true : false + , svg: phantom.args[2] === 'true' ? true : false + , verbose: phantom.args[3] === 'true' ? true : false + } + , log = logger(options.verbose) + +page.content = [ + '' + , '' + , '' + , '' + , '' + , '' + , '' +].join('\n') + +page.injectJs('../dist/mermaid.full.js') + +files.forEach(function(file) { + var contents = fs.read(file) + , filename = file.split(fs.separator).slice(-1) + , oParser = new DOMParser() + , oDOM + , svgContent + , allElements + + // this JS is executed in this statement is sandboxed, even though it doesn't + // look like it. we need to serialize then unserialize the svgContent that's + // taken from the DOM + svgContent = page.evaluate(executeInPage, contents) + oDOM = oParser.parseFromString(svgContent, "text/xml") + + resolveSVGElement(oDOM.firstChild) + + // traverse the SVG, and replace all foreignObject elements + // can be removed when https://github.com/knsv/mermaid/issues/58 is resolved + allElements = traverse(oDOM) + for (var i = 0, len = allElements.length; i < len; i++) { + resolveForeignObjects(allElements[i]) + } + + if (options.png) { + page.viewportSize = { + width: ~~oDOM.documentElement.attributes.getNamedItem('width').value + , height: ~~oDOM.documentElement.attributes.getNamedItem('height').value + } + + page.render(options.outputDir + fs.separator + filename + '.png') + log('saved png: ' + filename + '.png') + } + + if (options.svg) { + var serialize = new XMLSerializer() + fs.write( + options.outputDir + fs.separator + filename + '.svg' + , serialize.serializeToString(oDOM) + , 'w' + ) + log('saved svg: ' + filename + '.svg') + } +}) + +phantom.exit() + +function logger(_verbose) { + var verbose = _verbose + + return function(_message, _level) { + var level = level + , message = _message + , log + + log = level === 'error' ? system.stderr : system.stdout + + if (verbose) { + log.write(message + '\n') + } + } +} + +function traverse(obj){ + var tree = [] + + tree.push(obj) + visit(obj) + + function visit(node) { + if (node && node.hasChildNodes()) { + var child = node.firstChild + while (child) { + if (child.nodeType === 1 && child.nodeName != 'SCRIPT'){ + tree.push(child) + visit(child) + } + child = child.nextSibling + } + } + } + + return tree +} + +function resolveSVGElement(element) { + var prefix = { + xmlns: "http://www.w3.org/2000/xmlns/" + , xlink: "http://www.w3.org/1999/xlink" + , svg: "http://www.w3.org/2000/svg" + } + , doctype = '' + + element.setAttribute("version", "1.1") + // removing attributes so they aren't doubled up + element.removeAttribute("xmlns") + element.removeAttribute("xlink") + // These are needed for the svg + if (!element.hasAttributeNS(prefix.xmlns, "xmlns")) { + element.setAttributeNS(prefix.xmlns, "xmlns", prefix.svg) + } + if (!element.hasAttributeNS(prefix.xmlns, "xmlns:xlink")) { + element.setAttributeNS(prefix.xmlns, "xmlns:xlink", prefix.xlink) + } +} + +function resolveForeignObjects(element) { + var children + , textElement + , textSpan + + if (element.tagName === 'foreignObject') { + textElement = document.createElement('text') + textSpan = document.createElement('tspan') + textSpan.setAttribute( + 'style' + , 'font-size: 11.5pt; font-family: "sans-serif";' + ) + textSpan.setAttribute('x', 0) + textSpan.setAttribute('y', 14.5) + textSpan.textContent = element.textContent + + textElement.appendChild(textSpan) + element.parentElement.appendChild(textElement) + element.parentElement.removeChild(element) + } +} + +// The sandboxed function that's executed in-page by phantom +function executeInPage(contents) { + var xmlSerializer = new XMLSerializer() + , toRemove + , el + , elContent + , svg + , svgValue + + toRemove = document.getElementsByClassName('mermaid') + if (toRemove && toRemove.length) { + for (var i = 0, len = toRemove.length; i < len; i++) { + toRemove[i].parentNode.removeChild(toRemove[i]) + } + } + + el = document.createElement("div") + el.className = 'mermaid' + elContent = document.createTextNode(contents) + el.appendChild(elContent) + + document.body.appendChild(el) + + mermaid.init() + + svg = document.querySelector('svg') + svgValue = xmlSerializer.serializeToString(svg) + + return svgValue +} diff --git a/package.json b/package.json index f3ee7a33e..ef27ddd9b 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,9 @@ "version": "0.2.16", "description": "Markdownish syntax for generating flowcharts", "main": "src/main.js", + "bin": { + "mermaid": "./bin/mermaid.js" + }, "scripts": { "test": "gulp coverage" }, @@ -13,8 +16,13 @@ "author": "", "license": "MIT", "dependencies": { + "chalk": "^0.5.1", + "dagre-d3": "~0.3.2", "he": "^0.5.0", - "dagre-d3": "~0.3.2" + "minimist": "^1.1.0", + "mkdirp": "^0.5.0", + "semver": "^4.1.1", + "which": "^1.0.8" }, "devDependencies": { "browserify": "~6.2.0", From f349ddd796928ed1148419f591dd1b52367a129a Mon Sep 17 00:00:00 2001 From: fardog Date: Sat, 20 Dec 2014 17:40:58 -0800 Subject: [PATCH 2/5] Adds CLI tests --- gulpfile.js | 2 + package.json | 6 +- test/cli_test-output.js | 101 +++++++++++++++++++++++++++++++++ test/cli_test-parser.js | 100 ++++++++++++++++++++++++++++++++ test/fixtures/sequence.mermaid | 8 +++ test/fixtures/test.mermaid | 5 ++ test/fixtures/test2.mermaid | 7 +++ 7 files changed, 228 insertions(+), 1 deletion(-) create mode 100644 test/cli_test-output.js create mode 100644 test/cli_test-parser.js create mode 100644 test/fixtures/sequence.mermaid create mode 100644 test/fixtures/test.mermaid create mode 100644 test/fixtures/test2.mermaid diff --git a/gulpfile.js b/gulpfile.js index 6d77a3d5e..de50f3bff 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -45,6 +45,8 @@ gulp.task('jasmine',['jison','lint'], function () { .pipe(jasmine({includeStackTrace:true})); }); +gulp.task('tape', shell.task(['./node_modules/.bin/tape ./test/cli_test-*.js'])); + gulp.task('coverage', function (cb) { gulp.src(['src/**/*.js', '!src/**/*.spec.js']) .pipe(istanbul()) // Covering files diff --git a/package.json b/package.json index ef27ddd9b..8df079cb0 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,9 @@ "which": "^1.0.8" }, "devDependencies": { + "async": "^0.9.0", "browserify": "~6.2.0", + "clone": "^0.2.0", "codeclimate-test-reporter": "0.0.4", "d3": "~3.4.13", "dagre-d3": "~0.3.2", @@ -62,6 +64,8 @@ "mock-browser": "^0.90.27", "path": "^0.4.9", "phantomjs": "^1.9.12", - "rewire": "^2.1.3" + "rewire": "^2.1.3", + "rimraf": "^2.2.8", + "tape": "^3.0.3" } } diff --git a/test/cli_test-output.js b/test/cli_test-output.js new file mode 100644 index 000000000..5cea818d0 --- /dev/null +++ b/test/cli_test-output.js @@ -0,0 +1,101 @@ +var fs = require('fs') + , path = require('path') + +var test = require('tape') + , async = require('async') + , clone = require('clone') + , rimraf = require('rimraf') + +var mermaid = require('../lib') + +var singleFile = { + files: ['test/fixtures/test.mermaid'] + , outputDir: 'test/tmp/' + , phantomPath: './node_modules/.bin/phantomjs' + } + , multiFile = { + files: ['test/fixtures/test.mermaid', 'test/fixtures/test2.mermaid'] + , outputDir: 'test/tmp/' + , phantomPath: './node_modules/.bin/phantomjs' + } + + +test('output of single png', function(t) { + t.plan(3) + + var expected = ['test.mermaid.png'] + + opt = clone(singleFile) + opt.png = true + + mermaid.process(opt.files, opt, function(code) { + t.equal(code, 0, 'has clean exit code') + + verifyFiles(expected, opt.outputDir, t) + }) +}) + +test('output of multiple png', function(t) { + t.plan(3) + + var expected = ['test.mermaid.png', 'test2.mermaid.png'] + + opt = clone(multiFile) + opt.png = true + + mermaid.process(opt.files, opt, function(code) { + t.equal(code, 0, 'has clean exit code') + + verifyFiles(expected, opt.outputDir, t) + }) +}) + +test('output of single svg', function(t) { + t.plan(3) + + var expected = ['test.mermaid.svg'] + + opt = clone(singleFile) + opt.svg = true + + mermaid.process(opt.files, opt, function(code) { + t.equal(code, 0, 'has clean exit code') + + verifyFiles(expected, opt.outputDir, t) + }) +}) + +test('output of multiple svg', function(t) { + t.plan(3) + + var expected = ['test.mermaid.svg', 'test2.mermaid.svg'] + + opt = clone(multiFile) + opt.svg = true + + mermaid.process(opt.files, opt, function(code) { + t.equal(code, 0, 'has clean exit code') + + verifyFiles(expected, opt.outputDir, t) + }) +}) + +function verifyFiles(expected, dir, t) { + async.each( + expected + , function(file, cb) { + filename = path.join(dir, path.basename(file)) + fs.stat(filename, function(err, stat) { + cb(err) + }) + } + , function(err) { + t.notOk(err, 'all files passed') + + rimraf(dir, function(rmerr) { + t.notOk(rmerr, 'cleaned up') + t.end() + }) + } + ) +} diff --git a/test/cli_test-parser.js b/test/cli_test-parser.js new file mode 100644 index 000000000..f21acfd67 --- /dev/null +++ b/test/cli_test-parser.js @@ -0,0 +1,100 @@ +var test = require('tape') + , cliPath = '../lib/cli' + +test('parses multiple files', function(t) { + t.plan(2) + + var cli = require(cliPath) + , argv = ['example/file1.mermaid', 'file2.mermaid', 'file3.mermaid'] + , expect = ['example/file1.mermaid', 'file2.mermaid', 'file3.mermaid'] + + cli.parse(argv, function(err, msg, opt) { + t.equal(opt.files.length, 3, 'should have 3 parameters') + t.deepEqual(opt.files, expect, 'should match expected values') + + t.end() + }) +}) + +test('defaults to png', function(t) { + t.plan(2) + + var cli = require(cliPath) + , argv = ['example/file1.mermaid'] + + cli.parse(argv, function(err, msg, opt) { + t.ok(opt.png, 'png is set by default') + t.notOk(opt.svg, 'svg is not set by default') + + t.end() + }) +}) + +test('setting svg unsets png', function(t) { + t.plan(2) + + var cli = require(cliPath) + , argv = ['example/file1.mermaid', '-s'] + + cli.parse(argv, function(err, msg, opt) { + + t.ok(opt.svg, 'svg is set when requested') + t.notOk(opt.png, 'png is unset when svg is set') + + t.end() + }) +}) + +test('setting png and svg is allowed', function(t) { + t.plan(2) + + var cli = require(cliPath) + , argv = ['example/file1.mermaid', '-s', '-p'] + + cli.parse(argv, function(err, msg, opt) { + t.ok(opt.png, 'png is set when requested') + t.ok(opt.svg, 'svg is set when requested') + + t.end() + }) +}) + +test('setting an output directory succeeds', function(t) { + t.plan(1) + + var cli = require(cliPath) + , argv = ['-o', 'example/'] + + cli.parse(argv, function(err, msg, opt) { + t.equal(opt.outputDir, 'example/', 'output directory is set') + t.end() + }) +}) + +test('setting an output directory incorrectly causes an error', function(t) { + t.plan(1) + + var cli = require(cliPath) + , argv = ['-o'] + + cli.parse(argv, function(err) { + t.ok(err, 'an error is raised') + + t.end() + }) +}) + +test('a callback function is called after parsing', function(t) { + t.plan(2) + + var cli = require(cliPath) + , argv = ['example/test.mermaid'] + , expects = ['example/test.mermaid'] + + cli.parse(argv, function(err, msg, opts) { + t.ok(true, 'callback was called') + t.deepEqual(argv, opts.files, 'options are as expected') + + t.end() + }) +}) diff --git a/test/fixtures/sequence.mermaid b/test/fixtures/sequence.mermaid new file mode 100644 index 000000000..e0f8a5b57 --- /dev/null +++ b/test/fixtures/sequence.mermaid @@ -0,0 +1,8 @@ +sequenceDiagram + Alice->Bob: Hello Bob, how are you? + Note right of Bob: Bob thinks + Bob-->Alice: I am good thanks! + Bob-->John the Long: How about you John? + Bob-->Alice: Checking with John... + Alice->John the Long: Yes... John, how are you? + John the Long-->Alice: Better than you! diff --git a/test/fixtures/test.mermaid b/test/fixtures/test.mermaid new file mode 100644 index 000000000..d5bf6cb31 --- /dev/null +++ b/test/fixtures/test.mermaid @@ -0,0 +1,5 @@ +graph TD; + A-->B; + A-->C; + B-->D; + C-->D; diff --git a/test/fixtures/test2.mermaid b/test/fixtures/test2.mermaid new file mode 100644 index 000000000..02a2a061b --- /dev/null +++ b/test/fixtures/test2.mermaid @@ -0,0 +1,7 @@ +graph LR; + A[Hard edge]-->|Link text|B(Round edge); + B-->C{Decision}; + C-->|One|D[Result one]; + C-->|Two|E[Result two]; + classDef pink fill:#f9f,stroke:#333,stroke-width:4px; + class C pink; From d2513e9b501984c9851a16ee53f5c786f685f427 Mon Sep 17 00:00:00 2001 From: fardog Date: Sat, 20 Dec 2014 17:46:23 -0800 Subject: [PATCH 3/5] Adds CLI information to the README. --- README.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/README.md b/README.md index c027212de..4d4117016 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,36 @@ graph LR ![Example 2](http://www.sveido.com/mermaid/img/ex2.png) +# mermaid CLI + +Installing mermaid globally (`npm install -g mermaid`) will expose the `mermaid` command to your environment, allowing you to generate PNGs from any file containing mermaid markup via the command line. + +**Note:** The `mermaid` command requires PhantomJS to be installed and available in your *$PATH*. You can specify it's location with the `-e` option. + +## Usage + +``` +$ mermaid --help + +Usage: mermaid [options] ... + +file The mermaid description file to be rendered + +Options: + -s --svg Output SVG instead of PNG (experimental) + -p --png If SVG was selected, and you also want PNG, set this flag + -o --outputDir Directory to save files, will be created automatically, defaults to `cwd` + -e --phantomPath Specify the path to the phantomjs executable + -h --help Show this message + -v --verbose Show logging + --version Print version and quit +``` + +## Known Issues + +- SVG output currently does some replacement on text, as mermaid's SVG output is only appropriate for browsers. Text color and background color is not yet replicated; please use PNGs for most purposes until this is resolved. +- SVG output is decidedly non-standard. It works, but may cause issues in some viewers. + # Credits Many thanks to the [d3](http://d3js.org/) and [dagre-d3](https://github.com/cpettitt/dagre-d3) projects for providing the graphical layout and drawing libraries! Thanks also to the [js-sequence-diagram](http://bramp.github.io/js-sequence-diagrams) project for usage of the grammar for the sequence diagrams. From 46a7eaea7da47b378f3b92b71d36a0c2b983a11f Mon Sep 17 00:00:00 2001 From: fardog Date: Sat, 20 Dec 2014 17:49:06 -0800 Subject: [PATCH 4/5] Better information around CLI's phantomjs requirement --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 4d4117016..a396e1686 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ graph LR Installing mermaid globally (`npm install -g mermaid`) will expose the `mermaid` command to your environment, allowing you to generate PNGs from any file containing mermaid markup via the command line. -**Note:** The `mermaid` command requires PhantomJS to be installed and available in your *$PATH*. You can specify it's location with the `-e` option. +**Note:** The `mermaid` command requires [PhantomJS](http://phantomjs.org/) (version `^1.9.0`) to be installed and available in your *$PATH*, or you can specify it's location with the `-e` option. For most environments, `npm install -g phantomjs` will satisfy this requirement. ## Usage From 8920411b3c7dbe9b86a48334d85e4293464f3e63 Mon Sep 17 00:00:00 2001 From: fardog Date: Sat, 20 Dec 2014 17:50:45 -0800 Subject: [PATCH 5/5] Make obvious that README known issues section is for the CLI --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a396e1686..ba5ceea80 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ Options: --version Print version and quit ``` -## Known Issues +## CLI Known Issues - SVG output currently does some replacement on text, as mermaid's SVG output is only appropriate for browsers. Text color and background color is not yet replicated; please use PNGs for most purposes until this is resolved. - SVG output is decidedly non-standard. It works, but may cause issues in some viewers.