This is a small JavaScript library for generating railroad diagrams (like what JSON.org uses) using SVG.
Railroad diagrams are a way of visually representing a grammar in a form that is more readable than using regular expressions or BNF. They can easily represent any context-free grammar, and some more powerful grammars. There are several railroad-diagram generators out there, but none of them had the visual appeal I wanted, so I wrote my own.
There's an online dingus for JavaScript, JSON or YAML input for you to play with and get SVG code from!
This is a fork of the original project with the following ipmrovements:
- Introduces the method
Diagram.fromJSON
to create diagrams from machine readable formats convertible to JSON. - Adds a command-line tool
rrd2svg
for converting JSON or YAML diagram input to SVG output. - Adds a command-line tool
rrdlint
for validating JSON or YAML diagram input. - Supports
VerticalSequence
, which is makes sequences with many items more readable thanStack
. (Authored by Glynn Williams.) - No Python and separate JavaScript implementation for Node.js. Single JavaScript "source of truth".
To use the library, include railroad-diagrams.css
in your page, and import
the ./railroad-diagrams/lib/index.mjs
module in your script, then call
the Diagram() function. Its arguments are the components of the diagram
(Diagram is a special form of Sequence).
The constructors for each node are named exports in the module;
the default export is an object of same-named functions that just call the constructors,
so you can construct diagrams without having to spam new
all over the place:
// Use the constructors
import {Diagram, Choice} from "./railroad-diagrams/lib/index.mjs";
const d = new Diagram("foo", new Choice(0, "bar", "baz"));
// Or use the functions that call the constructors for you
import rr from "./railroad-diagrams/lib/index.mjs";
const d = rr.Diagram("foo", rr.Choice(0, "bar", "baz"));
// Or use the JSON serialization of the diagram
import {Diagram} from "./railroad-diagrams/lib/index.mjs";
const d = Diagram.fromJSON([
{ type: 'Terminal', text: 'foo' }.
{ type: 'Choice', normalIndex: 0, options: [
{ type: 'Terminal', text: 'bar' }, { type: 'Terminal', text: 'baz' }
] }
]);
Alternately, you can call ComplexDiagram(); it's identical to Diagram(), but has slightly different start/end shapes, same as what JSON.org does to distinguish between "leaf" types like number (ordinary Diagram()) and "container" types like Array (ComplexDiagram()).
The Diagram class also has a few methods:
.walk(cb)
calls the cb function on the diagram, then on its child nodes, recursing down the tree. This is a "pre-order depth-first" traversal, if you're into that sort of thing - the first child's children are visited before the diagram's second child. (In other words, the same order you encounter their constructors in the code that created the diagram.) Use this if you want to, say, sanitize things in the diagram..format(...paddings)
"formats" the Diagram to make it ready for output. Pass it 0-4 paddings, interpreted just like the CSSpadding
property, to give it some "breathing room" around its box; these default to20
if not specified. This is automatically called by the output functions if you don't do so yourself, so if the default paddings suffice, there's no need to worry about this..toString()
outputs the SVG of the diagram as a string, ready to be put into your HTML. This is not a standalone SVG file; it's intended to be embedded into HTML..toStandalone()
outputs the SVG of the diagram as a string, but this is a standalone SVG file..toSVG()
outputs the diagram as an actual<svg>
DOM element, ready for appending into a document..addTo(parent?)
directly appends the diagram, as an<svg>
element, to the specified parent element. If you omit the parent element, it instead appends to the script element it's being called from, so you can easily insert a diagram into your document by just dropping a tiny inline<script>
that just callsnew Diagram(...).addTo()
where you want the diagram to show up.
You can include a specific version of this library on a plain web page using the service provided by unpkg
:
<link rel="stylesheet"
href="https://unpkg.com/@prantlf/railroad-diagrams@1.0.0/railroad-diagrams.css">
<script src="https://unpkg.com/@prantlf/railroad-diagrams@1.0.0/lib/index.umd.min.js"></script>
You can install the library using the Node.js 6 or newer. For example, with npm
or yarn
:
npm i @prantlf/railroad-diagrams
yarn add @prantlf/railroad-diagrams
Exports of the library can be consumed in ESM modules similarly as it is documented for the web pages above:
// Use the constructors os the static Diagram.fromJSON
import {Diagram, Choice} from "@prantlf/railroad-diagrams";
// Or use the functions that call the constructors for you
import rr from "@prantlf/railroad-diagrams";
Exports of the library can be consumed in CJS modules too:
// Use the constructors os the static Diagram.fromJSON
const {Diagram, Choice} = require("@prantlf/railroad-diagrams");
// Or use the functions that call the constructors for you
const rr = require("@prantlf/railroad-diagrams").default;
Make sure, that you do not call methods addTo
and toSVG
, which work inly in the web browser. You can generate an SVG by toString
or toStandalone
.
If you install the library using the Node.js 6 or newer globally:
npm i -g railroad-diagrams
You will be able to execute the following command line tools:
rrdlint
- checks the syntax of railroad diagrams in JSON, YAML or JavaScript.rrd2svg
- generates railroad diagrams from JSON, YAML or JavaScript to SVG.
$ rrdlint -h
Usage: rrdlint [option...] [pattern...]
Options:
-i|--input <type> read input from json, yaml or javascript. defaults to json
-v|--verbose print checked file names and error stacktrace
-V|--version print version number
-h|--help print usage instructions
Examples:
cat foo.yaml | rrdlint -i yaml
rrdlint diagrams/*
$ rrd2svg -h
Usage: rrd2svg [option...] [file]
Options:
--[no]-standalone add stylesheet to the SVG element. defaults to true
--[no]-debug add sizing data into the SVG element. defaults to false
-i|--input <type> read input from json, yaml or javascript. defaults to json
-v|--verbose print error stacktrace
-V|--version print version number
-h|--help print usage instructions
Examples:
cat foo.yaml | rrd2svg -i yaml
rrd2svg foo.json
If no file name or file name pattern is provided, standard input will be read. If no input type is provided, it will be inferred from the file extension: ".json" -> json, ".yaml" or ".yml" -> yaml, ".js" -> javascript.
Components are either leaves or containers.
The leaves:
- Terminal(text, href) or a bare string - represents literal text. The 'href' attribute is optional, and creates a hyperlink with the given destination.
- NonTerminal(text, href) - represents an instruction or another production. The 'href' attribute is optional, and creates a hyperlink with the given destination.
- Comment(text, href) - a comment. The 'href' attribute is optional, and creates a hyperlink with the given destination.
- Skip() - an empty line
- Start(type, label) and End(type) - the start/end shapes. These are supplied by default, but if you want to supply a label to the diagram, you can create a Start() explicitly (as the first child of the Diagram!). The "type" attribute takes either "simple" (the default) or "complex", a la Diagram() and ComplexDiagram(). All arguments are optional.
The containers:
-
Sequence(...children) - like simple concatenation in a regex.
-
Stack(...children) - identical to a Sequence, but the items are stacked vertically rather than horizontally. Best used when a simple Sequence would be too wide; instead, you can break the items up into a Stack of Sequences of an appropriate width.
-
VerticalSequence(...children) - identical to a Stack, but the items are connected by short vertical lines rather than long rounded curves on the sides of the sequence. Best used when a Stack would be so high that the rounded curves would make it difficult to read.
-
OptionalSequence(...children) - a Sequence where every item is individually optional, but at least one item must be chosen
-
Choice(index, ...children) - like
|
in a regex. The index argument specifies which child is the "normal" choice and should go in the middle -
MultipleChoice(index, type, ...children) - like
||
or&&
in a CSS grammar; it's similar to a Choice, but more than one branch can be taken. The index argument specifies which child is the "normal" choice and should go in the middle, while the type argument must be either "any" (1+ branches can be taken) or "all" (all branches must be taken). -
HorizontalChoice(...children) - Identical to Choice, but the items are stacked horizontally rather than vertically. There's no "straight-line" choice, so it just takes a list of children. Best used when a simple Choice would be too tall; instead, you can break up the items into a HorizontalChoice of Choices of an appropriate height.
-
Optional(child, skip) - like
?
in a regex. A shorthand forChoice(1, Skip(), child)
. If the optionalskip
parameter has the value"skip"
, it instead puts the Skip() in the straight-line path, for when the "normal" behavior is to omit the item. -
OneOrMore(child, repeat) - like
+
in a regex. The 'repeat' argument is optional, and specifies something that must go between the repetitions (usually aComment()
, but sometimes things like","
, etc.) -
AlternatingSequence(option1, option2) - similar to a OneOrMore, where you must alternate between the two choices, but allows you to start and end with either element. (OneOrMore requires you to start and end with the "child" node.)
-
ZeroOrMore(child, repeat, skip) - like
*
in a regex. A shorthand forOptional(OneOrMore(child, repeat), skip)
. Bothrepeat
(same as inOneOrMore()
) andskip
(same as inOptional()
) are optional. -
Group(child, label?) - highlights its child with a dashed outline, and optionally labels it. Passing a string as the label constructs a Comment, or you can build one yourself (to give an href or title).
After constructing a Diagram, call .format(...padding)
on it, specifying 0-4 padding values (just like CSS) for some additional "breathing space" around the diagram (the paddings default to 20px).
The result can either be .toString()
'd for the markup, or .toSVG()
'd for an <svg>
element, which can then be immediately inserted to the document. As a convenience, Diagram also has an .addTo(element)
method, which immediately converts it to SVG and appends it to the referenced element with default paddings. element
defaults to document.body
.
There are a few options you can tweak,
in an Options
object exported from the module.
Just tweak either until the diagram looks like what you want.
You can also change the CSS file - feel free to tweak to your heart's content.
Note, though, that if you change the text sizes in the CSS,
you'll have to go adjust the options specifying the text metrics as well.
Options.VS
- sets the minimum amount of vertical separation between two items, in CSS px. Note that the stroke width isn't counted when computing the separation; this shouldn't be relevant unless you have a very small separation or very large stroke width. Defaults to8
.Options.AR
- the radius of the arcs, in CSS px, used in the branching containers like Choice. This has a relatively large effect on the size of non-trivial diagrams. Both tight and loose values look good, depending on what you're going for. Defaults to10
.Options.DIAGRAM_CLASS
- the class set on the root<svg>
element of each diagram, for use in the CSS stylesheet. Defaults to"railroad-diagram"
.Options.STROKE_ODD_PIXEL_LENGTH
- the default stylesheet uses odd pixel lengths for 'stroke'. Due to rasterization artifacts, they look best when the item has been translated half a pixel in both directions. If you change the styling to use a stroke with even pixel lengths, you'll want to set this variable toFalse
.Options.INTERNAL_ALIGNMENT
- when some branches of a container are narrower than others, this determines how they're aligned in the extra space. Defaults to"center"
, but can be set to"left"
or"right"
.Options.CHAR_WIDTH
- the approximate width, in CSS px, of characters in normal text (Terminal
andNonTerminal
). Defaults to8.5
.Options.COMMENT_CHAR_WIDTH
- the approximate width, in CSS px, of character inComment
text, which by default is smaller than the other textual items. Defaults to7
.Options.DEBUG
- iftrue
, writes some additional "debug information" into the attributes of elements in the output, to help debug sizing issues. Defaults tofalse
.
Diagrams can be created from a JSON serialization using Diagram.fromJSON(input)
or ComplexDiagram.fromJSON(input)
. (If the JSON input starts with a Diagram
or ComplexDigram
node, it will be honoured and the parent class of fromJSON
will not apply.)
The JSON serialization can be a single object or an array of objects in the format { "type": "...", ...parameters }
, where type
is a class name of a node and parameters
are constructor arguments following more-or-less closely the naming conventions from the documentation above.
{ "type": "Diagram", "items" }
{ "type": "ComplexDiagram", "items" }
{ "type": "Terminal, "text", "href", "title" }
{ "type": "NonTerminal", "text", "href", "title" }
{ "type": "Comment", "text", "href", "title" }
{ "type": "Skip" }
{ "type": "Start", "startType", "label" }
{ "type": "End", "endType" }
{ "type": "Sequence", "items" }
{ "type": "Stack", "items" }
{ "type": "OptionalSequence", "items" }
{ "type": "Sequence", "items" }
{ "type": "Choice", "normalIndex", "options" }
{ "type": "MultipleChoice", "normalIndex", "choiceType", "options" }
{ "type": "HorizontalChoice", "options" }
{ "type": "Optional", "item", "skip" }
{ "type": "OneOrMore", "item", "repeat" }
{ "type": "AlternatingSequence", "option1", "option2" }
{ "type": "ZeroOrMore", "item", "repeat", "skip" }
{ "type": "Group", "item", "label" }
If the diagram input should be edited manually, using YAML instead of JSON will make maintenance easier. YAML can be converted to JSON before calling fromJSON
.
The difference between using constructors or functions to create diagram nodes is negligible. Parsing the JSON serialization is only a little slower. Results from generating a all example diagrams using Node.js 12 on Macbook Pro 2018 with i7 2,6 GHz:
Creating 17 diagrams...
using functions x 29,989 ops/sec ±0.56% (95 runs sampled)
using constructors x 30,651 ops/sec ±0.28% (97 runs sampled)
using fromJSON x 23,038 ops/sec ±0.50% (95 runs sampled)
The fastest one was using constructors.
SVG can't actually respond to the sizes of content; in particular, there's no way to make SVG adjust sizing/positioning based on the length of some text. Instead, I guess at some font metrics, which mostly work as long as you're using a fairly standard monospace font. This works pretty well, but long text inside of a construct might eventually overflow the construct.
This document and all associated files in the github project are licensed under CC0 . This means you can reuse, remix, or otherwise appropriate this project for your own use without restriction. (The actual legal meaning can be found at the above link.)