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",