├── src ├── js │ ├── .keep │ ├── parser │ │ ├── javascript │ │ │ ├── repeat_any.js │ │ │ ├── repeat_optional.js │ │ │ ├── repeat_required.js │ │ │ ├── any_character.js │ │ │ ├── anchor.js │ │ │ ├── charset_escape.js │ │ │ ├── repeat_spec.js │ │ │ ├── parser_state.js │ │ │ ├── charset_range.js │ │ │ ├── literal.js │ │ │ ├── root.js │ │ │ ├── subexp.js │ │ │ ├── grammar.peg │ │ │ ├── parser.js │ │ │ ├── escape.js │ │ │ ├── charset.js │ │ │ ├── match_fragment.js │ │ │ ├── match.js │ │ │ ├── repeat.js │ │ │ ├── regexp.js │ │ │ └── node.js │ │ └── javascript.js │ ├── main.js │ ├── util.js │ └── regexper.js ├── sass │ ├── .keep │ ├── _base.scss │ ├── svg.scss │ ├── reset.scss │ └── main.scss ├── robots.txt ├── favicon.ico ├── changelog.hbs ├── humans.txt ├── 404.hbs ├── index.hbs └── documentation.hbs ├── .babelrc ├── lib ├── data │ ├── date.js │ └── changelog.json ├── canopy-loader.js ├── helpers │ ├── layouts.js │ └── icons.js └── partials │ ├── google_analytics.hbs │ ├── svg_template.hbs │ ├── sentry.hbs │ ├── open_iconic.hbs │ └── layout.hbs ├── .gitignore ├── spec ├── test_index.js ├── parser │ ├── javascript │ │ ├── repeat_any_spec.js │ │ ├── repeat_optional_spec.js │ │ ├── repeat_required_spec.js │ │ ├── anchor_spec.js │ │ ├── any_character_spec.js │ │ ├── parser_state_spec.js │ │ ├── charset_escape_spec.js │ │ ├── repeat_spec_spec.js │ │ ├── literal_spec.js │ │ ├── escape_spec.js │ │ ├── charset_range_spec.js │ │ ├── subexp_spec.js │ │ ├── root_spec.js │ │ ├── charset_spec.js │ │ ├── match_spec.js │ │ ├── match_fragment_spec.js │ │ ├── repeat_spec.js │ │ ├── regexp_spec.js │ │ └── node_spec.js │ └── javascript_spec.js ├── setup_spec.js └── util_spec.js ├── .travis.yml ├── config.js ├── karma.conf.js ├── LICENSE.txt ├── webpack.config.js ├── README.md ├── package.json ├── .jscsrc └── gulpfile.js /src/js/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/sass/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015"] 3 | } 4 | -------------------------------------------------------------------------------- /src/robots.txt: -------------------------------------------------------------------------------- 1 | # robotstxt.org/ 2 | 3 | User-agent: * 4 | -------------------------------------------------------------------------------- /lib/data/date.js: -------------------------------------------------------------------------------- 1 | module.exports = new Date().toISOString(); 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .sass-cache 3 | build 4 | docs 5 | tmp 6 | -------------------------------------------------------------------------------- /src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/javallone/regexper-static/HEAD/src/favicon.ico -------------------------------------------------------------------------------- /spec/test_index.js: -------------------------------------------------------------------------------- 1 | var testsContext = require.context(".", true, /_spec$/); 2 | testsContext.keys().forEach(testsContext); 3 | -------------------------------------------------------------------------------- /lib/canopy-loader.js: -------------------------------------------------------------------------------- 1 | var canopy = require('canopy'); 2 | 3 | module.exports = function(source) { 4 | this.cacheable(); 5 | return canopy.compile(source); 6 | }; 7 | -------------------------------------------------------------------------------- /lib/helpers/layouts.js: -------------------------------------------------------------------------------- 1 | var layouts = require('handlebars-layouts'); 2 | 3 | module.exports.register = function(handlebars) { 4 | layouts.register(handlebars); 5 | }; 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "node" 4 | addons: 5 | firefox: "latest" 6 | before_script: 7 | - "export DISPLAY=:99.0" 8 | - "sh -e /etc/init.d/xvfb start" 9 | - sleep 3 10 | script: yarn test 11 | -------------------------------------------------------------------------------- /src/js/parser/javascript/repeat_any.js: -------------------------------------------------------------------------------- 1 | // RepeatAny nodes are used for `a*` regular expression syntax. It is not 2 | // rendered directly; it just indicates that the [Repeat](./repeat.html) node 3 | // loops zero or more times. 4 | 5 | export default { 6 | minimum: 0, 7 | maximum: -1 8 | }; 9 | -------------------------------------------------------------------------------- /src/js/parser/javascript/repeat_optional.js: -------------------------------------------------------------------------------- 1 | // RepeatOptional nodes are used for `a?` regular expression syntax. It is not 2 | // rendered directly; it just indicates that the [Repeat](./repeat.html) node 3 | // loops zero or one times. 4 | 5 | export default { 6 | minimum: 0, 7 | maximum: 1 8 | }; 9 | -------------------------------------------------------------------------------- /src/js/parser/javascript/repeat_required.js: -------------------------------------------------------------------------------- 1 | // RepeatRequired nodes are used for `a+` regular expression syntax. It is not 2 | // rendered directly; it just indicates that the [Repeat](./repeat.html) node 3 | // loops one or more times. 4 | 5 | export default { 6 | minimum: 1, 7 | maximum: -1 8 | }; 9 | -------------------------------------------------------------------------------- /lib/helpers/icons.js: -------------------------------------------------------------------------------- 1 | module.exports.register = function(handlebars) { 2 | handlebars.registerHelper('icon', function(selector, context) { 3 | return new handlebars.SafeString(``); 4 | }); 5 | }; 6 | -------------------------------------------------------------------------------- /src/js/parser/javascript/any_character.js: -------------------------------------------------------------------------------- 1 | // AnyCharacter nodes are for `*` regular expression syntax. They are rendered 2 | // as just an "any character" label. 3 | 4 | import _ from 'lodash'; 5 | 6 | export default { 7 | type: 'any-character', 8 | 9 | _render() { 10 | return this.renderLabel('any character'); 11 | } 12 | }; 13 | -------------------------------------------------------------------------------- /src/js/parser/javascript/anchor.js: -------------------------------------------------------------------------------- 1 | export default { 2 | _render() { 3 | return this.renderLabel(this.label).then(label => label.addClass('anchor')); 4 | }, 5 | 6 | setup() { 7 | if (this.textValue === '^') { 8 | this.label = 'Start of line'; 9 | } else { 10 | this.label = 'End of line'; 11 | } 12 | } 13 | }; 14 | -------------------------------------------------------------------------------- /src/changelog.hbs: -------------------------------------------------------------------------------- 1 | --- 2 | title: Changelog 3 | --- 4 | {{#extend "layout"}} 5 | {{#content "body"}} 6 |
7 |
8 | {{#each changelog}} 9 |
{{label}}
10 | {{#each changes}} 11 |
{{{this}}}
12 | {{/each}} 13 | {{/each}} 14 |
15 |
16 | {{/content}} 17 | {{/extend}} 18 | -------------------------------------------------------------------------------- /src/humans.txt: -------------------------------------------------------------------------------- 1 | # humanstxt.org/ 2 | # The humans responsible & technology colophon 3 | 4 | # TEAM 5 | 6 | Creator: Jeff Avallone 7 | Site: http://github.com/javallone 8 | Twitter: @javallone 9 | 10 | # THANKS 11 | 12 | strfriend.com for the idea, whatever happened to you? 13 | 14 | # TECHNOLOGY COLOPHON 15 | 16 | HTML5, CSS3, SVG, Sass, Open Iconic 17 | -------------------------------------------------------------------------------- /spec/parser/javascript/repeat_any_spec.js: -------------------------------------------------------------------------------- 1 | import javascript from '../../../src/js/parser/javascript/parser.js'; 2 | 3 | describe('parser/javascript/repeat_any.js', function() { 4 | 5 | it('parses "*" as a RepeatAny', function() { 6 | var parser = new javascript.Parser('*'); 7 | expect(parser.__consume__repeat_any()).toEqual(jasmine.objectContaining({ 8 | minimum: 0, 9 | maximum: -1 10 | })); 11 | }); 12 | 13 | }); 14 | -------------------------------------------------------------------------------- /src/js/parser/javascript/charset_escape.js: -------------------------------------------------------------------------------- 1 | // CharsetEscape nodes are for escape sequences inside of character sets. They 2 | // differ from other [Escape](./escape.html) nodes in that `\b` matches a 3 | // backspace character instead of a word boundary. 4 | 5 | import _ from 'lodash'; 6 | import Escape from './escape.js'; 7 | 8 | export default _.extend({}, Escape, { 9 | type: 'charset-escape', 10 | 11 | b: ['backspace', 0x08, true] 12 | }); 13 | -------------------------------------------------------------------------------- /spec/parser/javascript/repeat_optional_spec.js: -------------------------------------------------------------------------------- 1 | import javascript from '../../../src/js/parser/javascript/parser.js'; 2 | 3 | describe('parser/javascript/repeat_optional.js', function() { 4 | 5 | it('parses "?" as a RepeatOptional', function() { 6 | var parser = new javascript.Parser('?'); 7 | expect(parser.__consume__repeat_optional()).toEqual(jasmine.objectContaining({ 8 | minimum: 0, 9 | maximum: 1 10 | })); 11 | }); 12 | 13 | }); 14 | -------------------------------------------------------------------------------- /spec/parser/javascript/repeat_required_spec.js: -------------------------------------------------------------------------------- 1 | import javascript from '../../../src/js/parser/javascript/parser.js'; 2 | 3 | describe('parser/javascript/repeat_required.js', function() { 4 | 5 | it('parses "+" as a RepeatRequired', function() { 6 | var parser = new javascript.Parser('+'); 7 | expect(parser.__consume__repeat_required()).toEqual(jasmine.objectContaining({ 8 | minimum: 1, 9 | maximum: -1 10 | })); 11 | }); 12 | 13 | }); 14 | -------------------------------------------------------------------------------- /src/404.hbs: -------------------------------------------------------------------------------- 1 | --- 2 | title: Page Not Found 3 | --- 4 | {{#extend "layout"}} 5 | {{#content "body"}} 6 |
7 |

404: Not Found

8 | 9 |
10 | Some people, when confronted with a problem, think
11 | “I know, I'll use regular expressions.” Now they have two problems. 12 |
13 | 14 |

Apparently, you have three problems…because the page you requested cannot be found.

15 |
16 | {{/content}} 17 | {{/extend}} 18 | -------------------------------------------------------------------------------- /spec/parser/javascript/anchor_spec.js: -------------------------------------------------------------------------------- 1 | import javascript from '../../../src/js/parser/javascript/parser.js'; 2 | import _ from 'lodash'; 3 | 4 | describe('parser/javascript/anchor.js', function() { 5 | 6 | _.forIn({ 7 | '^': { 8 | label: 'Start of line' 9 | }, 10 | '$': { 11 | label: 'End of line' 12 | } 13 | }, (content, str) => { 14 | it(`parses "${str}" as an Anchor`, function() { 15 | var parser = new javascript.Parser(str); 16 | expect(parser.__consume__anchor()).toEqual(jasmine.objectContaining(content)); 17 | }); 18 | }); 19 | 20 | }); 21 | -------------------------------------------------------------------------------- /lib/partials/google_analytics.hbs: -------------------------------------------------------------------------------- 1 | {{#if gaPropertyId}} 2 | 15 | {{/if}} 16 | -------------------------------------------------------------------------------- /spec/parser/javascript/any_character_spec.js: -------------------------------------------------------------------------------- 1 | import javascript from '../../../src/js/parser/javascript/parser.js'; 2 | 3 | describe('parser/javascript/any_character.js', function() { 4 | 5 | it('parses "." as an AnyCharacter', function() { 6 | var parser = new javascript.Parser('.'); 7 | expect(parser.__consume__terminal()).toEqual(jasmine.objectContaining({ 8 | type: 'any-character' 9 | })); 10 | }); 11 | 12 | describe('#_render', function() { 13 | 14 | beforeEach(function() { 15 | var parser = new javascript.Parser('.'); 16 | this.node = parser.__consume__terminal(); 17 | }); 18 | 19 | it('renders a label', function() { 20 | spyOn(this.node, 'renderLabel').and.returnValue('rendered label'); 21 | expect(this.node._render()).toEqual('rendered label'); 22 | expect(this.node.renderLabel).toHaveBeenCalledWith('any character'); 23 | }); 24 | 25 | }); 26 | 27 | }); 28 | -------------------------------------------------------------------------------- /config.js: -------------------------------------------------------------------------------- 1 | var path = require('path'), 2 | _ = require('lodash'), 3 | buildRoot = process.env.BUILD_PATH || path.join(__dirname, './build'), 4 | buildPath = _.bind(path.join, path, buildRoot); 5 | 6 | module.exports = { 7 | buildRoot: buildRoot, 8 | buildPath: buildPath, 9 | globs: { 10 | other: './src/**/*.!(hbs|scss|js|peg)', 11 | templates: './src/**/*.hbs', 12 | data: ['./lib/data/**/*.json', './lib/data/**/*.js'], 13 | helpers: './lib/helpers/**/*.js', 14 | partials: './lib/partials/**/*.hbs', 15 | sass: './src/**/*.scss', 16 | svg_sass: './src/sass/svg.scss', 17 | js: ['./src/**/*.js', './src/**/*.peg'], 18 | spec: './spec/**/*_spec.js', 19 | lint: [ 20 | './lib/**/*.js', 21 | './src/**/*.js', 22 | './spec/**/*.js', 23 | './*.js' 24 | ] 25 | }, 26 | lintRoots: ['lib', 'src', 'spec'], 27 | browserify: { 28 | debug: true, 29 | fullPaths: false 30 | } 31 | }; 32 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | module.exports = function(karma) { 2 | karma.set({ 3 | frameworks: ['jasmine'], 4 | files: [ 'spec/test_index.js' ], 5 | preprocessors: { 6 | 'spec/test_index.js': ['webpack', 'sourcemap'] 7 | }, 8 | reporters: ['progress', 'notify'], 9 | colors: true, 10 | logLevel: karma.LOG_INFO, 11 | browsers: ['Firefox'], 12 | autoWatch: true, 13 | singleRun: false, 14 | webpack: { 15 | devtool: 'inline-source-map', 16 | module: { 17 | loaders: [ 18 | { 19 | test: /\.js$/, 20 | exclude: /node_modules/, 21 | loader: 'babel-loader' 22 | }, 23 | { 24 | test: require.resolve('snapsvg'), 25 | loader: 'imports-loader?this=>window,fix=>module.exports=0' 26 | }, 27 | { 28 | test: /\.peg$/, 29 | loader: require.resolve('./lib/canopy-loader') 30 | } 31 | ] 32 | } 33 | } 34 | }); 35 | }; 36 | -------------------------------------------------------------------------------- /spec/setup_spec.js: -------------------------------------------------------------------------------- 1 | import util from '../src/js/util.js'; 2 | 3 | // Setup (and teardown) SVG container template 4 | beforeEach(function() { 5 | var template = document.createElement('script'); 6 | template.setAttribute('type', 'text/html'); 7 | template.setAttribute('id', 'svg-container-base'); 8 | template.innerHTML = [ 9 | '
', 10 | '
' 11 | ].join(''); 12 | document.body.appendChild(template); 13 | 14 | this.testablePromise = function() { 15 | var result = {}; 16 | 17 | result.promise = new Promise((resolve, reject) => { 18 | result.resolve = resolve; 19 | result.reject = reject; 20 | }); 21 | 22 | return result; 23 | }; 24 | }); 25 | 26 | afterEach(function() { 27 | document.body.removeChild(document.body.querySelector('#svg-container-base')); 28 | }); 29 | 30 | // Spy on util.track to prevent unnecessary logging 31 | beforeEach(function() { 32 | spyOn(util, 'track'); 33 | }); 34 | -------------------------------------------------------------------------------- /src/js/parser/javascript/repeat_spec.js: -------------------------------------------------------------------------------- 1 | // RepeatSpec nodes are used for `a{m,n}` regular expression syntax. It is not 2 | // rendered directly; it just indicates how many times the 3 | // [Repeat](./repeat.html) node loops. 4 | 5 | export default { 6 | setup() { 7 | if (this.properties.min) { 8 | this.minimum = Number(this.properties.min.textValue); 9 | } else if (this.properties.exact) { 10 | this.minimum = Number(this.properties.exact.textValue); 11 | } else { 12 | this.minimum = 0; 13 | } 14 | 15 | if (this.properties.max) { 16 | this.maximum = Number(this.properties.max.textValue); 17 | } else if (this.properties.exact) { 18 | this.maximum = Number(this.properties.exact.textValue); 19 | } else { 20 | this.maximum = -1; 21 | } 22 | 23 | // Report invalid repeat when the minimum is larger than the maximum. 24 | if (this.minimum > this.maximum && this.maximum !== -1) { 25 | throw `Numbers out of order: ${this.textValue}`; 26 | } 27 | } 28 | }; 29 | -------------------------------------------------------------------------------- /lib/partials/svg_template.hbs: -------------------------------------------------------------------------------- 1 |
2 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 |
23 |
24 |
25 |
26 | -------------------------------------------------------------------------------- /spec/parser/javascript/parser_state_spec.js: -------------------------------------------------------------------------------- 1 | import ParserState from '../../../src/js/parser/javascript/parser_state.js'; 2 | 3 | describe('parser/javascript/parser_state.js', function() { 4 | 5 | beforeEach(function() { 6 | this.progress = { style: {} }; 7 | this.state = new ParserState(this.progress); 8 | }); 9 | 10 | describe('renderCounter property', function() { 11 | 12 | it('sets the width of the progress element to the percent of completed steps', function() { 13 | this.state.renderCounter = 50; 14 | expect(this.progress.style.width).toEqual('0.00%'); 15 | this.state.renderCounter = 10; 16 | expect(this.progress.style.width).toEqual('80.00%'); 17 | }); 18 | 19 | it('does not change the width of the progress element when rendering has been cancelled', function() { 20 | this.state.renderCounter = 50; 21 | this.state.renderCounter = 40; 22 | expect(this.progress.style.width).toEqual('20.00%'); 23 | this.state.cancelRender = true; 24 | this.state.renderCounter = 10; 25 | expect(this.progress.style.width).toEqual('20.00%'); 26 | }); 27 | 28 | }); 29 | 30 | }); 31 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Jeffrey Avallone 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /lib/partials/sentry.hbs: -------------------------------------------------------------------------------- 1 | {{#if sentryKey}} 2 | 17 | {{/if}} 18 | -------------------------------------------------------------------------------- /src/sass/_base.scss: -------------------------------------------------------------------------------- 1 | @import 'bourbon'; 2 | 3 | $green: #bada55; 4 | $dark-green: shade($green, 25%); 5 | $light-green: tint($green, 25%); 6 | $gray: #6b6659; 7 | $light-gray: tint($gray, 25%); 8 | $tan: #cbcbba; 9 | $red: #b3151a; 10 | $blue: #dae9e5; 11 | $yellow: #f8ca00; 12 | $black: #000; 13 | $white: #fff; 14 | 15 | $base-font-size: 16px; 16 | $base-line-height: 24px; 17 | 18 | @mixin box-shadow { 19 | @include prefixer(box-shadow, 0 0 10px $black, webkit moz spec); 20 | } 21 | 22 | @mixin input-placeholder { 23 | &:-moz-placeholder { 24 | @content; 25 | } 26 | 27 | &::-moz-placeholder { 28 | @content; 29 | } 30 | 31 | &:-ms-input-placeholder { 32 | @content; 33 | } 34 | 35 | &::-webkit-input-placeholder { 36 | @content; 37 | } 38 | } 39 | 40 | @function rhythm($scale, $font-size: $base-font-size) { 41 | @return ($scale * $base-line-height / $font-size) * 1em; 42 | } 43 | 44 | @mixin adjust-font-size-to($to-size, $lines: auto) { 45 | font-size: ($to-size / $base-font-size) * 1em; 46 | @if $lines == auto { 47 | $lines: ceil($to-size / $base-font-size); 48 | } 49 | line-height: rhythm($lines, $to-size); 50 | } 51 | -------------------------------------------------------------------------------- /src/sass/svg.scss: -------------------------------------------------------------------------------- 1 | @import 'base'; 2 | 3 | svg { 4 | background-color: $white; 5 | } 6 | 7 | .root text, 8 | .root tspan { 9 | font: 12px Arial; 10 | } 11 | 12 | .root path { 13 | fill-opacity: 0; 14 | stroke-width: 2px; 15 | stroke: $black; 16 | } 17 | 18 | .root circle { 19 | fill: $gray; 20 | stroke-width: 2px; 21 | stroke: $black; 22 | } 23 | 24 | .anchor text, .any-character text { 25 | fill: $white; 26 | } 27 | 28 | .anchor rect, .any-character rect { 29 | fill: $gray; 30 | } 31 | 32 | .escape text, .charset-escape text, .literal text { 33 | fill: $black; 34 | } 35 | 36 | .escape rect, .charset-escape rect { 37 | fill: $green; 38 | } 39 | 40 | .literal rect { 41 | fill: $blue; 42 | } 43 | 44 | .charset .charset-box { 45 | fill: $tan; 46 | } 47 | 48 | .subexp .subexp-label tspan, 49 | .charset .charset-label tspan, 50 | .match-fragment .repeat-label tspan { 51 | font-size: 10px; 52 | } 53 | 54 | .repeat-label { 55 | cursor: help; 56 | } 57 | 58 | .subexp .subexp-label tspan, 59 | .charset .charset-label tspan { 60 | dominant-baseline: text-after-edge; 61 | } 62 | 63 | .subexp .subexp-box { 64 | stroke: $light-gray; 65 | stroke-dasharray: 6,2; 66 | stroke-width: 2px; 67 | fill-opacity: 0; 68 | } 69 | 70 | .quote { 71 | fill: $light-gray; 72 | } 73 | -------------------------------------------------------------------------------- /src/sass/reset.scss: -------------------------------------------------------------------------------- 1 | /* http://meyerweb.com/eric/tools/css/reset/ 2 | v2.0 | 20110126 3 | License: none (public domain) 4 | */ 5 | 6 | html, body, div, span, applet, object, iframe, 7 | h1, h2, h3, h4, h5, h6, p, blockquote, pre, 8 | a, abbr, acronym, address, big, cite, code, 9 | del, dfn, em, img, ins, kbd, q, s, samp, 10 | small, strike, strong, sub, sup, tt, var, 11 | b, u, i, center, 12 | dl, dt, dd, ol, ul, li, 13 | fieldset, form, label, legend, 14 | table, caption, tbody, tfoot, thead, tr, th, td, 15 | article, aside, canvas, details, embed, 16 | figure, figcaption, footer, header, hgroup, 17 | menu, nav, output, ruby, section, summary, 18 | time, mark, audio, video { 19 | margin: 0; 20 | padding: 0; 21 | border: 0; 22 | font-size: 100%; 23 | font: inherit; 24 | vertical-align: baseline; 25 | } 26 | /* HTML5 display-role reset for older browsers */ 27 | article, aside, details, figcaption, figure, 28 | footer, header, hgroup, menu, nav, section { 29 | display: block; 30 | } 31 | body { 32 | line-height: 1; 33 | } 34 | ol, ul { 35 | list-style: none; 36 | } 37 | blockquote, q { 38 | quotes: none; 39 | } 40 | blockquote:before, blockquote:after, 41 | q:before, q:after { 42 | content: ''; 43 | content: none; 44 | } 45 | table { 46 | border-collapse: collapse; 47 | border-spacing: 0; 48 | } 49 | -------------------------------------------------------------------------------- /src/js/parser/javascript/parser_state.js: -------------------------------------------------------------------------------- 1 | // State tracking for an in-progress parse and render. 2 | export default class ParserState { 3 | // - __progress__ - DOM node to update to indicate completion progress. 4 | constructor(progress) { 5 | // Tracks the number of capture groups in the expression. 6 | this.groupCounter = 1; 7 | // Cancels the in-progress render when set to true. 8 | this.cancelRender = false; 9 | // Warnings that have been generated while rendering. 10 | this.warnings = []; 11 | 12 | // Used to display the progress indicator 13 | this._renderCounter = 0; 14 | this._maxCounter = 0; 15 | this._progress = progress; 16 | } 17 | 18 | // Counts the number of in-progress rendering steps. As the counter goes up, 19 | // a maximum value is also tracked. The maximum value and current render 20 | // counter are used to calculate the completion process. 21 | get renderCounter() { 22 | return this._renderCounter; 23 | } 24 | 25 | set renderCounter(value) { 26 | if (value > this.renderCounter) { 27 | this._maxCounter = value; 28 | } 29 | 30 | this._renderCounter = value; 31 | 32 | if (this._maxCounter && !this.cancelRender) { 33 | this._progress.style.width = ((1 - this.renderCounter / this._maxCounter) * 100).toFixed(2) + '%'; 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/index.hbs: -------------------------------------------------------------------------------- 1 | {{#extend "layout"}} 2 | {{#content "body"}} 3 |
4 |
5 | 6 | 7 | 8 | 19 |
20 |
21 | 22 |
23 |
24 | 25 | 26 | 27 |
28 |
29 | 30 | {{/content}} 31 | 32 | {{#content "footer" mode="append"}} 33 | 34 | {{/content}} 35 | {{/extend}} 36 | -------------------------------------------------------------------------------- /src/js/parser/javascript/charset_range.js: -------------------------------------------------------------------------------- 1 | // CharsetRange nodes are used for `[a-z]` regular expression syntax. The two 2 | // literal or escape nodes are rendered with a hyphen between them. 3 | 4 | import util from '../../util.js'; 5 | import _ from 'lodash'; 6 | 7 | export default { 8 | type: 'charset-range', 9 | 10 | // Renders the charset range into the currently set container 11 | _render() { 12 | let contents = [ 13 | this.first, 14 | this.container.text(0, 0, '-'), 15 | this.last 16 | ]; 17 | 18 | // Render the nodes of the range. 19 | return Promise.all([ 20 | this.first.render(this.container.group()), 21 | this.last.render(this.container.group()) 22 | ]) 23 | .then(() => { 24 | // Space the nodes and hyphen horizontally. 25 | util.spaceHorizontally(contents, { 26 | padding: 5 27 | }); 28 | }); 29 | }, 30 | 31 | setup() { 32 | // The two nodes for the range. In `[a-z]` these would be 33 | // [Literal](./literal.html) nodes for "a" and "z". 34 | this.first = this.properties.first; 35 | this.last = this.properties.last; 36 | 37 | // Report invalid expression when extents of the range are out of order. 38 | if (this.first.ordinal > this.last.ordinal) { 39 | throw `Range out of order in character class: ${this.textValue}`; 40 | } 41 | } 42 | }; 43 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | var webpack = require('webpack'), 2 | bourbon = require('bourbon'), 3 | config = require('./config'); 4 | 5 | module.exports = { 6 | devtool: 'source-map', 7 | entry: { 8 | 'js/main.js': ['babel-polyfill', './src/js/main.js'], 9 | '__discard__/css/main.css.js': './src/sass/main.scss', 10 | '__discard__/css/svg.css.js': './src/sass/svg.scss' 11 | }, 12 | output: { 13 | path: config.buildRoot, 14 | filename: '[name]' 15 | }, 16 | plugins: [ 17 | new webpack.optimize.UglifyJsPlugin({ 18 | sourceMap: true, 19 | compress: { 20 | warnings: false 21 | } 22 | }) 23 | ], 24 | module: { 25 | loaders: [ 26 | { 27 | test: /\.js$/, 28 | exclude: /node_modules/, 29 | loader: 'babel-loader' 30 | }, 31 | { 32 | test: require.resolve('snapsvg'), 33 | loader: 'imports-loader?this=>window,fix=>module.exports=0' 34 | }, 35 | { 36 | test: /\.peg$/, 37 | loader: require.resolve('./lib/canopy-loader') 38 | }, 39 | { 40 | test: /\.scss$/, 41 | exclude: /node_modules/, 42 | loaders: [ 43 | 'file-loader?name=css/[name].css', 44 | 'extract-loader', 45 | 'css-loader', 46 | 'sass-loader?includePaths[]=' + bourbon.includePaths 47 | ] 48 | } 49 | ] 50 | } 51 | }; 52 | -------------------------------------------------------------------------------- /src/js/parser/javascript/literal.js: -------------------------------------------------------------------------------- 1 | // Literal nodes are for plain strings in the regular expression. They are 2 | // rendered as labels with the value of the literal quoted. 3 | 4 | import _ from 'lodash'; 5 | 6 | export default { 7 | type: 'literal', 8 | 9 | // Renders the literal into the currently set container. 10 | _render() { 11 | return this.renderLabel(['\u201c', this.literal, '\u201d']) 12 | .then(label => { 13 | let spans = label.selectAll('tspan'); 14 | 15 | // The quote marks get some styling to lighten their color so they are 16 | // distinct from the actual literal value. 17 | spans[0].addClass('quote'); 18 | spans[2].addClass('quote'); 19 | 20 | label.select('rect').attr({ 21 | rx: 3, 22 | ry: 3 23 | }); 24 | 25 | return label; 26 | }); 27 | }, 28 | 29 | // Merges this literal with another. Literals come back as single characters 30 | // during parsing, and must be post-processed into multi-character literals 31 | // for rendering. This processing is done in [Match](./match.html). 32 | merge(other) { 33 | this.literal += other.literal; 34 | }, 35 | 36 | setup() { 37 | // Value of the literal. 38 | this.literal = this.properties.literal.textValue; 39 | // Ordinal value of the literal for use in 40 | // [CharsetRange](./charset_range.html). 41 | this.ordinal = this.literal.charCodeAt(0); 42 | } 43 | }; 44 | -------------------------------------------------------------------------------- /spec/parser/javascript/charset_escape_spec.js: -------------------------------------------------------------------------------- 1 | import javascript from '../../../src/js/parser/javascript/parser.js'; 2 | import _ from 'lodash'; 3 | import Snap from 'snapsvg'; 4 | 5 | describe('parser/javascript/charset_escape.js', function() { 6 | 7 | _.forIn({ 8 | '\\b': { label: 'backspace (0x08)', ordinal: 0x08 }, 9 | '\\d': { label: 'digit', ordinal: -1 }, 10 | '\\D': { label: 'non-digit', ordinal: -1 }, 11 | '\\f': { label: 'form feed (0x0C)', ordinal: 0x0c }, 12 | '\\n': { label: 'line feed (0x0A)', ordinal: 0x0a }, 13 | '\\r': { label: 'carriage return (0x0D)', ordinal: 0x0d }, 14 | '\\s': { label: 'white space', ordinal: -1 }, 15 | '\\S': { label: 'non-white space', ordinal: -1 }, 16 | '\\t': { label: 'tab (0x09)', ordinal: 0x09 }, 17 | '\\v': { label: 'vertical tab (0x0B)', ordinal: 0x0b }, 18 | '\\w': { label: 'word', ordinal: -1 }, 19 | '\\W': { label: 'non-word', ordinal: -1 }, 20 | '\\0': { label: 'null (0x00)', ordinal: 0 }, 21 | '\\012': { label: 'octal: 12 (0x0A)', ordinal: 10 }, 22 | '\\cx': { label: 'ctrl-X (0x18)', ordinal: 24 }, 23 | '\\xab': { label: '0xAB', ordinal: 0xab }, 24 | '\\uabcd': { label: 'U+ABCD', ordinal: 0xabcd } 25 | }, (content, str) => { 26 | it(`parses "${str}" as a CharsetEscape`, function() { 27 | var parser = new javascript.Parser(str); 28 | expect(parser.__consume__charset_terminal()).toEqual(jasmine.objectContaining(content)); 29 | }); 30 | }); 31 | 32 | }); 33 | -------------------------------------------------------------------------------- /src/js/main.js: -------------------------------------------------------------------------------- 1 | // This file contains code to start up pages on the site, and other code that 2 | // is not directly related to parsing and display of regular expressions. 3 | // 4 | // Since the code in this is executed immediately, it is all but impossible to 5 | // test. Therefore, this code is kept as simple as possible to reduce the need 6 | // to run it through automated tests. 7 | 8 | import util from './util.js'; 9 | import Regexper from './regexper.js'; 10 | import Parser from './parser/javascript.js'; 11 | import _ from 'lodash'; 12 | 13 | (function() { 14 | // Initialize the main page of the site. Functionality is kept in the 15 | // [Regexper class](./regexper.html). 16 | if (document.body.querySelector('#content .application')) { 17 | let regexper = new Regexper(document.body); 18 | 19 | regexper.detectBuggyHash(); 20 | regexper.bindListeners(); 21 | 22 | util.tick().then(() => { 23 | window.dispatchEvent(util.customEvent('hashchange')); 24 | }); 25 | } 26 | 27 | // Initialize other pages on the site (specifically the documentation page). 28 | // Any element with a `data-expr` attribute will contain a rendering of the 29 | // provided regular expression. 30 | _.each(document.querySelectorAll('[data-expr]'), element => { 31 | new Parser(element, { keepContent: true }) 32 | .parse(element.getAttribute('data-expr')) 33 | .then(parser => { 34 | parser.render(); 35 | }) 36 | .catch(util.exposeError); 37 | }); 38 | }()); 39 | -------------------------------------------------------------------------------- /spec/parser/javascript/repeat_spec_spec.js: -------------------------------------------------------------------------------- 1 | import javascript from '../../../src/js/parser/javascript/parser.js'; 2 | 3 | describe('parser/javascript/repeat_spec.js', function() { 4 | 5 | it('parses "{n,m}" as a RepeatSpec (with minimum and maximum values)', function() { 6 | var parser = new javascript.Parser('{24,42}'); 7 | expect(parser.__consume__repeat_spec()).toEqual(jasmine.objectContaining({ 8 | minimum: 24, 9 | maximum: 42 10 | })); 11 | }); 12 | 13 | it('parses "{n,}" as a RepeatSpec (with only minimum value)', function() { 14 | var parser = new javascript.Parser('{24,}'); 15 | expect(parser.__consume__repeat_spec()).toEqual(jasmine.objectContaining({ 16 | minimum: 24, 17 | maximum: -1 18 | })); 19 | }); 20 | 21 | it('parses "{n}" as a RepeatSpec (with an exact count)', function() { 22 | var parser = new javascript.Parser('{24}'); 23 | expect(parser.__consume__repeat_spec()).toEqual(jasmine.objectContaining({ 24 | minimum: 24, 25 | maximum: 24 26 | })); 27 | }); 28 | 29 | it('does not parse "{,m}" as a RepeatSpec', function() { 30 | var parser = new javascript.Parser('{,42}'); 31 | expect(parser.__consume__repeat_spec()).toEqual(null); 32 | }); 33 | 34 | it('throws an exception when the numbers are out of order', function() { 35 | var parser = new javascript.Parser('{42,24}'); 36 | expect(() => { 37 | parser.__consume__repeat_spec(); 38 | }).toThrow('Numbers out of order: {42,24}'); 39 | }); 40 | 41 | }); 42 | -------------------------------------------------------------------------------- /lib/partials/open_iconic.hbs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Regexper [![Build Status](https://travis-ci.org/javallone/regexper-static.svg?branch=master)](https://travis-ci.org/javallone/regexper-static) 2 | 3 | **This project has been migrated to https://gitlab.com/javallone/regexper-static** 4 | 5 | Code for the http://regexper.com/ site. 6 | 7 | ## Contributing 8 | 9 | I greatly appreciate any contributions that you may have for the site. Feel free to fork the project and work on any of the reported issues, or let me know about any improvements you think would be beneficial. 10 | 11 | When sending pull requests, please keep them focused on a single issue. I would rather deal with a dozen pull requests for a dozen issues with one change each over one pull request fixing a dozen issues. Also, I appreciate tests to be included with feature development, but for minor changes I will probably not put up much of a fuss if they're missing. 12 | 13 | ### Working with the code 14 | 15 | Node is required for working with this site. 16 | 17 | To start with, install the necessary dependencies: 18 | 19 | $ yarn install 20 | 21 | To start a development server, run: 22 | 23 | $ yarn start 24 | 25 | This will build the site into the ./build directory, start a local start on port 8080, and begin watching the source files for modifications. The site will automatically be rebuilt when files are changed. Also, if you browser has the LiveReload extension, then the page will be reloaded. 26 | 27 | These other gulp tasks are available: 28 | 29 | $ gulp docs # Build documentation into the ./docs directory 30 | $ gulp build # Build the site into the ./build directory 31 | $ yarn test # Run JSCS lint and Karma tests 32 | 33 | ## License 34 | 35 | See [LICENSE.txt](/LICENSE.txt) file for licensing details. 36 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "regexper", 3 | "version": "1.0.0", 4 | "description": "Regular expression visualization tool", 5 | "homepage": "http://regexper.com", 6 | "author": { 7 | "name": "Jeffrey Avallone", 8 | "email": "jeff.avallone@gmail.com" 9 | }, 10 | "license": "MIT", 11 | "private": true, 12 | "scripts": { 13 | "pretest": "jscs lib/ src/ spec/", 14 | "test": "karma start --single-run", 15 | "build": "gulp build", 16 | "start": "gulp" 17 | }, 18 | "devDependencies": { 19 | "babel-core": "^6.17.0", 20 | "babel-loader": "^7.1.1", 21 | "babel-polyfill": "^6.3.14", 22 | "babel-preset-es2015": "^6.16.0", 23 | "babel-runtime": "^6.3.19", 24 | "canopy": "^0.2.0", 25 | "css-loader": "^0.28.4", 26 | "docco": "^0.7.0", 27 | "extract-loader": "^1.0.0", 28 | "file-loader": "^0.11.2", 29 | "folder-toc": "^0.1.0", 30 | "gulp": "^3.8.10", 31 | "gulp-connect": "^5.0.0", 32 | "gulp-docco": "0.0.4", 33 | "gulp-front-matter": "^1.3.0", 34 | "gulp-hb": "^6.0.2", 35 | "gulp-help": "^1.6.1", 36 | "gulp-notify": "^3.0.0", 37 | "gulp-rename": "^1.2.2", 38 | "gulp-util": "^3.0.7", 39 | "handlebars-layouts": "^3.1.2", 40 | "imports-loader": "^0.7.1", 41 | "jasmine-core": "^2.4.1", 42 | "jscs": "^3.0.7", 43 | "karma": "^1.1.2", 44 | "karma-firefox-launcher": "^1.0.0", 45 | "karma-jasmine": "^1.0.2", 46 | "karma-notify-reporter": "^1.0.1", 47 | "karma-sourcemap-loader": "^0.3.7", 48 | "karma-webpack": "^2.0.4", 49 | "lodash": "^4.6.1", 50 | "node-bourbon": "^4.2.3", 51 | "node-sass": "^4.5.3", 52 | "sass-loader": "^6.0.6", 53 | "snapsvg": "^0.5.1", 54 | "watchify": "^3.7.0", 55 | "webpack": "^3.4.1" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/js/parser/javascript/root.js: -------------------------------------------------------------------------------- 1 | // Root nodes contain the top-level [Regexp](./regexp.html) node. Any flags 2 | // and a few decorative elements are rendered by the root node. 3 | 4 | import _ from 'lodash'; 5 | 6 | export default { 7 | type: 'root', 8 | 9 | flagLabels: { 10 | i: 'Ignore Case', 11 | g: 'Global', 12 | m: 'Multiline', 13 | y: 'Sticky', 14 | u: 'Unicode' 15 | }, 16 | 17 | // Renders the root into the currently set container. 18 | _render() { 19 | let flagText; 20 | 21 | // Render a label for any flags that have been set of the expression. 22 | if (this.flags.length > 0) { 23 | flagText = this.container.text(0, 0, `Flags: ${this.flags.join(', ')}`); 24 | } 25 | 26 | // Render the content of the regular expression. 27 | return this.regexp.render(this.container.group()) 28 | .then(() => { 29 | // Move rendered regexp to account for flag label and to allow for 30 | // decorative elements. 31 | if (flagText) { 32 | this.regexp.transform(Snap.matrix() 33 | .translate(10, flagText.getBBox().height)); 34 | } else { 35 | this.regexp.transform(Snap.matrix() 36 | .translate(10, 0)); 37 | } 38 | 39 | let box = this.regexp.getBBox(); 40 | 41 | // Render decorative elements. 42 | this.container.path(`M${box.ax},${box.ay}H0M${box.ax2},${box.ay}H${box.x2 + 10}`); 43 | this.container.circle(0, box.ay, 5); 44 | this.container.circle(box.x2 + 10, box.ay, 5); 45 | }); 46 | }, 47 | 48 | setup() { 49 | // Convert list of flags into text describing each flag. 50 | this.flags = _(this.properties.flags.textValue) 51 | .uniq().sort() 52 | .map(flag => this.flagLabels[flag]).value(); 53 | 54 | this.regexp = this.properties.regexp 55 | } 56 | }; 57 | -------------------------------------------------------------------------------- /.jscsrc: -------------------------------------------------------------------------------- 1 | { 2 | "requireCurlyBraces": [ 3 | "if", 4 | "else", 5 | "for", 6 | "while", 7 | "do", 8 | "try", 9 | "catch" 10 | ], 11 | "requireSpaceAfterKeywords": [ 12 | "if", 13 | "else", 14 | "for", 15 | "while", 16 | "do", 17 | "switch", 18 | "case", 19 | "return", 20 | "try", 21 | "typeof" 22 | ], 23 | "requireSpaceBeforeBlockStatements": true, 24 | "requireParenthesesAroundIIFE": true, 25 | "requireSpacesInConditionalExpression": true, 26 | "disallowSpacesInNamedFunctionExpression": { 27 | "beforeOpeningRoundBrace": true 28 | }, 29 | "disallowSpacesInFunctionDeclaration": { 30 | "beforeOpeningRoundBrace": true 31 | }, 32 | "requireSpaceBetweenArguments": true, 33 | "requireMultipleVarDecl": "onevar", 34 | "requireVarDeclFirst": true, 35 | "requireBlocksOnNewline": true, 36 | "disallowEmptyBlocks": true, 37 | "disallowSpacesInsideArrayBrackets": true, 38 | "disallowSpacesInsideParentheses": true, 39 | "requireCommaBeforeLineBreak": true, 40 | "disallowSpaceAfterPrefixUnaryOperators": true, 41 | "disallowSpaceBeforePostfixUnaryOperators": true, 42 | "disallowSpaceBeforeBinaryOperators": [ 43 | "," 44 | ], 45 | "requireSpacesInForStatement": true, 46 | "requireSpacesInAnonymousFunctionExpression": { 47 | "beforeOpeningCurlyBrace": true 48 | }, 49 | "requireSpaceBeforeBinaryOperators": true, 50 | "requireSpaceAfterBinaryOperators": true, 51 | "disallowKeywords": [ 52 | "with", 53 | "continue" 54 | ], 55 | "validateIndentation": 2, 56 | "disallowMixedSpacesAndTabs": true, 57 | "disallowTrailingWhitespace": true, 58 | "disallowTrailingComma": true, 59 | "disallowKeywordsOnNewLine": [ 60 | "else" 61 | ], 62 | "requireLineFeedAtFileEnd": true, 63 | "requireCapitalizedConstructors": true, 64 | "requireDotNotation": true, 65 | "disallowNewlineBeforeBlockStatements": true, 66 | "disallowMultipleLineStrings": true, 67 | "requireSpaceBeforeObjectValues": true 68 | } 69 | -------------------------------------------------------------------------------- /src/js/parser/javascript/subexp.js: -------------------------------------------------------------------------------- 1 | // Subexp nodes are for expressions inside of parenthesis. It is rendered as a 2 | // labeled box around the contained expression if a label is required. 3 | 4 | import _ from 'lodash'; 5 | 6 | export default { 7 | type: 'subexp', 8 | 9 | definedProperties: { 10 | // Default anchor is overridden to move it down to account for the group 11 | // label and outline box. 12 | _anchor: { 13 | get: function() { 14 | var anchor = this.regexp.getBBox(), 15 | matrix = this.transform().localMatrix; 16 | 17 | return { 18 | ax: matrix.x(anchor.ax, anchor.ay), 19 | ax2: matrix.x(anchor.ax2, anchor.ay), 20 | ay: matrix.y(anchor.ax, anchor.ay) 21 | }; 22 | } 23 | } 24 | }, 25 | 26 | labelMap: { 27 | '?:': '', 28 | '?=': 'positive lookahead', 29 | '?!': 'negative lookahead' 30 | }, 31 | 32 | // Renders the subexp into the currently set container. 33 | _render() { 34 | // **NOTE:** `this.label()` **MUST** be called here, in _render, and before 35 | // any child nodes are rendered. This is to keep the group numbers in the 36 | // correct order. 37 | let label = this.label(); 38 | 39 | // Render the contained regexp. 40 | return this.regexp.render(this.container.group()) 41 | // Create the labeled box around the regexp. 42 | .then(() => this.renderLabeledBox(label, this.regexp, { 43 | padding: 10 44 | })); 45 | }, 46 | 47 | // Returns the label for the subexpression. 48 | label() { 49 | if (_.has(this.labelMap, this.properties.capture.textValue)) { 50 | return this.labelMap[this.properties.capture.textValue]; 51 | } else { 52 | return `group #${this.state.groupCounter++}`; 53 | } 54 | }, 55 | 56 | setup() { 57 | // **NOTE:** **DO NOT** call `this.label()` in setup. It will lead to 58 | // groups being numbered in reverse order. 59 | this.regexp = this.properties.regexp; 60 | 61 | // If there is no need for a label, then proxy to the nested regexp. 62 | if (this.properties.capture.textValue == '?:') { 63 | this.proxy = this.regexp; 64 | } 65 | } 66 | }; 67 | -------------------------------------------------------------------------------- /lib/partials/layout.hbs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Regexper{{#if file.frontMatter.title}} - {{file.frontMatter.title}}{{/if}} 8 | 9 | 10 | 11 | 12 | 13 | {{> "google_analytics"}} 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 |
23 | 28 | 29 | 42 |
43 | 44 |
45 | {{#block "body"}}{{/block}} 46 |
47 | 48 | 65 | 66 | {{> "open_iconic"}} 67 | 68 | 69 | -------------------------------------------------------------------------------- /spec/parser/javascript/literal_spec.js: -------------------------------------------------------------------------------- 1 | import javascript from '../../../src/js/parser/javascript/parser.js'; 2 | import Snap from 'snapsvg'; 3 | 4 | describe('parser/javascript/literal.js', function() { 5 | 6 | it('parses "x" as a Literal', function() { 7 | var parser = new javascript.Parser('x'); 8 | expect(parser.__consume__terminal()).toEqual(jasmine.objectContaining({ 9 | type: 'literal', 10 | literal: 'x', 11 | ordinal: 120 12 | })); 13 | }); 14 | 15 | it('parses "\\x" as a Literal', function() { 16 | var parser = new javascript.Parser('\\x'); 17 | expect(parser.__consume__terminal()).toEqual(jasmine.objectContaining({ 18 | type: 'literal', 19 | literal: 'x', 20 | ordinal: 120 21 | })); 22 | }); 23 | 24 | describe('#_render', function() { 25 | 26 | beforeEach(function() { 27 | var parser = new javascript.Parser('a'); 28 | this.node = parser.__consume__terminal(); 29 | this.node.state = {}; 30 | 31 | this.svg = Snap(document.createElement('svg')); 32 | this.node.container = this.svg.group(); 33 | spyOn(this.node, 'renderLabel').and.callThrough(); 34 | }); 35 | 36 | it('renders a label', function() { 37 | this.node._render(); 38 | expect(this.node.renderLabel).toHaveBeenCalledWith(['\u201c', 'a', '\u201d']); 39 | }); 40 | 41 | it('sets the class of the first and third tspan to "quote"', function(done) { 42 | this.node._render() 43 | .then(label => { 44 | expect(label.selectAll('tspan')[0].hasClass('quote')).toBeTruthy(); 45 | expect(label.selectAll('tspan')[2].hasClass('quote')).toBeTruthy(); 46 | done(); 47 | }); 48 | }); 49 | 50 | it('sets the edge radius of the rect', function(done) { 51 | this.node._render() 52 | .then(label => { 53 | expect(label.select('rect').attr()).toEqual(jasmine.objectContaining({ 54 | rx: '3', 55 | ry: '3' 56 | })); 57 | done(); 58 | }); 59 | }); 60 | 61 | }); 62 | 63 | describe('#merge', function() { 64 | 65 | beforeEach(function() { 66 | var parser = new javascript.Parser('a'); 67 | this.node = parser.__consume__terminal(); 68 | }); 69 | 70 | it('appends to the literal value', function() { 71 | this.node.merge({ literal: 'b' }); 72 | expect(this.node.literal).toEqual('ab'); 73 | }); 74 | 75 | }); 76 | 77 | }); 78 | -------------------------------------------------------------------------------- /src/js/parser/javascript/grammar.peg: -------------------------------------------------------------------------------- 1 | grammar JavascriptRegexp 2 | root <- ( ( "/" regexp "/" flags:[yigmu]* ) / regexp flags:""? ) 3 | regexp <- match:match alternates:( "|" match )* 4 | match <- (!repeat) parts:match_fragment* 5 | anchor <- ( "^" / "$" ) 6 | match_fragment <- content:( anchor / subexp / charset / terminal ) repeat:repeat? 7 | repeat <- spec:( repeat_any / repeat_required / repeat_optional / repeat_spec ) greedy:"?"? 8 | repeat_any <- "*" 9 | repeat_required <- "+" 10 | repeat_optional <- "?" 11 | repeat_spec <- ( "{" min:[0-9]+ "," max:[0-9]+ "}" 12 | / "{" min:[0-9]+ ",}" 13 | / "{" exact:[0-9]+ "}" ) 14 | subexp <- "(" capture:( "?:" / "?=" / "?!" )? regexp ")" 15 | charset <- "[" invert:"^"? parts:( charset_range / charset_terminal )* "]" 16 | charset_range <- first:charset_range_terminal "-" last:charset_range_terminal 17 | charset_terminal <- charset_escape 18 | / charset_literal 19 | charset_range_terminal <- charset_range_escape 20 | / charset_literal 21 | charset_escape <- "\\" esc:( 22 | code:[bdDfnrsStvwW] arg:""? 23 | / control_escape 24 | / octal_escape 25 | / hex_escape 26 | / unicode_escape 27 | / null_escape ) 28 | charset_range_escape <- "\\" esc:( 29 | code:[bfnrtv] arg:""? 30 | / control_escape 31 | / octal_escape 32 | / hex_escape 33 | / unicode_escape 34 | / null_escape ) 35 | charset_literal <- ( ""? literal:[^\\\]] ) 36 | / ( literal:"\\" &"c" ) 37 | / ( "\\" literal:[^bdDfnrsStvwW] ) 38 | terminal <- "." 39 | / escape 40 | / literal 41 | escape <- "\\" esc:( 42 | code:[bBdDfnrsStvwW1-9] arg:""? 43 | / control_escape 44 | / octal_escape 45 | / hex_escape 46 | / unicode_escape 47 | / null_escape ) 48 | literal <- ( ""? literal:[^|\\/.\[\(\)?+*$^] ) 49 | / ( literal:"\\" &"c" ) 50 | / ( "\\" literal:. ) 51 | 52 | control_escape <- code:"c" arg:[a-zA-Z] 53 | octal_escape <- code:"0" arg:[0-7]+ 54 | hex_escape <- code:"x" arg:( [0-9a-fA-F] [0-9a-fA-F] ) 55 | unicode_escape <- code:"u" arg:( [0-9a-fA-F] [0-9a-fA-F] [0-9a-fA-F] [0-9a-fA-F] ) 56 | null_escape <- code:"0" arg:""? 57 | -------------------------------------------------------------------------------- /src/js/parser/javascript/parser.js: -------------------------------------------------------------------------------- 1 | // Sets up the parser generated by canopy to use the 2 | // [Node](./javascript/node.html) subclasses in the generated tree. This is all 3 | // a bit of a hack that is dependent on how canopy creates nodes in its parse 4 | // tree. 5 | import parser from './grammar.peg'; 6 | 7 | import Node from './node.js'; 8 | import Root from './root.js'; 9 | import Regexp from './regexp.js'; 10 | import Match from './match.js'; 11 | import MatchFragment from './match_fragment.js'; 12 | import Anchor from './anchor.js'; 13 | import Subexp from './subexp.js'; 14 | import Charset from './charset.js'; 15 | import CharsetEscape from './charset_escape.js'; 16 | import CharsetRange from './charset_range.js'; 17 | import Literal from './literal.js'; 18 | import Escape from './escape.js'; 19 | import AnyCharacter from './any_character.js'; 20 | import Repeat from './repeat.js'; 21 | import RepeatAny from './repeat_any.js'; 22 | import RepeatOptional from './repeat_optional.js'; 23 | import RepeatRequired from './repeat_required.js'; 24 | import RepeatSpec from './repeat_spec.js'; 25 | 26 | // Canopy creates an instance of SyntaxNode for each element in the tree, then 27 | // adds any necessary fields to that instance. In this case, we're replacing 28 | // the default class with the Node class. 29 | parser.Parser.SyntaxNode = Node; 30 | 31 | // Once the SyntaxNode instance is created, the specific node type object is 32 | // overlayed onto it. This causes the module attribute on the Node to be set, 33 | // which updates the Node instance into the more specific "subclass" that is 34 | // used for rendering. 35 | parser.Parser.Root = { module: Root }; 36 | parser.Parser.Regexp = { module: Regexp }; 37 | parser.Parser.Match = { module: Match }; 38 | parser.Parser.MatchFragment = { module: MatchFragment }; 39 | parser.Parser.Anchor = { module: Anchor }; 40 | parser.Parser.Subexp = { module: Subexp }; 41 | parser.Parser.Charset = { module: Charset }; 42 | parser.Parser.CharsetEscape = { module: CharsetEscape }; 43 | parser.Parser.CharsetRange = { module: CharsetRange }; 44 | parser.Parser.Literal = { module: Literal }; 45 | parser.Parser.Escape = { module: Escape }; 46 | parser.Parser.AnyCharacter = { module: AnyCharacter }; 47 | parser.Parser.Repeat = { module: Repeat }; 48 | parser.Parser.RepeatAny = { module: RepeatAny }; 49 | parser.Parser.RepeatOptional = { module: RepeatOptional }; 50 | parser.Parser.RepeatRequired = { module: RepeatRequired }; 51 | parser.Parser.RepeatSpec = { module: RepeatSpec }; 52 | 53 | export default parser; 54 | -------------------------------------------------------------------------------- /src/js/parser/javascript/escape.js: -------------------------------------------------------------------------------- 1 | // Escape nodes are used for escape sequences. It is rendered as a label with 2 | // the description of the escape and the numeric code it matches when 3 | // appropriate. 4 | 5 | import _ from 'lodash'; 6 | 7 | function hex(value) { 8 | var str = value.toString(16).toUpperCase(); 9 | 10 | if (str.length < 2) { 11 | str = '0' + str; 12 | } 13 | 14 | return `(0x${str})`; 15 | } 16 | 17 | export default { 18 | type: 'escape', 19 | 20 | // Renders the escape into the currently set container. 21 | _render() { 22 | return this.renderLabel(this.label) 23 | .then(label => { 24 | label.select('rect').attr({ 25 | rx: 3, 26 | ry: 3 27 | }); 28 | return label; 29 | }); 30 | }, 31 | 32 | setup() { 33 | let addHex; 34 | 35 | // The escape code. For an escape such as `\b` it would be "b". 36 | this.code = this.properties.esc.properties.code.textValue; 37 | // The argument. For an escape such as `\xab` it would be "ab". 38 | this.arg = this.properties.esc.properties.arg.textValue; 39 | // Retrieves the label, ordinal value, an flag to control adding hex value 40 | // from the escape code mappings 41 | [this.label, this.ordinal, addHex] = _.result(this, this.code); 42 | 43 | // When requested, add hex code to the label. 44 | if (addHex) { 45 | this.label = `${this.label} ${hex(this.ordinal)}`; 46 | } 47 | }, 48 | 49 | // Escape code mappings 50 | b: ['word boundary', -1, false], 51 | B: ['non-word boundary', -1, false], 52 | d: ['digit', -1, false], 53 | D: ['non-digit', -1, false], 54 | f: ['form feed', 0x0c, true], 55 | n: ['line feed', 0x0a, true], 56 | r: ['carriage return', 0x0d, true], 57 | s: ['white space', -1, false], 58 | S: ['non-white space', -1, false], 59 | t: ['tab', 0x09, true], 60 | v: ['vertical tab', 0x0b, true], 61 | w: ['word', -1, false], 62 | W: ['non-word', -1, false], 63 | 1: ['Back reference (group = 1)', -1, false], 64 | 2: ['Back reference (group = 2)', -1, false], 65 | 3: ['Back reference (group = 3)', -1, false], 66 | 4: ['Back reference (group = 4)', -1, false], 67 | 5: ['Back reference (group = 5)', -1, false], 68 | 6: ['Back reference (group = 6)', -1, false], 69 | 7: ['Back reference (group = 7)', -1, false], 70 | 8: ['Back reference (group = 8)', -1, false], 71 | 9: ['Back reference (group = 9)', -1, false], 72 | 0: function() { 73 | if (this.arg) { 74 | return [`octal: ${this.arg}`, parseInt(this.arg, 8), true]; 75 | } else { 76 | return ['null', 0, true]; 77 | } 78 | }, 79 | c() { 80 | return [`ctrl-${this.arg.toUpperCase()}`, this.arg.toUpperCase().charCodeAt(0) - 64, true]; 81 | }, 82 | x() { 83 | return [`0x${this.arg.toUpperCase()}`, parseInt(this.arg, 16), false]; 84 | }, 85 | u() { 86 | return [`U+${this.arg.toUpperCase()}`, parseInt(this.arg, 16), false]; 87 | } 88 | }; 89 | -------------------------------------------------------------------------------- /src/js/parser/javascript/charset.js: -------------------------------------------------------------------------------- 1 | // Charset nodes are used for `[abc1-9]` regular expression syntax. It is 2 | // rendered as a labeled box with each literal, escape, and range rendering 3 | // handled by the nested node(s). 4 | 5 | import util from '../../util.js'; 6 | import _ from 'lodash'; 7 | 8 | export default { 9 | type: 'charset', 10 | 11 | definedProperties: { 12 | // Default anchor is overridden to move it down so that it connects at the 13 | // middle of the box that wraps all of the charset parts, instead of the 14 | // middle of the container, which would take the label into account. 15 | _anchor: { 16 | get: function() { 17 | var matrix = this.transform().localMatrix; 18 | 19 | return { 20 | ay: matrix.y(0, this.partContainer.getBBox().cy) 21 | }; 22 | } 23 | } 24 | }, 25 | 26 | // Renders the charset into the currently set container. 27 | _render() { 28 | this.partContainer = this.container.group(); 29 | 30 | // Renders each part of the charset into the part container. 31 | return Promise.all(_.map(this.elements, 32 | part => part.render(this.partContainer.group()) 33 | )) 34 | .then(() => { 35 | // Space the parts of the charset vertically in the part container. 36 | util.spaceVertically(this.elements, { 37 | padding: 5 38 | }); 39 | 40 | // Label the part container. 41 | return this.renderLabeledBox(this.label, this.partContainer, { 42 | padding: 5 43 | }); 44 | }); 45 | }, 46 | 47 | setup() { 48 | // The label for the charset will be: 49 | // - "One of:" for charsets of the form: `[abc]`. 50 | // - "None of:" for charsets of the form: `[^abc]`. 51 | this.label = (this.properties.invert.textValue === '^') ? 'None of:' : 'One of:'; 52 | 53 | // Removes any duplicate parts from the charset. This is based on the type 54 | // and text value of the part, so `[aa]` will have only one item, but 55 | // `[a\x61]` will contain two since the first matches "a" and the second 56 | // matches 0x61 (even though both are an "a"). 57 | this.elements = _.uniqBy(this.properties.parts.elements, 58 | part => `${part.type}:${part.textValue}`); 59 | 60 | // Include a warning for charsets that attempt to match `\c` followed by 61 | // any character other than A-Z (case insensitive). Charsets like `[\c@]` 62 | // behave differently in different browsers. Some match the character 63 | // reference by the control charater escape, others match "\", "c", or "@", 64 | // and some do not appear to match anything. 65 | if (this.textValue.match(/\\c[^a-zA-Z]/)) { 66 | this.state.warnings.push(`The character set "${this.textValue}" contains the \\c escape followed by a character other than A-Z. This can lead to different behavior depending on browser. The representation here is the most common interpretation.`); 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /spec/parser/javascript/escape_spec.js: -------------------------------------------------------------------------------- 1 | import javascript from '../../../src/js/parser/javascript/parser.js'; 2 | import _ from 'lodash'; 3 | import Snap from 'snapsvg'; 4 | 5 | describe('parser/javascript/escape.js', function() { 6 | 7 | _.forIn({ 8 | '\\b': { label: 'word boundary', ordinal: -1 }, 9 | '\\B': { label: 'non-word boundary', ordinal: -1 }, 10 | '\\d': { label: 'digit', ordinal: -1 }, 11 | '\\D': { label: 'non-digit', ordinal: -1 }, 12 | '\\f': { label: 'form feed (0x0C)', ordinal: 0x0c }, 13 | '\\n': { label: 'line feed (0x0A)', ordinal: 0x0a }, 14 | '\\r': { label: 'carriage return (0x0D)', ordinal: 0x0d }, 15 | '\\s': { label: 'white space', ordinal: -1 }, 16 | '\\S': { label: 'non-white space', ordinal: -1 }, 17 | '\\t': { label: 'tab (0x09)', ordinal: 0x09 }, 18 | '\\v': { label: 'vertical tab (0x0B)', ordinal: 0x0b }, 19 | '\\w': { label: 'word', ordinal: -1 }, 20 | '\\W': { label: 'non-word', ordinal: -1 }, 21 | '\\0': { label: 'null (0x00)', ordinal: 0 }, 22 | '\\1': { label: 'Back reference (group = 1)', ordinal: -1 }, 23 | '\\2': { label: 'Back reference (group = 2)', ordinal: -1 }, 24 | '\\3': { label: 'Back reference (group = 3)', ordinal: -1 }, 25 | '\\4': { label: 'Back reference (group = 4)', ordinal: -1 }, 26 | '\\5': { label: 'Back reference (group = 5)', ordinal: -1 }, 27 | '\\6': { label: 'Back reference (group = 6)', ordinal: -1 }, 28 | '\\7': { label: 'Back reference (group = 7)', ordinal: -1 }, 29 | '\\8': { label: 'Back reference (group = 8)', ordinal: -1 }, 30 | '\\9': { label: 'Back reference (group = 9)', ordinal: -1 }, 31 | '\\012': { label: 'octal: 12 (0x0A)', ordinal: 10 }, 32 | '\\cx': { label: 'ctrl-X (0x18)', ordinal: 24 }, 33 | '\\xab': { label: '0xAB', ordinal: 0xab }, 34 | '\\uabcd': { label: 'U+ABCD', ordinal: 0xabcd } 35 | }, (content, str) => { 36 | it(`parses "${str}" as an Escape`, function() { 37 | var parser = new javascript.Parser(str); 38 | expect(parser.__consume__terminal()).toEqual(jasmine.objectContaining(content)); 39 | }); 40 | }); 41 | 42 | describe('#_render', function() { 43 | 44 | beforeEach(function() { 45 | var parser = new javascript.Parser('\\b'); 46 | this.node = parser.__consume__terminal(); 47 | this.node.state = {}; 48 | 49 | this.svg = Snap(document.createElement('svg')); 50 | this.node.container = this.svg.group(); 51 | spyOn(this.node, 'renderLabel').and.callThrough(); 52 | }); 53 | 54 | it('renders a label', function() { 55 | this.node._render(); 56 | expect(this.node.renderLabel).toHaveBeenCalledWith('word boundary'); 57 | }); 58 | 59 | it('sets the edge radius of the rect', function(done) { 60 | this.node._render() 61 | .then(label => { 62 | expect(label.select('rect').attr()).toEqual(jasmine.objectContaining({ 63 | rx: '3', 64 | ry: '3' 65 | })); 66 | done(); 67 | }); 68 | }); 69 | 70 | }); 71 | 72 | }); 73 | -------------------------------------------------------------------------------- /src/js/parser/javascript/match_fragment.js: -------------------------------------------------------------------------------- 1 | // MatchFragment nodes are part of a [Match](./match.html) followed by an 2 | // optional [Repeat](./repeat.html) node. If no repeat is applied, then 3 | // rendering is proxied to the content node. 4 | 5 | import _ from 'lodash'; 6 | 7 | export default { 8 | type: 'match-fragment', 9 | 10 | definedProperties: { 11 | // Default anchor is overridden to apply an transforms from the fragment 12 | // to its content's anchor. Essentially, the fragment inherits the anchor 13 | // of its content. 14 | _anchor: { 15 | get: function() { 16 | var anchor = this.content.getBBox(), 17 | matrix = this.transform().localMatrix; 18 | 19 | return { 20 | ax: matrix.x(anchor.ax, anchor.ay), 21 | ax2: matrix.x(anchor.ax2, anchor.ay), 22 | ay: matrix.y(anchor.ax, anchor.ay) 23 | }; 24 | } 25 | } 26 | }, 27 | 28 | // Renders the fragment into the currently set container. 29 | _render() { 30 | return this.content.render(this.container.group()) 31 | .then(() => { 32 | let box, paths; 33 | 34 | // Contents must be transformed based on the repeat that is applied. 35 | this.content.transform(this.repeat.contentPosition); 36 | 37 | box = this.content.getBBox(); 38 | 39 | // Add skip or repeat paths to the container. 40 | paths = _.flatten([ 41 | this.repeat.skipPath(box), 42 | this.repeat.loopPath(box) 43 | ]); 44 | 45 | this.container.prepend( 46 | this.container.path(paths.join(''))); 47 | 48 | this.loopLabel(); 49 | }); 50 | }, 51 | 52 | // Renders label for the loop path indicating how many times the content may 53 | // be matched. 54 | loopLabel() { 55 | let labelStr = this.repeat.label, 56 | tooltipStr = this.repeat.tooltip; 57 | 58 | if (labelStr) { 59 | let label = this.container.text(0, 0, [labelStr]) 60 | .addClass('repeat-label'), 61 | labelBox = label.getBBox(), 62 | box = this.getBBox(); 63 | 64 | if (tooltipStr) { 65 | let tooltip = this.container.el('title') 66 | .append(this.container.text(0, 0, tooltipStr)); 67 | label.append(tooltip); 68 | } 69 | 70 | label.transform(Snap.matrix().translate( 71 | box.x2 - labelBox.width - (this.repeat.hasSkip ? 5 : 0), 72 | box.y2 + labelBox.height)); 73 | } 74 | }, 75 | 76 | setup() { 77 | // Then content of the fragment. 78 | this.content = this.properties.content; 79 | // The repetition rule for the fragment. 80 | this.repeat = this.properties.repeat; 81 | 82 | if (!this.repeat.hasLoop && !this.repeat.hasSkip) { 83 | // For fragments without a skip or loop, rendering is proxied to the 84 | // content. Also set flag indicating that contents can be merged if the 85 | // content is a literal node. 86 | this.canMerge = (this.content.type === 'literal'); 87 | this.proxy = this.content; 88 | } else { 89 | // Fragments that have skip or loop lines cannot be merged with others. 90 | this.canMerge = false; 91 | } 92 | } 93 | }; 94 | -------------------------------------------------------------------------------- /spec/util_spec.js: -------------------------------------------------------------------------------- 1 | import util from '../src/js/util.js'; 2 | 3 | describe('util.js', function() { 4 | 5 | describe('customEvent', function() { 6 | 7 | it('sets the event type', function() { 8 | var event = util.customEvent('example'); 9 | expect(event.type).toEqual('example'); 10 | }); 11 | 12 | it('sets the event detail', function() { 13 | var event = util.customEvent('example', 'detail'); 14 | expect(event.detail).toEqual('detail'); 15 | }); 16 | 17 | }); 18 | 19 | describe('normalizeBBox', function() { 20 | 21 | it('defaults the anchor keys to values from the bbox', function() { 22 | expect(util.normalizeBBox({ 23 | x: 'bbox x', 24 | x2: 'bbox x2', 25 | cy: 'bbox cy', 26 | ay: 'bbox ay' 27 | })).toEqual({ 28 | x: 'bbox x', 29 | x2: 'bbox x2', 30 | cy: 'bbox cy', 31 | ax: 'bbox x', 32 | ax2: 'bbox x2', 33 | ay: 'bbox ay' 34 | }); 35 | }); 36 | 37 | }); 38 | 39 | describe('spaceHorizontally', function() { 40 | 41 | it('positions each item', function() { 42 | var svg = Snap(document.createElement('svg')), 43 | items = [ 44 | svg.group(), 45 | svg.group(), 46 | svg.group() 47 | ]; 48 | 49 | spyOn(items[0], 'getBBox').and.returnValue({ ay: 5, width: 10 }); 50 | spyOn(items[1], 'getBBox').and.returnValue({ ay: 15, width: 30 }); 51 | spyOn(items[2], 'getBBox').and.returnValue({ ay: 10, width: 20 }); 52 | spyOn(items[0], 'transform').and.callThrough(); 53 | spyOn(items[1], 'transform').and.callThrough(); 54 | spyOn(items[2], 'transform').and.callThrough(); 55 | 56 | util.spaceHorizontally(items, { padding: 5 }); 57 | 58 | expect(items[0].transform).toHaveBeenCalledWith(Snap.matrix() 59 | .translate(0, 10)); 60 | expect(items[1].transform).toHaveBeenCalledWith(Snap.matrix() 61 | .translate(15, 0)); 62 | expect(items[2].transform).toHaveBeenCalledWith(Snap.matrix() 63 | .translate(50, 5)); 64 | }); 65 | 66 | }); 67 | 68 | describe('spaceVertically', function() { 69 | 70 | it('positions each item', function() { 71 | var svg = Snap(document.createElement('svg')), 72 | items = [ 73 | svg.group(), 74 | svg.group(), 75 | svg.group() 76 | ]; 77 | 78 | spyOn(items[0], 'getBBox').and.returnValue({ cx: 5, height: 10 }); 79 | spyOn(items[1], 'getBBox').and.returnValue({ cx: 15, height: 30 }); 80 | spyOn(items[2], 'getBBox').and.returnValue({ cx: 10, height: 20 }); 81 | spyOn(items[0], 'transform').and.callThrough(); 82 | spyOn(items[1], 'transform').and.callThrough(); 83 | spyOn(items[2], 'transform').and.callThrough(); 84 | 85 | util.spaceVertically(items, { padding: 5 }); 86 | 87 | expect(items[0].transform).toHaveBeenCalledWith(Snap.matrix() 88 | .translate(10, 0)); 89 | expect(items[1].transform).toHaveBeenCalledWith(Snap.matrix() 90 | .translate(0, 15)); 91 | expect(items[2].transform).toHaveBeenCalledWith(Snap.matrix() 92 | .translate(5, 50)); 93 | }); 94 | 95 | }); 96 | 97 | }); 98 | -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | const gulp = require('gulp-help')(require('gulp')), 2 | _ = require('lodash'), 3 | notify = require('gulp-notify'), 4 | folderToc = require('folder-toc'), 5 | docco = require('gulp-docco'), 6 | connect = require('gulp-connect'), 7 | hb = require('gulp-hb'), 8 | frontMatter = require('gulp-front-matter'), 9 | rename = require('gulp-rename'), 10 | config = require('./config'), 11 | gutil = require('gulp-util'), 12 | webpack = require('webpack') 13 | webpackConfig = require('./webpack.config'), 14 | fs = require('fs'); 15 | 16 | gulp.task('default', 'Auto-rebuild site on changes.', ['server', 'docs'], function() { 17 | gulp.watch(config.globs.other, ['static']); 18 | gulp.watch(_.flatten([ 19 | config.globs.templates, 20 | config.globs.data, 21 | config.globs.helpers, 22 | config.globs.partials, 23 | config.globs.svg_sass 24 | ]), ['markup']); 25 | gulp.watch(_.flatten([ 26 | config.globs.sass, 27 | config.globs.js 28 | ]), ['webpack']); 29 | gulp.watch(config.globs.js, ['docs']); 30 | }); 31 | 32 | gulp.task('docs', 'Build documentation into ./docs directory.', ['docs:files'], function() { 33 | folderToc('./docs', { 34 | filter: '*.html' 35 | }); 36 | }); 37 | 38 | gulp.task('docs:files', false, function() { 39 | return gulp.src(config.globs.js) 40 | .pipe(docco()) 41 | .pipe(gulp.dest('./docs')); 42 | }); 43 | 44 | gulp.task('server', 'Start development server.', ['build'], function() { 45 | gulp.watch(config.buildPath('**/*'), function(file) { 46 | return gulp.src(file.path).pipe(connect.reload()); 47 | }); 48 | 49 | return connect.server({ 50 | root: config.buildRoot, 51 | livereload: true 52 | }); 53 | }); 54 | 55 | gulp.task('build', 'Build site into ./build directory.', ['static', 'webpack', 'markup']); 56 | 57 | gulp.task('static', 'Build static files into ./build directory.', function() { 58 | return gulp.src(config.globs.other, { base: './src' }) 59 | .pipe(gulp.dest(config.buildRoot)); 60 | }); 61 | 62 | gulp.task('markup', 'Build markup into ./build directory.', ['webpack'], function() { 63 | var hbStream = hb({ 64 | data: config.globs.data, 65 | helpers: config.globs.helpers, 66 | partials: config.globs.partials, 67 | parsePartialName: function(option, file) { 68 | return _.last(file.path.split(/\\|\//)).replace('.hbs', ''); 69 | }, 70 | bustCache: true 71 | }); 72 | hbStream.partials({ 73 | svg_styles: fs.readFileSync(config.buildRoot + '/css/svg.css').toString() 74 | }); 75 | if (process.env.GA_PROP) { 76 | hbStream.data({ 77 | 'gaPropertyId': process.env.GA_PROP 78 | }); 79 | } 80 | if (process.env.SENTRY_KEY) { 81 | hbStream.data({ 82 | 'sentryKey': process.env.SENTRY_KEY 83 | }); 84 | } 85 | return gulp.src(config.globs.templates) 86 | .pipe(frontMatter()) 87 | .pipe(hbStream) 88 | .on('error', notify.onError()) 89 | .pipe(rename({ extname: '.html' })) 90 | .pipe(gulp.dest(config.buildRoot)); 91 | }); 92 | 93 | gulp.task('webpack', 'Build JS & CSS into ./build directory.', function(callback) { 94 | webpack(webpackConfig, function(err, stats) { 95 | if (err) { 96 | throw new gutil.PluginError('webpack', err); 97 | } 98 | gutil.log('[webpack]', stats.toString()); 99 | callback(); 100 | }); 101 | }); 102 | -------------------------------------------------------------------------------- /spec/parser/javascript/charset_range_spec.js: -------------------------------------------------------------------------------- 1 | import javascript from '../../../src/js/parser/javascript/parser.js'; 2 | import util from '../../../src/js/util.js'; 3 | import _ from 'lodash'; 4 | 5 | describe('parser/javascript/charset_range.js', function() { 6 | 7 | _.forIn({ 8 | 'a-z': { 9 | first: jasmine.objectContaining({ textValue: 'a' }), 10 | last: jasmine.objectContaining({ textValue: 'z' }) 11 | }, 12 | '\\b-z': { 13 | first: jasmine.objectContaining({ textValue: '\\b' }), 14 | last: jasmine.objectContaining({ textValue: 'z' }) 15 | }, 16 | '\\f-z': { 17 | first: jasmine.objectContaining({ textValue: '\\f' }), 18 | last: jasmine.objectContaining({ textValue: 'z' }) 19 | }, 20 | '\\n-z': { 21 | first: jasmine.objectContaining({ textValue: '\\n' }), 22 | last: jasmine.objectContaining({ textValue: 'z' }) 23 | }, 24 | '\\r-z': { 25 | first: jasmine.objectContaining({ textValue: '\\r' }), 26 | last: jasmine.objectContaining({ textValue: 'z' }) 27 | }, 28 | '\\t-z': { 29 | first: jasmine.objectContaining({ textValue: '\\t' }), 30 | last: jasmine.objectContaining({ textValue: 'z' }) 31 | }, 32 | '\\v-z': { 33 | first: jasmine.objectContaining({ textValue: '\\v' }), 34 | last: jasmine.objectContaining({ textValue: 'z' }) 35 | } 36 | }, (content, str) => { 37 | it(`parses "${str}" as a CharsetRange`, function() { 38 | var parser = new javascript.Parser(str); 39 | expect(parser.__consume__charset_range()).toEqual(jasmine.objectContaining(content)); 40 | }); 41 | }); 42 | 43 | _.each([ 44 | '\\d-a', 45 | '\\D-a', 46 | '\\s-a', 47 | '\\S-a', 48 | '\\w-a', 49 | '\\W-a' 50 | ], str => { 51 | it(`does not parse "${str}" as a CharsetRange`, function() { 52 | var parser = new javascript.Parser(str); 53 | expect(parser.__consume__charset_range()).toEqual(null); 54 | }); 55 | }); 56 | 57 | it('throws an exception when the range is out of order', function() { 58 | var parser = new javascript.Parser('z-a'); 59 | expect(() => { 60 | parser.__consume__charset_range(); 61 | }).toThrow('Range out of order in character class: z-a'); 62 | }); 63 | 64 | describe('#_render', function() { 65 | 66 | beforeEach(function() { 67 | var parser = new javascript.Parser('a-z'); 68 | this.node = parser.__consume__charset_range(); 69 | 70 | this.node.container = jasmine.createSpyObj('cotnainer', ['addClass', 'text', 'group']); 71 | this.node.container.text.and.returnValue('hyphen'); 72 | 73 | this.firstDeferred = this.testablePromise(); 74 | this.lastDeferred = this.testablePromise(); 75 | 76 | spyOn(this.node.first, 'render').and.returnValue(this.firstDeferred.promise); 77 | spyOn(this.node.last, 'render').and.returnValue(this.lastDeferred.promise); 78 | spyOn(util, 'spaceHorizontally'); 79 | }); 80 | 81 | it('renders a hyphen', function() { 82 | this.node._render(); 83 | expect(this.node.container.text).toHaveBeenCalledWith(0, 0, '-'); 84 | }); 85 | 86 | it('spaces the items horizontally', function(done) { 87 | this.firstDeferred.resolve(); 88 | this.lastDeferred.resolve(); 89 | 90 | this.node._render() 91 | .then(() => { 92 | expect(util.spaceHorizontally).toHaveBeenCalledWith([ 93 | this.node.first, 94 | 'hyphen', 95 | this.node.last 96 | ], { padding: 5 }); 97 | done(); 98 | }); 99 | }); 100 | 101 | }); 102 | 103 | }); 104 | -------------------------------------------------------------------------------- /src/js/parser/javascript/match.js: -------------------------------------------------------------------------------- 1 | // Match nodes are used for the parts of a regular expression between `|` 2 | // symbols. They consist of a series of [MatchFragment](./match_fragment.html) 3 | // nodes. Optional `^` and `$` symbols are also allowed at the beginning and 4 | // end of the Match. 5 | 6 | import util from '../../util.js'; 7 | import _ from 'lodash'; 8 | 9 | export default { 10 | type: 'match', 11 | 12 | definedProperties: { 13 | // Default anchor is overridden to attach the left point of the anchor to 14 | // the first element, and the right point to the last element. 15 | _anchor: { 16 | get: function() { 17 | var start = util.normalizeBBox(this.start.getBBox()), 18 | end = util.normalizeBBox(this.end.getBBox()), 19 | matrix = this.transform().localMatrix; 20 | 21 | return { 22 | ax: matrix.x(start.ax, start.ay), 23 | ax2: matrix.x(end.ax2, end.ay), 24 | ay: matrix.y(start.ax, start.ay) 25 | }; 26 | } 27 | } 28 | }, 29 | 30 | // Renders the match into the currently set container. 31 | _render() { 32 | // Render each of the match fragments. 33 | let partPromises = _.map(this.parts, part => part.render(this.container.group())), 34 | items = _(partPromises).compact().value(); 35 | 36 | // Handle the situation where a regular expression of `()` is rendered. 37 | // This leads to a Match node with no fragments. Something must be rendered 38 | // so that the anchor can be calculated based on it. 39 | // 40 | // Furthermore, the content rendered must have height and width or else the 41 | // anchor calculations fail. 42 | if (items.length === 0) { 43 | items = [this.container.group().path('M0,0h10')]; 44 | } 45 | 46 | return Promise.all(items) 47 | .then(items => { 48 | // Find SVG elements to be used when calculating the anchor. 49 | this.start = _.first(items); 50 | this.end = _.last(items); 51 | 52 | util.spaceHorizontally(items, { 53 | padding: 10 54 | }); 55 | 56 | // Add lines between each item. 57 | this.container.prepend( 58 | this.container.path(this.connectorPaths(items).join(''))); 59 | }); 60 | }, 61 | 62 | // Returns an array of SVG path strings between each item. 63 | // - __items__ - Array of SVG elements or nodes. 64 | connectorPaths(items) { 65 | let prev, next; 66 | 67 | prev = util.normalizeBBox(_.first(items).getBBox()); 68 | return _.map(items.slice(1), item => { 69 | try { 70 | next = util.normalizeBBox(item.getBBox()); 71 | return `M${prev.ax2},${prev.ay}H${next.ax}`; 72 | } 73 | finally { 74 | prev = next; 75 | } 76 | }); 77 | }, 78 | 79 | setup() { 80 | // Merged list of MatchFragments to be rendered. 81 | this.parts = _.reduce(this.properties.parts.elements, function(result, node) { 82 | var last = _.last(result); 83 | 84 | if (last && node.canMerge && last.canMerge) { 85 | // Merged the content of `node` into `last` when possible. This also 86 | // discards `node` in the process since `result` has not been changed. 87 | last.content.merge(node.content); 88 | } else { 89 | // `node` cannot be merged with the previous node, so it is added to 90 | // the list of parts. 91 | result.push(node); 92 | } 93 | 94 | return result; 95 | }, []); 96 | 97 | // When there is only one part, then proxy to the part. 98 | if (this.parts.length === 1) { 99 | this.proxy = this.parts[0]; 100 | } 101 | } 102 | }; 103 | -------------------------------------------------------------------------------- /spec/parser/javascript/subexp_spec.js: -------------------------------------------------------------------------------- 1 | import javascript from '../../../src/js/parser/javascript/parser.js'; 2 | import Node from '../../../src/js/parser/javascript/node.js'; 3 | import _ from 'lodash'; 4 | import Snap from 'snapsvg'; 5 | 6 | describe('parser/javascript/subexp.js', function() { 7 | 8 | beforeEach(function() { 9 | Node.state = { groupCounter: 1 }; 10 | }); 11 | 12 | _.forIn({ 13 | '(test)': { 14 | regexp: jasmine.objectContaining({ textValue: 'test' }) 15 | }, 16 | '(?=test)': { 17 | regexp: jasmine.objectContaining({ textValue: 'test' }) 18 | }, 19 | '(?!test)': { 20 | regexp: jasmine.objectContaining({ textValue: 'test' }) 21 | }, 22 | '(?:test)': { 23 | regexp: jasmine.objectContaining({ textValue: 'test' }), 24 | proxy: jasmine.objectContaining({ textValue: 'test' }) 25 | } 26 | }, (content, str) => { 27 | it(`parses "${str}" as a Subexp`, function() { 28 | var parser = new javascript.Parser(str); 29 | expect(parser.__consume__subexp()).toEqual(jasmine.objectContaining(content)); 30 | }); 31 | }); 32 | 33 | describe('_anchor property', function() { 34 | 35 | it('applies the local transform matrix to the anchor from the regexp', function() { 36 | var node = new javascript.Parser('(test)').__consume__subexp(); 37 | 38 | node.regexp = { 39 | getBBox() { 40 | return { 41 | ax: 10, 42 | ax2: 15, 43 | ay: 20 44 | }; 45 | } 46 | }; 47 | 48 | spyOn(node, 'transform').and.returnValue({ 49 | localMatrix: Snap.matrix().translate(3, 8) 50 | }); 51 | 52 | expect(node._anchor).toEqual({ 53 | ax: 13, 54 | ax2: 18, 55 | ay: 28 56 | }); 57 | }); 58 | 59 | }); 60 | 61 | describe('#_render', function() { 62 | 63 | beforeEach(function() { 64 | this.renderDeferred = this.testablePromise(); 65 | 66 | this.node = new javascript.Parser('(test)').__consume__subexp(); 67 | this.node.regexp = jasmine.createSpyObj('regexp', ['render']); 68 | this.node.container = jasmine.createSpyObj('container', ['addClass', 'group']); 69 | spyOn(this.node, 'label').and.returnValue('example label') 70 | 71 | this.node.regexp.render.and.returnValue(this.renderDeferred.promise); 72 | }); 73 | 74 | it('renders the regexp', function() { 75 | this.node._render(); 76 | expect(this.node.regexp.render).toHaveBeenCalled(); 77 | }); 78 | 79 | it('renders a labeled box', function(done) { 80 | spyOn(this.node, 'renderLabeledBox'); 81 | this.renderDeferred.resolve(); 82 | this.node._render() 83 | .then(() => { 84 | expect(this.node.renderLabeledBox).toHaveBeenCalledWith('example label', this.node.regexp, { padding: 10 }); 85 | done(); 86 | }); 87 | }); 88 | 89 | }); 90 | 91 | describe('#label', function() { 92 | 93 | _.forIn({ 94 | '(test)': { 95 | label: 'group #1', 96 | groupCounter: 2 97 | }, 98 | '(?=test)': { 99 | label: 'positive lookahead', 100 | groupCounter: 1 101 | }, 102 | '(?!test)': { 103 | label: 'negative lookahead', 104 | groupCounter: 1 105 | }, 106 | '(?:test)': { 107 | label: '', 108 | groupCounter: 1 109 | } 110 | }, (data, str) => { 111 | it(`generates the correct label for "${str}"`, function() { 112 | var node = new javascript.Parser(str).__consume__subexp(); 113 | expect(node.label()).toEqual(data.label); 114 | expect(node.state.groupCounter).toEqual(data.groupCounter); 115 | }); 116 | }); 117 | 118 | }); 119 | 120 | }); 121 | -------------------------------------------------------------------------------- /src/js/parser/javascript.js: -------------------------------------------------------------------------------- 1 | // Entry point for the JavaScript-flavor regular expression parsing and 2 | // rendering. Actual parsing code is in 3 | // [parser.js](./javascript/parser.html) and the grammar file. Rendering code 4 | // is contained in the various subclasses of 5 | // [Node](./javascript/node.html) 6 | 7 | import Snap from 'snapsvg'; 8 | import _ from 'lodash'; 9 | 10 | import util from '../util.js'; 11 | import javascript from './javascript/parser.js'; 12 | import ParserState from './javascript/parser_state.js'; 13 | 14 | export default class Parser { 15 | // - __container__ - DOM node that will contain the rendered expression 16 | // - __options.keepContent__ - Boolean indicating if content of the container 17 | // should be preserved after rendering. Defaults to false (don't keep 18 | // contents) 19 | constructor(container, options) { 20 | this.options = options || {}; 21 | _.defaults(this.options, { 22 | keepContent: false 23 | }); 24 | 25 | this.container = container; 26 | 27 | // The [ParserState](./javascript/parser_state.html) instance is used to 28 | // communicate between the parser and a running render, and to update the 29 | // progress bar for the running render. 30 | this.state = new ParserState(this.container.querySelector('.progress div')); 31 | } 32 | 33 | // DOM node that will contain the rendered expression. Setting this will add 34 | // the base markup necessary for rendering the expression, and set the 35 | // `svg-container` class 36 | set container(cont) { 37 | this._container = cont; 38 | this._container.innerHTML = [ 39 | document.querySelector('#svg-container-base').innerHTML, 40 | this.options.keepContent ? this.container.innerHTML : '' 41 | ].join(''); 42 | this._addClass('svg-container'); 43 | } 44 | 45 | get container() { 46 | return this._container; 47 | } 48 | 49 | // Helper method to simplify adding classes to the container. 50 | _addClass(className) { 51 | this.container.className = _(this.container.className.split(' ')) 52 | .union([className]) 53 | .join(' '); 54 | } 55 | 56 | // Helper method to simplify removing classes from the container. 57 | _removeClass(className) { 58 | this.container.className = _(this.container.className.split(' ')) 59 | .without(className) 60 | .join(' '); 61 | } 62 | 63 | // Parse a regular expression into a tree of 64 | // [Nodes](./javascript/node.html) that can then be used to render an SVG. 65 | // - __expression__ - Regular expression to parse. 66 | parse(expression) { 67 | this._addClass('loading'); 68 | 69 | // Allow the browser to repaint before parsing so that the loading bar is 70 | // displayed before the (possibly lengthy) parsing begins. 71 | return util.tick().then(() => { 72 | javascript.Parser.SyntaxNode.state = this.state; 73 | 74 | this.parsed = javascript.parse(expression.replace(/\n/g, '\\n')); 75 | return this; 76 | }); 77 | } 78 | 79 | // Render the parsed expression to an SVG. 80 | render() { 81 | let svg = Snap(this.container.querySelector('svg')); 82 | 83 | return this.parsed.render(svg.group()) 84 | // Once rendering is complete, the rendered expression is positioned and 85 | // the SVG resized to create some padding around the image contents. 86 | .then(result => { 87 | let box = result.getBBox(); 88 | 89 | result.transform(Snap.matrix() 90 | .translate(10 - box.x, 10 - box.y)); 91 | svg.attr({ 92 | width: box.width + 20, 93 | height: box.height + 20 94 | }); 95 | }) 96 | // Stop and remove loading indicator after render is totally complete. 97 | .then(() => { 98 | this._removeClass('loading'); 99 | this.container.removeChild(this.container.querySelector('.progress')); 100 | }); 101 | } 102 | 103 | // Cancels any currently in-progress render. 104 | cancel() { 105 | this.state.cancelRender = true; 106 | } 107 | 108 | // Returns any warnings that may have been set during the rendering process. 109 | get warnings() { 110 | return this.state.warnings; 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/js/parser/javascript/repeat.js: -------------------------------------------------------------------------------- 1 | // Repeat nodes are for the various repetition syntaxes (`a*`, `a+`, `a?`, and 2 | // `a{1,3}`). It is not rendered directly, but contains data used for the 3 | // rendering of [MatchFragment](./match_fragment.html) nodes. 4 | 5 | function formatTimes(times) { 6 | if (times === 1) { 7 | return 'once'; 8 | } else { 9 | return `${times} times`; 10 | } 11 | } 12 | 13 | export default { 14 | definedProperties: { 15 | // Translation to apply to content to be repeated to account for the loop 16 | // and skip lines. 17 | contentPosition: { 18 | get: function() { 19 | var matrix = Snap.matrix(); 20 | 21 | if (this.hasSkip) { 22 | return matrix.translate(15, 10); 23 | } else if (this.hasLoop) { 24 | return matrix.translate(10, 0); 25 | } else { 26 | return matrix.translate(0, 0); 27 | } 28 | } 29 | }, 30 | 31 | // Label to place of loop path to indicate the number of times that path 32 | // may be followed. 33 | label: { 34 | get: function() { 35 | if (this.minimum === this.maximum) { 36 | if (this.minimum === 0) { 37 | return undefined; 38 | } 39 | return formatTimes(this.minimum - 1); 40 | } else if (this.minimum <= 1 && this.maximum >= 2) { 41 | return `at most ${formatTimes(this.maximum - 1)}`; 42 | } else if (this.minimum >= 2) { 43 | if (this.maximum === -1) { 44 | return `${this.minimum - 1}+ times`; 45 | } else { 46 | return `${this.minimum - 1}\u2026${formatTimes(this.maximum - 1)}`; 47 | } 48 | } 49 | } 50 | }, 51 | 52 | // Tooltip to place of loop path label to provide further details. 53 | tooltip: { 54 | get: function() { 55 | let repeatCount; 56 | if (this.minimum === this.maximum) { 57 | if (this.minimum === 0) { 58 | repeatCount = undefined; 59 | } else { 60 | repeatCount = formatTimes(this.minimum); 61 | } 62 | } else if (this.minimum <= 1 && this.maximum >= 2) { 63 | repeatCount = `at most ${formatTimes(this.maximum)}`; 64 | } else if (this.minimum >= 2) { 65 | if (this.maximum === -1) { 66 | repeatCount = `${this.minimum}+ times`; 67 | } else { 68 | repeatCount = `${this.minimum}\u2026${formatTimes(this.maximum)}`; 69 | } 70 | } 71 | return repeatCount ? `repeats ${repeatCount} in total` : repeatCount; 72 | } 73 | } 74 | }, 75 | 76 | // Returns the path spec to render the line that skips over the content for 77 | // fragments that are optionally matched. 78 | skipPath(box) { 79 | let paths = []; 80 | 81 | if (this.hasSkip) { 82 | let vert = Math.max(0, box.ay - box.y - 10), 83 | horiz = box.width - 10; 84 | 85 | paths.push(`M0,${box.ay}q10,0 10,-10v${-vert}q0,-10 10,-10h${horiz}q10,0 10,10v${vert}q0,10 10,10`); 86 | 87 | // When the repeat is not greedy, the skip path gets a preference arrow. 88 | if (!this.greedy) { 89 | paths.push(`M10,${box.ay - 15}l5,5m-5,-5l-5,5`); 90 | } 91 | } 92 | 93 | return paths; 94 | }, 95 | 96 | // Returns the path spec to render the line that repeats the content for 97 | // fragments that are matched more than once. 98 | loopPath(box) { 99 | let paths = []; 100 | 101 | if (this.hasLoop) { 102 | let vert = box.y2 - box.ay - 10; 103 | 104 | paths.push(`M${box.x},${box.ay}q-10,0 -10,10v${vert}q0,10 10,10h${box.width}q10,0 10,-10v${-vert}q0,-10 -10,-10`); 105 | 106 | // When the repeat is greedy, the loop path gets the preference arrow. 107 | if (this.greedy) { 108 | paths.push(`M${box.x2 + 10},${box.ay + 15}l5,-5m-5,5l-5,-5`); 109 | } 110 | } 111 | 112 | return paths; 113 | }, 114 | 115 | setup() { 116 | this.minimum = this.properties.spec.minimum; 117 | this.maximum = this.properties.spec.maximum; 118 | this.greedy = (this.properties.greedy.textValue === ''); 119 | this.hasSkip = (this.minimum === 0); 120 | this.hasLoop = (this.maximum === -1 || this.maximum > 1); 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/js/util.js: -------------------------------------------------------------------------------- 1 | // Utility functions used elsewhere in the codebase. Most JavaScript files on 2 | // the site use some functions defined in this file. 3 | 4 | import _ from 'lodash'; 5 | 6 | // Generate an `Event` object for triggering a custom event. 7 | // 8 | // - __name__ - Name of the custom event. This should be a String. 9 | // - __detail__ - Event details. The event details are provided to the event 10 | // handler. 11 | function customEvent(name, detail) { 12 | var evt = document.createEvent('Event'); 13 | evt.initEvent(name, true, true); 14 | evt.detail = detail; 15 | return evt; 16 | } 17 | 18 | // Add extra fields to a bounding box returned by `getBBox`. Specifically adds 19 | // details about the box's axis points (used when positioning elements for 20 | // display). 21 | // 22 | // - __box__ - Bounding box object to update. Attributes `ax`, `ax2`, and `ay` 23 | // will be added if they are not already defined. 24 | function normalizeBBox(box) { 25 | return _.defaults(box, { 26 | ax: box.x, 27 | ax2: box.x2, 28 | ay: box.cy 29 | }); 30 | } 31 | 32 | // Positions a collection of items with their axis points aligned along a 33 | // horizontal line. This leads to the items being spaced horizontally and 34 | // effectively centered vertically. 35 | // 36 | // - __items__ - Array of items to be positioned 37 | // - __options.padding__ - Number of pixels to leave between items 38 | function spaceHorizontally(items, options) { 39 | var verticalCenter, 40 | values; 41 | 42 | options = _.defaults(options || {}, { 43 | padding: 0 44 | }); 45 | 46 | values = _.map(items, item => ({ 47 | box: normalizeBBox(item.getBBox()), 48 | item 49 | })); 50 | 51 | // Calculate where the axis points should be positioned vertically. 52 | verticalCenter = _.reduce(values, 53 | (center, { box }) => Math.max(center, box.ay), 54 | 0); 55 | 56 | // Position items with padding between them and aligned their axis points. 57 | _.reduce(values, (offset, { item, box }) => { 58 | item.transform(Snap.matrix() 59 | .translate(offset, verticalCenter - box.ay)); 60 | 61 | return offset + options.padding + box.width; 62 | }, 0); 63 | } 64 | 65 | // Positions a collection of items centered horizontally in a vertical stack. 66 | // 67 | // - __items__ - Array of items to be positioned 68 | // - __options.padding__ - Number of pixels to leave between items 69 | function spaceVertically(items, options) { 70 | var horizontalCenter, 71 | values; 72 | 73 | options = _.defaults(options || {}, { 74 | padding: 0 75 | }); 76 | 77 | values = _.map(items, item => ({ 78 | box: item.getBBox(), 79 | item 80 | })); 81 | 82 | // Calculate where the center of each item should be positioned horizontally. 83 | horizontalCenter = _.reduce(values, 84 | (center, { box }) => Math.max(center, box.cx), 85 | 0); 86 | 87 | // Position items with padding between them and align their centers. 88 | _.reduce(values, (offset, { item, box }) => { 89 | item.transform(Snap.matrix() 90 | .translate(horizontalCenter - box.cx, offset)); 91 | 92 | return offset + options.padding + box.height; 93 | }, 0); 94 | } 95 | 96 | // Creates a Promise that will be resolved after a specified delay. 97 | // 98 | // - __delay__ - Time in milliseconds to wait before resolving promise. 99 | function wait(delay) { 100 | return new Promise((resolve, reject) => { 101 | setTimeout(resolve, delay); 102 | }); 103 | } 104 | 105 | // Creates a Promise that will be resolved after 0 milliseconds. This is used 106 | // to create a short delay that allows the browser to address any pending tasks 107 | // while the JavaScript VM is not active. 108 | function tick() { 109 | return wait(0); 110 | } 111 | 112 | // Re-throws an exception asynchronously. This is used to expose an exception 113 | // that was created during a Promise operation to be handled by global error 114 | // handlers (and to be displayed in the browser's debug console). 115 | // 116 | // - __error__ - Error/exception object to be re-thrown to the browser. 117 | function exposeError(error) { 118 | setTimeout(() => { 119 | throw error; 120 | }, 0); 121 | } 122 | 123 | // Renders an SVG icon. 124 | // 125 | // - __selector__ - Selector to the SVG icon to render. 126 | function icon(selector) { 127 | return ``; 128 | } 129 | 130 | // Send tracking data. 131 | function track() { 132 | if (window.ga) { 133 | ga.apply(ga, arguments); 134 | } else { 135 | console.debug.apply(console, arguments); 136 | } 137 | } 138 | 139 | export default { 140 | customEvent, 141 | normalizeBBox, 142 | spaceHorizontally, 143 | spaceVertically, 144 | wait, 145 | tick, 146 | exposeError, 147 | icon, 148 | track 149 | }; 150 | -------------------------------------------------------------------------------- /src/js/parser/javascript/regexp.js: -------------------------------------------------------------------------------- 1 | // Regexp nodes are the entire regular expression. They consist of a collection 2 | // of [Match](./match.html) nodes separated by `|`. 3 | 4 | import util from '../../util.js'; 5 | import _ from 'lodash'; 6 | 7 | export default { 8 | type: 'regexp', 9 | 10 | // Renders the regexp into the currently set container. 11 | _render() { 12 | let matchContainer = this.container.group() 13 | .addClass('regexp-matches') 14 | .transform(Snap.matrix() 15 | .translate(20, 0)); 16 | 17 | // Renders each match into the match container. 18 | return Promise.all(_.map(this.matches, 19 | match => match.render(matchContainer.group()) 20 | )) 21 | .then(() => { 22 | let containerBox, 23 | paths; 24 | 25 | // Space matches vertically in the match container. 26 | util.spaceVertically(this.matches, { 27 | padding: 5 28 | }); 29 | 30 | containerBox = this.getBBox(); 31 | 32 | // Creates the curves from the side lines for each match. 33 | paths = _.map(this.matches, match => this.makeCurve(containerBox, match)); 34 | 35 | // Add side lines to the list of paths. 36 | paths.push(this.makeSide(containerBox, _.first(this.matches))); 37 | paths.push(this.makeSide(containerBox, _.last(this.matches))); 38 | 39 | // Render connector paths. 40 | this.container.prepend( 41 | this.container.path(_(paths).flatten().compact().values().join(''))); 42 | 43 | containerBox = matchContainer.getBBox(); 44 | 45 | // Create connections from side lines to each match and render into 46 | // the match container. 47 | paths = _.map(this.matches, match => this.makeConnector(containerBox, match)); 48 | matchContainer.prepend( 49 | matchContainer.path(paths.join(''))); 50 | }); 51 | }, 52 | 53 | // Returns an array of SVG path strings to draw the vertical lines on the 54 | // left and right of the node. 55 | // 56 | // - __containerBox__ - Bounding box of the container. 57 | // - __match__ - Match node that the line will be drawn to. 58 | makeSide(containerBox, match) { 59 | let box = match.getBBox(), 60 | distance = Math.abs(box.ay - containerBox.cy); 61 | 62 | // Only need to draw side lines if the match is more than 15 pixels from 63 | // the vertical center of the rendered regexp. Less that 15 pixels will be 64 | // handled by the curve directly. 65 | if (distance >= 15) { 66 | let shift = (box.ay > containerBox.cy) ? 10 : -10, 67 | edge = box.ay - shift; 68 | 69 | return [ 70 | `M0,${containerBox.cy}q10,0 10,${shift}V${edge}`, 71 | `M${containerBox.width + 40},${containerBox.cy}q-10,0 -10,${shift}V${edge}` 72 | ]; 73 | } 74 | }, 75 | 76 | // Returns an array of SVG path strings to draw the curves from the 77 | // sidelines up to the anchor of the match node. 78 | // 79 | // - __containerBox__ - Bounding box of the container. 80 | // - __match__ - Match node that the line will be drawn to. 81 | makeCurve(containerBox, match) { 82 | let box = match.getBBox(), 83 | distance = Math.abs(box.ay - containerBox.cy); 84 | 85 | if (distance >= 15) { 86 | // For match nodes more than 15 pixels from the center of the regexp, a 87 | // quarter-circle curve is used to connect to the sideline. 88 | let curve = (box.ay > containerBox.cy) ? 10 : -10; 89 | 90 | return [ 91 | `M10,${box.ay - curve}q0,${curve} 10,${curve}`, 92 | `M${containerBox.width + 30},${box.ay - curve}q0,${curve} -10,${curve}` 93 | ]; 94 | } else { 95 | // For match nodes less than 15 pixels from the center of the regexp, a 96 | // slightly curved line is used to connect to the sideline. 97 | let anchor = box.ay - containerBox.cy; 98 | 99 | return [ 100 | `M0,${containerBox.cy}c10,0 10,${anchor} 20,${anchor}`, 101 | `M${containerBox.width + 40},${containerBox.cy}c-10,0 -10,${anchor} -20,${anchor}` 102 | ]; 103 | } 104 | }, 105 | 106 | // Returns an array of SVG path strings to draw the connection from the 107 | // curve to match node. 108 | // 109 | // - __containerBox__ - Bounding box of the container. 110 | // - __match__ - Match node that the line will be drawn to. 111 | makeConnector(containerBox, match) { 112 | let box = match.getBBox(); 113 | 114 | return `M0,${box.ay}h${box.ax}M${box.ax2},${box.ay}H${containerBox.width}`; 115 | }, 116 | 117 | setup() { 118 | if (this.properties.alternates.elements.length === 0) { 119 | // When there is only one match node to render, proxy to it. 120 | this.proxy = this.properties.match; 121 | } else { 122 | // Merge all the match nodes into one array. 123 | this.matches = [this.properties.match].concat( 124 | _.map(this.properties.alternates.elements, 125 | element => element.properties.match) 126 | ); 127 | } 128 | } 129 | }; 130 | -------------------------------------------------------------------------------- /spec/parser/javascript/root_spec.js: -------------------------------------------------------------------------------- 1 | import javascript from '../../../src/js/parser/javascript/parser.js'; 2 | import Snap from 'snapsvg'; 3 | import _ from 'lodash'; 4 | 5 | describe('parser/javascript/root.js', function() { 6 | 7 | _.forIn({ 8 | 'test': { 9 | flags: [], 10 | regexp: jasmine.objectContaining({ textValue: 'test' }) 11 | }, 12 | '/test/': { 13 | flags: [], 14 | regexp: jasmine.objectContaining({ textValue: 'test' }) 15 | }, 16 | '/test/i': { 17 | flags: ['Ignore Case'], 18 | regexp: jasmine.objectContaining({ textValue: 'test' }) 19 | }, 20 | '/test/g': { 21 | flags: ['Global'], 22 | regexp: jasmine.objectContaining({ textValue: 'test' }) 23 | }, 24 | '/test/m': { 25 | flags: ['Multiline'], 26 | regexp: jasmine.objectContaining({ textValue: 'test' }) 27 | }, 28 | '/test/y': { 29 | flags: ['Sticky'], 30 | regexp: jasmine.objectContaining({ textValue: 'test' }) 31 | }, 32 | '/test/u': { 33 | flags: ['Unicode'], 34 | regexp: jasmine.objectContaining({ textValue: 'test' }) 35 | }, 36 | '/test/mgi': { 37 | flags: ['Global', 'Ignore Case', 'Multiline'], 38 | regexp: jasmine.objectContaining({ textValue: 'test' }) 39 | } 40 | }, (content, str) => { 41 | it(`parses "${str}" as a Root`, function() { 42 | var parser = new javascript.Parser(str); 43 | expect(parser.__consume__root()).toEqual(jasmine.objectContaining(content)); 44 | }); 45 | }); 46 | 47 | describe('#_render', function() { 48 | 49 | beforeEach(function() { 50 | this.textElement = jasmine.createSpyObj('text', ['getBBox']); 51 | this.textElement.getBBox.and.returnValue({ 52 | height: 20 53 | }); 54 | 55 | this.node = new javascript.Parser('test').__consume__root(); 56 | this.node.container = jasmine.createSpyObj('container', [ 57 | 'addClass', 58 | 'text', 59 | 'group', 60 | 'path', 61 | 'circle' 62 | ]); 63 | this.node.container.text.and.returnValue(this.textElement); 64 | this.node.container.group.and.returnValue('group element'); 65 | 66 | this.node.regexp = jasmine.createSpyObj('regexp', [ 67 | 'render', 68 | 'transform', 69 | 'getBBox' 70 | ]); 71 | 72 | this.renderDeferred = this.testablePromise(); 73 | this.node.regexp.render.and.returnValue(this.renderDeferred.promise); 74 | }); 75 | 76 | it('renders the regexp', function() { 77 | this.node._render(); 78 | expect(this.node.regexp.render).toHaveBeenCalledWith('group element'); 79 | }); 80 | 81 | describe('when there are flags', function() { 82 | 83 | beforeEach(function() { 84 | this.node.flags = ['example', 'flags']; 85 | }); 86 | 87 | it('renders a text element', function() { 88 | this.node._render(); 89 | expect(this.node.container.text).toHaveBeenCalledWith(0, 0, 'Flags: example, flags'); 90 | }); 91 | 92 | }); 93 | 94 | describe('when there are no flags', function() { 95 | 96 | beforeEach(function() { 97 | this.node.flags = []; 98 | }); 99 | 100 | it('does not render a text element', function() { 101 | this.node._render(); 102 | expect(this.node.container.text).not.toHaveBeenCalled(); 103 | }); 104 | 105 | }); 106 | 107 | describe('positioning of elements', function() { 108 | 109 | beforeEach(function() { 110 | this.renderDeferred.resolve(); 111 | 112 | this.node.regexp.getBBox.and.returnValue({ 113 | ax: 1, 114 | ay: 2, 115 | ax2: 3, 116 | x2: 4 117 | }); 118 | }); 119 | 120 | it('renders a path element to lead in and out of the regexp', function(done) { 121 | this.node._render() 122 | .then(() => { 123 | expect(this.node.container.path).toHaveBeenCalledWith('M1,2H0M3,2H14'); 124 | done(); 125 | }); 126 | }); 127 | 128 | it('renders circle elements before and after the regexp', function(done) { 129 | this.node._render() 130 | .then(() => { 131 | expect(this.node.container.circle).toHaveBeenCalledWith(0, 2, 5); 132 | expect(this.node.container.circle).toHaveBeenCalledWith(14, 2, 5); 133 | done(); 134 | }); 135 | }); 136 | 137 | describe('when there are flags', function() { 138 | 139 | beforeEach(function() { 140 | this.node.flags = ['example']; 141 | }); 142 | 143 | it('moves the regexp below the flag text', function(done) { 144 | this.node._render() 145 | .then(() => { 146 | expect(this.node.regexp.transform).toHaveBeenCalledWith(Snap.matrix() 147 | .translate(10, 20)); 148 | done(); 149 | }); 150 | }); 151 | 152 | }); 153 | 154 | describe('when there are no flags', function() { 155 | 156 | beforeEach(function() { 157 | this.node.flags = []; 158 | }); 159 | 160 | it('positions the regexp', function(done) { 161 | this.node._render() 162 | .then(() => { 163 | expect(this.node.regexp.transform).toHaveBeenCalledWith(Snap.matrix() 164 | .translate(10, 0)); 165 | done(); 166 | }); 167 | }); 168 | 169 | }); 170 | 171 | }); 172 | 173 | }); 174 | 175 | }); 176 | -------------------------------------------------------------------------------- /spec/parser/javascript_spec.js: -------------------------------------------------------------------------------- 1 | import Parser from '../../src/js/parser/javascript.js'; 2 | import regexpParser from '../../src/js/parser/javascript/grammar.peg'; 3 | import Snap from 'snapsvg'; 4 | 5 | describe('parser/javascript.js', function() { 6 | 7 | beforeEach(function() { 8 | this.container = document.createElement('div'); 9 | this.parser = new Parser(this.container); 10 | }); 11 | 12 | describe('container property', function() { 13 | 14 | it('sets the content of the element', function() { 15 | var element = document.createElement('div'); 16 | this.parser.container = element; 17 | 18 | expect(element.innerHTML).not.toEqual(''); 19 | }); 20 | 21 | it('keeps the original content if the keepContent option is set', function() { 22 | var element = document.createElement('div'); 23 | element.innerHTML = 'example content'; 24 | 25 | this.parser.options.keepContent = true; 26 | this.parser.container = element; 27 | 28 | expect(element.innerHTML).toContain('example content'); 29 | expect(element.innerHTML).not.toEqual('example content'); 30 | }); 31 | 32 | it('adds the "svg-container" class', function() { 33 | spyOn(this.parser, '_addClass'); 34 | this.parser.container = document.createElement('div'); 35 | expect(this.parser._addClass).toHaveBeenCalledWith('svg-container'); 36 | }); 37 | 38 | }); 39 | 40 | describe('#parse', function() { 41 | 42 | beforeEach(function() { 43 | spyOn(regexpParser, 'parse'); 44 | }); 45 | 46 | it('adds the "loading" class', function() { 47 | spyOn(this.parser, '_addClass'); 48 | this.parser.parse('example expression'); 49 | expect(this.parser._addClass).toHaveBeenCalledWith('loading'); 50 | }); 51 | 52 | it('parses the expression', function(done) { 53 | this.parser.parse('example expression') 54 | .then(() => { 55 | expect(regexpParser.parse).toHaveBeenCalledWith('example expression'); 56 | done(); 57 | }); 58 | }); 59 | 60 | it('replaces newlines with "\\n"', function(done) { 61 | this.parser.parse('multiline\nexpression') 62 | .then(() => { 63 | expect(regexpParser.parse).toHaveBeenCalledWith('multiline\\nexpression'); 64 | done(); 65 | }); 66 | }); 67 | 68 | it('resolves the returned promise with the parser instance', function(done) { 69 | this.parser.parse('example expression') 70 | .then(result => { 71 | expect(result).toEqual(this.parser); 72 | done(); 73 | }); 74 | }); 75 | 76 | it('rejects the returned promise with the exception thrown', function(done) { 77 | regexpParser.parse.and.throwError('fail'); 78 | this.parser.parse('(example') 79 | .then(null, result => { 80 | expect(result).toBeDefined(); 81 | done(); 82 | }); 83 | }); 84 | 85 | }); 86 | 87 | describe('#render', function() { 88 | 89 | beforeEach(function() { 90 | this.renderPromise = this.testablePromise(); 91 | this.parser.parsed = jasmine.createSpyObj('parsed', ['render']); 92 | this.parser.parsed.render.and.returnValue(this.renderPromise.promise); 93 | }); 94 | 95 | it('render the parsed expression', function() { 96 | this.parser.render(); 97 | expect(this.parser.parsed.render).toHaveBeenCalled(); 98 | }); 99 | 100 | describe('when rendering is complete', function() { 101 | 102 | beforeEach(function() { 103 | this.result = jasmine.createSpyObj('result', ['getBBox', 'transform']); 104 | this.result.getBBox.and.returnValue({ 105 | x: 4, 106 | y: 2, 107 | width: 42, 108 | height: 24 109 | }); 110 | 111 | this.renderPromise.resolve(this.result); 112 | }); 113 | 114 | it('positions the renderd expression', function(done) { 115 | this.parser.render() 116 | .then(() => { 117 | expect(this.result.transform).toHaveBeenCalledWith(Snap.matrix() 118 | .translate(6, 8)); 119 | done(); 120 | }); 121 | }); 122 | 123 | it('sets the dimensions of the image', function(done) { 124 | this.parser.render() 125 | .then(() => { 126 | let svg = this.container.querySelector('svg'); 127 | 128 | expect(svg.getAttribute('width')).toEqual('62'); 129 | expect(svg.getAttribute('height')).toEqual('44'); 130 | done(); 131 | }); 132 | }); 133 | 134 | it('removes the "loading" class', function(done) { 135 | spyOn(this.parser, '_removeClass'); 136 | this.parser.render() 137 | .then(() => { 138 | expect(this.parser._removeClass).toHaveBeenCalledWith('loading'); 139 | done(); 140 | }); 141 | }); 142 | 143 | it('removes the progress element', function(done) { 144 | this.parser.render() 145 | .then(() => { 146 | expect(this.container.querySelector('.loading')).toBeNull(); 147 | done(); 148 | }); 149 | }); 150 | 151 | }); 152 | 153 | }); 154 | 155 | describe('#cancel', function() { 156 | 157 | it('sets the cancelRender state to true', function() { 158 | this.parser.cancel(); 159 | expect(this.parser.state.cancelRender).toEqual(true); 160 | }); 161 | 162 | }); 163 | 164 | describe('warnings property', function() { 165 | 166 | it('returns the content of the warnings state variable', function() { 167 | this.parser.state.warnings.push('example'); 168 | expect(this.parser.warnings).toEqual(['example']); 169 | }); 170 | 171 | }); 172 | 173 | }); 174 | -------------------------------------------------------------------------------- /spec/parser/javascript/charset_spec.js: -------------------------------------------------------------------------------- 1 | import javascript from '../../../src/js/parser/javascript/parser.js'; 2 | import Node from '../../../src/js/parser/javascript/node.js'; 3 | import util from '../../../src/js/util.js'; 4 | import _ from 'lodash'; 5 | import Snap from 'snapsvg'; 6 | 7 | describe('parser/javascript/charset.js', function() { 8 | 9 | _.forIn({ 10 | '[abc]': { 11 | label: 'One of:', 12 | elements: [ 13 | jasmine.objectContaining({ type: 'literal', textValue: 'a' }), 14 | jasmine.objectContaining({ type: 'literal', textValue: 'b' }), 15 | jasmine.objectContaining({ type: 'literal', textValue: 'c' }) 16 | ] 17 | }, 18 | '[^abc]': { 19 | label: 'None of:', 20 | elements: [ 21 | jasmine.objectContaining({ type: 'literal', textValue: 'a' }), 22 | jasmine.objectContaining({ type: 'literal', textValue: 'b' }), 23 | jasmine.objectContaining({ type: 'literal', textValue: 'c' }) 24 | ] 25 | }, 26 | '[aaa]': { 27 | label: 'One of:', 28 | elements: [ 29 | jasmine.objectContaining({ type: 'literal', textValue: 'a' }) 30 | ] 31 | }, 32 | '[a-z]': { 33 | label: 'One of:', 34 | elements: [ 35 | jasmine.objectContaining({ type: 'charset-range', textValue: 'a-z' }) 36 | ] 37 | }, 38 | '[\\b]': { 39 | label: 'One of:', 40 | elements: [ 41 | jasmine.objectContaining({ type: 'charset-escape', textValue: '\\b' }) 42 | ] 43 | } 44 | 45 | }, (content, str) => { 46 | it(`parses "${str}" as a Charset`, function() { 47 | var parser = new javascript.Parser(str); 48 | expect(parser.__consume__charset()).toEqual(jasmine.objectContaining(content)); 49 | }); 50 | }); 51 | 52 | it('adds a warning for character sets the contain non-standard escapes', function() { 53 | var node; 54 | 55 | Node.state = { warnings: [] }; 56 | node = new javascript.Parser('[\\c]').__consume__charset(); 57 | expect(node.state.warnings).toEqual(['The character set "[\\c]" contains the \\c escape followed by a character other than A-Z. This can lead to different behavior depending on browser. The representation here is the most common interpretation.']); 58 | }); 59 | 60 | describe('_anchor property', function() { 61 | 62 | it('calculates the anchor based on the partContainer', function() { 63 | var node = new javascript.Parser('[a]').__consume__charset(); 64 | 65 | node.partContainer = jasmine.createSpyObj('partContainer', ['getBBox']); 66 | node.partContainer.getBBox.and.returnValue({ 67 | cy: 20 68 | }); 69 | 70 | spyOn(node, 'transform').and.returnValue({ 71 | localMatrix: Snap.matrix().translate(3, 8) 72 | }); 73 | 74 | expect(node._anchor).toEqual({ 75 | ay: 28 76 | }); 77 | }); 78 | 79 | }); 80 | 81 | describe('#_render', function() { 82 | 83 | beforeEach(function() { 84 | var counter = 0; 85 | 86 | this.node = new javascript.Parser('[a]').__consume__charset(); 87 | this.node.label = 'example label'; 88 | this.node.elements = [ 89 | jasmine.createSpyObj('item', ['render']), 90 | jasmine.createSpyObj('item', ['render']), 91 | jasmine.createSpyObj('item', ['render']) 92 | ]; 93 | this.elementDeferred = [ 94 | this.testablePromise(), 95 | this.testablePromise(), 96 | this.testablePromise() 97 | ]; 98 | this.node.elements[0].render.and.returnValue(this.elementDeferred[0].promise); 99 | this.node.elements[1].render.and.returnValue(this.elementDeferred[1].promise); 100 | this.node.elements[2].render.and.returnValue(this.elementDeferred[2].promise); 101 | 102 | this.node.container = Snap(document.createElement('svg')).group(); 103 | this.partContainer = this.node.container.group(); 104 | spyOn(this.node.container, 'group').and.returnValue(this.partContainer); 105 | spyOn(this.partContainer, 'group').and.callFake(function() { 106 | return `group ${counter++}`; 107 | }); 108 | 109 | spyOn(this.node, 'renderLabeledBox').and.returnValue('labeled box promise'); 110 | spyOn(util, 'spaceVertically'); 111 | }); 112 | 113 | it('creates a cotainer for the parts of the charset', function() { 114 | this.node._render(); 115 | expect(this.node.partContainer).toEqual(this.partContainer); 116 | }); 117 | 118 | it('renders each item', function() { 119 | this.node._render(); 120 | expect(this.node.elements[0].render).toHaveBeenCalledWith('group 0'); 121 | expect(this.node.elements[1].render).toHaveBeenCalledWith('group 1'); 122 | expect(this.node.elements[2].render).toHaveBeenCalledWith('group 2'); 123 | }); 124 | 125 | describe('positioning of the items', function() { 126 | 127 | beforeEach(function() { 128 | this.elementDeferred[0].resolve(); 129 | this.elementDeferred[1].resolve(); 130 | this.elementDeferred[2].resolve(); 131 | }); 132 | 133 | it('spaces the elements vertically', function(done) { 134 | this.node._render() 135 | .then(() => { 136 | expect(util.spaceVertically).toHaveBeenCalledWith(this.node.elements, { padding: 5 }); 137 | done(); 138 | }); 139 | }); 140 | 141 | it('renders a labeled box', function(done) { 142 | this.node._render() 143 | .then(result => { 144 | expect(this.node.renderLabeledBox).toHaveBeenCalledWith('example label', this.partContainer, { padding: 5 }); 145 | expect(result).toEqual('labeled box promise'); 146 | done(); 147 | }); 148 | }); 149 | 150 | }); 151 | 152 | }); 153 | 154 | }); 155 | -------------------------------------------------------------------------------- /spec/parser/javascript/match_spec.js: -------------------------------------------------------------------------------- 1 | import javascript from '../../../src/js/parser/javascript/parser.js'; 2 | import util from '../../../src/js/util.js'; 3 | import _ from 'lodash'; 4 | import Snap from 'snapsvg'; 5 | 6 | describe('parser/javascript/match.js', function() { 7 | 8 | _.forIn({ 9 | 'example': { 10 | parts: [ 11 | jasmine.objectContaining({ 12 | content: jasmine.objectContaining({ literal: 'example' }) 13 | }) 14 | ], 15 | proxy: jasmine.objectContaining({ 16 | content: jasmine.objectContaining({ literal: 'example' }) 17 | }) 18 | }, 19 | 'example*': { 20 | parts: [ 21 | jasmine.objectContaining({ 22 | content: jasmine.objectContaining({ literal: 'exampl' }) 23 | }), 24 | jasmine.objectContaining({ 25 | content: jasmine.objectContaining({ literal: 'e' }) 26 | }) 27 | ] 28 | }, 29 | '': { 30 | parts: [] 31 | } 32 | }, (content, str) => { 33 | it(`parses "${str}" as a Match`, function() { 34 | var parser = new javascript.Parser(str); 35 | expect(parser.__consume__match()).toEqual(jasmine.objectContaining(content)); 36 | }); 37 | }); 38 | 39 | describe('_anchor property', function() { 40 | 41 | beforeEach(function() { 42 | this.node = new javascript.Parser('a').__consume__match(); 43 | 44 | this.node.start = jasmine.createSpyObj('start', ['getBBox']); 45 | this.node.start.getBBox.and.returnValue({ 46 | x: 1, 47 | x2: 2, 48 | cy: 3 49 | }); 50 | 51 | this.node.end = jasmine.createSpyObj('start', ['getBBox']); 52 | this.node.end.getBBox.and.returnValue({ 53 | x: 4, 54 | x2: 5, 55 | cy: 6 56 | }); 57 | 58 | spyOn(this.node, 'transform').and.returnValue({ 59 | localMatrix: Snap.matrix().translate(10, 20) 60 | }); 61 | }); 62 | 63 | it('calculates the anchor from the start and end items', function() { 64 | expect(this.node._anchor).toEqual({ 65 | ax: 11, 66 | ax2: 15, 67 | ay: 23 68 | }); 69 | }); 70 | 71 | }); 72 | 73 | describe('#_render', function() { 74 | 75 | beforeEach(function() { 76 | this.node = new javascript.Parser('a').__consume__match(); 77 | 78 | this.node.container = jasmine.createSpyObj('container', [ 79 | 'addClass', 80 | 'group', 81 | 'prepend', 82 | 'path' 83 | ]); 84 | this.node.container.group.and.returnValue('example group'); 85 | 86 | this.node.parts = [ 87 | jasmine.createSpyObj('part 0', ['render']), 88 | jasmine.createSpyObj('part 1', ['render']), 89 | jasmine.createSpyObj('part 2', ['render']) 90 | ]; 91 | 92 | this.partDeferreds = [ 93 | this.testablePromise(), 94 | this.testablePromise(), 95 | this.testablePromise() 96 | ]; 97 | 98 | this.node.parts[0].render.and.returnValue(this.partDeferreds[0].promise); 99 | this.node.parts[1].render.and.returnValue(this.partDeferreds[1].promise); 100 | this.node.parts[2].render.and.returnValue(this.partDeferreds[2].promise); 101 | }); 102 | 103 | it('renders each part', function() { 104 | this.node._render(); 105 | expect(this.node.parts[0].render).toHaveBeenCalledWith('example group'); 106 | expect(this.node.parts[1].render).toHaveBeenCalledWith('example group'); 107 | expect(this.node.parts[2].render).toHaveBeenCalledWith('example group'); 108 | }); 109 | 110 | describe('positioning of items', function() { 111 | 112 | beforeEach(function() { 113 | this.partDeferreds[0].resolve('part 0'); 114 | this.partDeferreds[1].resolve('part 1'); 115 | this.partDeferreds[2].resolve('part 2'); 116 | 117 | spyOn(util, 'spaceHorizontally'); 118 | spyOn(this.node, 'connectorPaths').and.returnValue(['connector paths']); 119 | }); 120 | 121 | it('sets the start and end properties', function(done) { 122 | this.node._render() 123 | .then(() => { 124 | expect(this.node.start).toEqual('part 0'); 125 | expect(this.node.end).toEqual('part 2'); 126 | done(); 127 | }); 128 | }); 129 | 130 | it('spaces the items horizontally', function(done) { 131 | this.node._render() 132 | .then(() => { 133 | expect(util.spaceHorizontally).toHaveBeenCalledWith([ 134 | 'part 0', 135 | 'part 1', 136 | 'part 2' 137 | ], { padding: 10 }); 138 | done(); 139 | }); 140 | }); 141 | 142 | it('renders the connector paths', function(done) { 143 | this.node._render() 144 | .then(() => { 145 | expect(this.node.connectorPaths).toHaveBeenCalledWith([ 146 | 'part 0', 147 | 'part 1', 148 | 'part 2' 149 | ]); 150 | expect(this.node.container.path).toHaveBeenCalledWith('connector paths'); 151 | done(); 152 | }); 153 | }); 154 | 155 | }); 156 | 157 | }); 158 | 159 | describe('#connectorPaths', function() { 160 | 161 | beforeEach(function() { 162 | this.node = new javascript.Parser('a').__consume__match(); 163 | 164 | this.items = [ 165 | jasmine.createSpyObj('item 0', ['getBBox']), 166 | jasmine.createSpyObj('item 1', ['getBBox']), 167 | jasmine.createSpyObj('item 2', ['getBBox']) 168 | ]; 169 | 170 | this.items[0].getBBox.and.returnValue({ 171 | x: 10, 172 | x2: 20, 173 | cy: 5 174 | }); 175 | this.items[1].getBBox.and.returnValue({ 176 | x: 30, 177 | x2: 40, 178 | cy: 5 179 | }); 180 | this.items[2].getBBox.and.returnValue({ 181 | x: 50, 182 | x2: 60, 183 | cy: 5 184 | }); 185 | }); 186 | 187 | it('returns the connector paths between fragments', function() { 188 | expect(this.node.connectorPaths(this.items)).toEqual([ 189 | 'M20,5H30', 190 | 'M40,5H50' 191 | ]); 192 | }); 193 | 194 | }); 195 | 196 | }); 197 | -------------------------------------------------------------------------------- /src/js/parser/javascript/node.js: -------------------------------------------------------------------------------- 1 | // Base class for all nodes in the parse tree. An instance of this class is 2 | // created for each parsed node, and then extended with one of the node-type 3 | // modules. 4 | import util from '../../util.js'; 5 | import _ from 'lodash'; 6 | 7 | export default class Node { 8 | // Arguments passed in are defined by the canopy tool. 9 | constructor(textValue, offset, elements, properties) { 10 | this.textValue = textValue; 11 | this.offset = offset; 12 | this.elements = elements || []; 13 | 14 | this.properties = properties; 15 | 16 | // This is the current parser state (an instance 17 | // [ParserState](./parser_state.html).) 18 | this.state = Node.state; 19 | } 20 | 21 | // Node-type module to extend the Node instance with. Setting of this is 22 | // done by canopy during parsing and is setup in [parser.js](./parser.html). 23 | set module(mod) { 24 | _.extend(this, mod); 25 | 26 | if (this.setup) { 27 | this.setup(); 28 | } 29 | 30 | _.forOwn(this.definedProperties || {}, (methods, name) => { 31 | Object.defineProperty(this, name, methods); 32 | }); 33 | 34 | delete this.definedProperties; 35 | } 36 | 37 | // The SVG element to render this node into. A node-type class is 38 | // automatically added to the container. The class to set is defined on the 39 | // module set during parsing. 40 | set container(container) { 41 | this._container = container; 42 | this._container.addClass(this.type); 43 | } 44 | 45 | get container() { 46 | return this._container; 47 | } 48 | 49 | // The anchor defined the points on the left and right of the rendered node 50 | // that the centerline of the rendered expression connects to. For most 51 | // nodes, this element will be defined by the normalizeBBox method in 52 | // [Util](../../util.html). 53 | get anchor() { 54 | if (this.proxy) { 55 | return this.proxy.anchor; 56 | } else { 57 | return this._anchor || {}; 58 | } 59 | } 60 | 61 | // Returns the bounding box of the container with the anchor included. 62 | getBBox() { 63 | return _.extend(util.normalizeBBox(this.container.getBBox()), this.anchor); 64 | } 65 | 66 | // Transforms the container. 67 | // 68 | // - __matrix__ - A matrix transform to be applied. Created using Snap.svg. 69 | transform(matrix) { 70 | return this.container.transform(matrix); 71 | } 72 | 73 | // Returns a Promise that will be resolved with the provided value. If the 74 | // render is cancelled before the Promise is resolved, then an exception will 75 | // be thrown to halt any rendering. 76 | // 77 | // - __value__ - Value to resolve the returned promise with. 78 | deferredStep(value) { 79 | return util.tick().then(() => { 80 | if (this.state.cancelRender) { 81 | throw 'Render cancelled'; 82 | } 83 | 84 | return value; 85 | }); 86 | } 87 | 88 | // Render this node. 89 | // 90 | // - __container__ - Optional element to render this node into. A container 91 | // must be specified, but if it has already been set, then it does not 92 | // need to be provided to render. 93 | render(container) { 94 | if (container) { 95 | this.container = container; 96 | } 97 | 98 | if (this.proxy) { 99 | // For nodes that proxy to a child node, just render the child. 100 | return this.proxy.render(this.container); 101 | } else { 102 | // Non-proxied nodes call their _render method (defined by the node-type 103 | // module). 104 | this.state.renderCounter++; 105 | return this._render() 106 | .then(() => { 107 | this.state.renderCounter--; 108 | return this; 109 | }); 110 | } 111 | } 112 | 113 | // Renders a label centered within a rectangle which can be styled. Returns 114 | // a Promise which will be resolved with the SVG group the rect and text are 115 | // rendered in. 116 | // 117 | // - __text__ - String or array of strings to render as a label. 118 | renderLabel(text) { 119 | let group = this.container.group() 120 | .addClass('label'), 121 | rect = group.rect(), 122 | label = group.text(0, 0, _.flatten([text])); 123 | 124 | return this.deferredStep() 125 | .then(() => { 126 | let box = label.getBBox(), 127 | margin = 5; 128 | 129 | label.transform(Snap.matrix() 130 | .translate(margin, box.height / 2 + 2 * margin)); 131 | 132 | rect.attr({ 133 | width: box.width + 2 * margin, 134 | height: box.height + 2 * margin 135 | }); 136 | 137 | return group; 138 | }); 139 | } 140 | 141 | // Renders a labeled box around another SVG element. Returns a Promise. 142 | // 143 | // - __text__ - String or array of strings to label the box with. 144 | // - __content__ - SVG element to wrap in the box. 145 | // - __options.padding__ - Pixels of padding to place between the content and 146 | // the box. 147 | renderLabeledBox(text, content, options) { 148 | let label = this.container.text(0, 0, _.flatten([text])) 149 | .addClass(`${this.type}-label`), 150 | box = this.container.rect() 151 | .addClass(`${this.type}-box`) 152 | .attr({ 153 | rx: 3, 154 | ry: 3 155 | }); 156 | 157 | options = _.defaults(options || {}, { 158 | padding: 0 159 | }); 160 | 161 | this.container.prepend(label); 162 | this.container.prepend(box); 163 | 164 | return this.deferredStep() 165 | .then(() => { 166 | let labelBox = label.getBBox(), 167 | contentBox = content.getBBox(), 168 | boxWidth = Math.max(contentBox.width + options.padding * 2, labelBox.width), 169 | boxHeight = contentBox.height + options.padding * 2; 170 | 171 | label.transform(Snap.matrix() 172 | .translate(0, labelBox.height)); 173 | 174 | box 175 | .transform(Snap.matrix() 176 | .translate(0, labelBox.height)) 177 | .attr({ 178 | width: boxWidth, 179 | height: boxHeight 180 | }); 181 | 182 | content.transform(Snap.matrix() 183 | .translate(boxWidth / 2 - contentBox.cx, labelBox.height + options.padding)); 184 | }); 185 | } 186 | }; 187 | -------------------------------------------------------------------------------- /spec/parser/javascript/match_fragment_spec.js: -------------------------------------------------------------------------------- 1 | import javascript from '../../../src/js/parser/javascript/parser.js'; 2 | import _ from 'lodash'; 3 | import Snap from 'snapsvg'; 4 | 5 | describe('parser/javascript/match_fragment.js', function() { 6 | 7 | _.forIn({ 8 | 'a': { 9 | proxy: jasmine.objectContaining({ textValue: 'a' }), 10 | canMerge: true 11 | }, 12 | '\\b': { 13 | proxy: jasmine.objectContaining({ textValue: '\\b' }), 14 | canMerge: false 15 | }, 16 | 'a*': { 17 | content: jasmine.objectContaining({ textValue: 'a' }), 18 | repeat: jasmine.objectContaining({ textValue: '*' }), 19 | canMerge: false 20 | } 21 | }, (content, str) => { 22 | it(`parses "${str}" as a MatchFragment`, function() { 23 | var parser = new javascript.Parser(str); 24 | expect(parser.__consume__match_fragment()).toEqual(jasmine.objectContaining(content)); 25 | }); 26 | }); 27 | 28 | describe('_anchor property', function() { 29 | 30 | beforeEach(function() { 31 | this.node = new javascript.Parser('a').__consume__match_fragment(); 32 | 33 | this.node.content = { 34 | getBBox() { 35 | return { 36 | ax: 1, 37 | ax2: 2, 38 | ay: 3 39 | }; 40 | } 41 | }; 42 | spyOn(this.node, 'transform').and.returnValue({ 43 | localMatrix: Snap.matrix().translate(10, 20) 44 | }); 45 | }); 46 | 47 | it('applies the local transform to the content anchor', function() { 48 | expect(this.node._anchor).toEqual({ 49 | ax: 11, 50 | ax2: 12, 51 | ay: 23 52 | }); 53 | }); 54 | 55 | }); 56 | 57 | describe('#_render', function() { 58 | 59 | beforeEach(function() { 60 | this.node = new javascript.Parser('a').__consume__match_fragment(); 61 | 62 | this.node.container = jasmine.createSpyObj('container', [ 63 | 'addClass', 64 | 'group', 65 | 'prepend', 66 | 'path' 67 | ]); 68 | this.node.container.group.and.returnValue('example group'); 69 | 70 | this.renderDeferred = this.testablePromise(); 71 | this.node.content = jasmine.createSpyObj('content', [ 72 | 'render', 73 | 'transform', 74 | 'getBBox' 75 | ]); 76 | this.node.content.getBBox.and.returnValue('content bbox'); 77 | this.node.content.render.and.returnValue(this.renderDeferred.promise); 78 | 79 | this.node.repeat = { 80 | contentPosition: 'example position', 81 | skipPath: jasmine.createSpy('skipPath').and.returnValue('skip path'), 82 | loopPath: jasmine.createSpy('loopPath').and.returnValue('loop path') 83 | }; 84 | 85 | spyOn(this.node, 'loopLabel'); 86 | }); 87 | 88 | it('renders the content', function() { 89 | this.node._render(); 90 | expect(this.node.content.render).toHaveBeenCalledWith('example group'); 91 | }); 92 | 93 | describe('positioning of content', function() { 94 | 95 | beforeEach(function() { 96 | this.renderDeferred.resolve(); 97 | }); 98 | 99 | it('moves the content to the correct position', function(done) { 100 | this.node._render() 101 | .then(() => { 102 | expect(this.node.content.transform).toHaveBeenCalledWith('example position'); 103 | done(); 104 | }); 105 | }); 106 | 107 | it('renders a skip path and loop path', function(done) { 108 | this.node._render() 109 | .then(() => { 110 | expect(this.node.repeat.skipPath).toHaveBeenCalledWith('content bbox'); 111 | expect(this.node.repeat.loopPath).toHaveBeenCalledWith('content bbox'); 112 | expect(this.node.container.path).toHaveBeenCalledWith('skip pathloop path'); 113 | done(); 114 | }); 115 | }); 116 | 117 | it('renders a loop label', function(done) { 118 | this.node._render() 119 | .then(() => { 120 | expect(this.node.loopLabel).toHaveBeenCalled(); 121 | done(); 122 | }); 123 | }); 124 | 125 | }); 126 | 127 | }); 128 | 129 | describe('#loopLabel', function() { 130 | 131 | beforeEach(function() { 132 | this.node = new javascript.Parser('a').__consume__match_fragment(); 133 | 134 | this.node.repeat = {}; 135 | 136 | this.node.container = jasmine.createSpyObj('container', [ 137 | 'addClass', 138 | 'text' 139 | ]); 140 | 141 | this.text = jasmine.createSpyObj('text', [ 142 | 'addClass', 143 | 'getBBox', 144 | 'transform' 145 | ]); 146 | this.node.container.text.and.returnValue(this.text); 147 | this.text.addClass.and.returnValue(this.text); 148 | this.text.getBBox.and.returnValue({ 149 | width: 11, 150 | height: 22 151 | }); 152 | spyOn(this.node, 'getBBox').and.returnValue({ 153 | x2: 33, 154 | y2: 44 155 | }); 156 | }); 157 | 158 | describe('when a label is defined', function() { 159 | 160 | beforeEach(function() { 161 | this.node.repeat.label = 'example label'; 162 | }); 163 | 164 | it('renders a text element', function() { 165 | this.node.loopLabel(); 166 | expect(this.node.container.text).toHaveBeenCalledWith(0, 0, ['example label']); 167 | }); 168 | 169 | describe('when there is a skip loop', function() { 170 | 171 | beforeEach(function() { 172 | this.node.repeat.hasSkip = true; 173 | }); 174 | 175 | it('positions the text element', function() { 176 | this.node.loopLabel(); 177 | expect(this.text.transform).toHaveBeenCalledWith(Snap.matrix() 178 | .translate(17, 66)); 179 | }); 180 | 181 | }); 182 | 183 | describe('when there is no skip loop', function() { 184 | 185 | beforeEach(function() { 186 | this.node.repeat.hasSkip = false; 187 | }); 188 | 189 | it('positions the text element', function() { 190 | this.node.loopLabel(); 191 | expect(this.text.transform).toHaveBeenCalledWith(Snap.matrix() 192 | .translate(22, 66)); 193 | }); 194 | 195 | }); 196 | 197 | }); 198 | 199 | describe('when a label is not defined', function() { 200 | 201 | it('does not render a text element', function() { 202 | this.node.loopLabel(); 203 | expect(this.node.container.text).not.toHaveBeenCalled(); 204 | }); 205 | 206 | }); 207 | 208 | }); 209 | 210 | }); 211 | -------------------------------------------------------------------------------- /src/documentation.hbs: -------------------------------------------------------------------------------- 1 | --- 2 | title: Documentation 3 | --- 4 | {{#extend "layout"}} 5 | {{#content "body"}} 6 |
7 |
8 |

Reading Railroad Diagrams

9 | 10 |

The images generated by Regexper are commonly referred to as "Railroad Diagrams". These diagram are a straight-forward way to illustrate what can sometimes become very complicated processing in a regular expression, with nested looping and optional elements. The easiest way to read these diagrams to to start at the left and follow the lines to the right. If you encounter a branch, then there is the option of following one of multiple paths (and those paths can loop back to earlier parts of the diagram). In order for a string to successfully match the regular expression in a diagram, you must be able to fulfill each part of the diagram as you move from left to right and proceed through the entire diagram to the end.

11 | 12 |
13 | 14 |

As an example, this expression will match "Lions and tigers and bears. Oh my!" or the more grammatically correct "Lions, tigers, and bears. Oh my!" (with or without an Oxford comma). The diagram first matches the string "Lions"; you cannot proceed without that in your input. Then there is a choice between a comma or the string " and". No matter what choice you make, the input string must then contain " tigers" followed by an optional comma (your path can either go through the comma or around it). Finally the string must end with " and bears. Oh my!".

15 | 16 |
17 |

Basic parts of these diagrams

18 | 19 |

The simplest pieces of these diagrams to understand are the parts that match some specific bit of text without an options. They are: Literals, Escape sequences, and "Any charater".

20 | 21 |
22 |

Literals

23 | 24 |
25 | 26 |

Literals match an exact string of text. They're displayed in a light blue box, and the contents are quoted (to make it easier to see any leading or trailing whitespace).

27 |
28 | 29 |
30 |

Escape sequences

31 | 32 |
33 | 34 |

Escape sequences are displayed in a green box and contain a description of the type of character(s) they will match.

35 |
36 | 37 |
38 |

"Any character"

39 | 40 |
41 | 42 |

"Any character" is similar to an escape sequence. It matches any single character.

43 |
44 |
45 | 46 |
47 |

Character Sets

48 | 49 |
50 | 51 |

Character sets will match or not match a collection of individual characters. They are shown as a box containing literals and escape sequences. The label at the top indicates that the character set will match "One of" the contained items or "None of" the contained items.

52 |
53 | 54 |
55 |

Subexpressions

56 | 57 |
58 | 59 |

Subexpressions are indicated by a dotted outline around the items that are in the expression. Captured subexpressions are labeled with the group number they will be captured under. Positive and negative lookahead are labeled as such.

60 |
61 | 62 |
63 |

Alternation

64 | 65 |
66 | 67 |

Alternation provides choices for the regular experssion. It is indicated by the path for the expression fanning out into a number of choices.

68 |
69 | 70 |
71 |

Quantifiers

72 | 73 |

Quantifiers indicate if part of the expression should be repeated or optional. They are displayed similarly to Alternation, by the path through the diagram branching (and possibly looping back on itself). Unless indicated by an arrow on the path, the preferred path is to continue going straight.

74 | 75 |
76 |

Zero-or-more

77 | 78 |
79 |
Greedy quantifier
80 |
81 | 82 |
83 |
Non-greedy quantifier
84 |
85 | 86 |

The zero-or-more quantifier matches any number of repetitions of the pattern.

87 |
88 | 89 |
90 |

Required

91 | 92 |
93 |
Greedy quantifier
94 |
95 | 96 |
97 |
Non-greedy quantifier
98 |
99 | 100 |

The required quantifier matches one or more repetitions of the pattern. Note that it does not have the path that allows the pattern to be skipped like the zero-or-more quantifier.

101 |
102 | 103 |
104 |

Optional

105 | 106 |
107 |
Greedy quantifier
108 |
109 | 110 |
111 |
Non-greedy quantifier
112 |
113 | 114 |

The optional quantifier matches the pattern at most once. Note that it does not have the path that allows the pattern to loop back on itself like the zero-or-more or required quantifiers.

115 |
116 | 117 |
118 |

Range

119 | 120 |
121 |
Greedy quantifier
122 |
123 | 124 |
125 |
Non-greedy quantifier
126 |
127 | 128 |

The ranged quantifier specifies a number of times the pattern may be repeated. The two examples provided here both have a range of "{5,10}", the label for the looping branch indicates the number of times that branch may be followed. The values are one less than specified in the expression since the pattern would have to be matched once before repeating it is an option. So, for these examples, the pattern would be matched once and then the loop would be followed 4 to 9 times, for a total of 5 to 10 matches of the pattern.

129 |
130 |
131 |
132 |
133 | {{/content}} 134 | 135 | {{#content "footer" mode="append"}} 136 | 137 | {{/content}} 138 | {{/extend}} 139 | -------------------------------------------------------------------------------- /lib/data/changelog.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "label": "May 24, 2018 Release", 4 | "changes": [ 5 | "Supporting browser \"Do Not Track\" setting. When enabled, this will prevent use of Google Analytics and Sentry error reporting." 6 | ] 7 | }, 8 | { 9 | "label": "February 10, 2018 Release", 10 | "changes": [ 11 | "Adding 'sticky' and 'unicode' flag support", 12 | "Encoding parenthesis in the permalink and browser URLs", 13 | "Adding PNG download support" 14 | ] 15 | }, 16 | { 17 | "label": "July 31, 2016 Release", 18 | "changes": [ 19 | "Merged code to enable automated testing with Travis CI from Sebastian Thiel", 20 | "Merged feature to show an informational tooltip on loop labels from Thibaud Colas", 21 | "Fixed issue with '^' and '$' not being allowed in the middle of a fragment (see GitHub issue)", 22 | "Updating several dependencies", 23 | "Some stylistic code cleanup" 24 | ] 25 | }, 26 | { 27 | "label": "May 31, 2016 Release", 28 | "changes": [ 29 | "Putting separate CSS for generated SVG images back into the build. Downloaded images have been broken since the March 10 release because the SVG styles were merged into the page styles." 30 | ] 31 | }, 32 | { 33 | "label": "May 23, 2016 Release", 34 | "changes": [ 35 | "Refactored tracking code to support latest Google Analytics setup" 36 | ] 37 | }, 38 | { 39 | "label": "March 10, 2016 Release", 40 | "changes": [ 41 | "Embedding SVG icon images into markup", 42 | "Some changes for minor performance improvements", 43 | "Updating several dependencies" 44 | ] 45 | }, 46 | { 47 | "label": "March 8, 2016 Release", 48 | "changes": [ 49 | "Replaced icon font with individual SVG images" 50 | ] 51 | }, 52 | { 53 | "label": "March 3, 2016 Release", 54 | "changes": [ 55 | "Merged some code cleanup and a bugfix from Sebastian Thiel", 56 | "Updated notice for IE8 users to no longer include link to legacy site" 57 | ] 58 | }, 59 | { 60 | "label": "December 21, 2015 Release", 61 | "changes": [ 62 | "Updating NPM dependencies to fix JS error that only appeared when running site from a local development environment (see GitHub issue)" 63 | ] 64 | }, 65 | { 66 | "label": "November 10, 2015 Release", 67 | "changes": [ 68 | "Fixing Babel integration to include polyfills" 69 | ] 70 | }, 71 | { 72 | "label": "November 1, 2015 Release", 73 | "changes": [ 74 | "Switching from Compass to node-sass and Bourbon (no more need for Ruby)", 75 | "Switching to Babel instead of es6ify", 76 | "Improving sourcemap generation", 77 | "Cleanup of the build process" 78 | ] 79 | }, 80 | { 81 | "label": "October 31, 2015 Release", 82 | "changes": [ 83 | "Reducing file size for title font", 84 | "Cleaning up gulpfile", 85 | "Upgrading most dependencies", 86 | "Switching to Handlebars for template rendering" 87 | ] 88 | }, 89 | { 90 | "label": "September 17, 2015 Release", 91 | "changes": [ 92 | "Fixing styling of labels on repetitions", 93 | "Fixing issue with vertical centering of alternation expressions that include empty expressions (see GitHub issue)" 94 | ] 95 | }, 96 | { 97 | "label": "September 2, 2015 Release", 98 | "changes": [ 99 | "Merging fix for error reporting from (see GitHub pull request)" 100 | ] 101 | }, 102 | { 103 | "label": "July 5, 2015 Release", 104 | "changes": [ 105 | "Updating Creative Commons license badge URL so it isn't pointing to a redirecting URL anymore" 106 | ] 107 | }, 108 | { 109 | "label": "June 22, 2015 Release", 110 | "changes": [ 111 | "Tweaking buggy Firefox hash detection code based on JavaScript errors that were logged" 112 | ] 113 | }, 114 | { 115 | "label": "June 16, 2015 Release", 116 | "changes": [ 117 | "Fixes issue with expressions containing a \"%\" not rendering in Firefox (see GitHub issue)", 118 | "Fixed rendering in IE that was causing \"-->\" to display at the top of the page." 119 | ] 120 | }, 121 | { 122 | "label": "April 14, 2015 Release", 123 | "changes": [ 124 | "Rendering speed improved. Most users will probably not see much improvement since logging data indicates that expressing rendering time is typically less than 1 second. Using the RFC822 email regular expression though shows a rendering speed improvement from ~120 seconds down to ~80 seconds.", 125 | "Fixing a bug that would only occur when attempting to render an expression while another is in the process of rendering" 126 | ] 127 | }, 128 | { 129 | "label": "March 14, 2015 Release", 130 | "changes": [ 131 | "Removing use of Q for promises in favor of \"native\" ES6 promises (even though they aren't quite native everywhere yet)" 132 | ] 133 | }, 134 | { 135 | "label": "March 13, 2015 Release", 136 | "changes": [ 137 | "Fixes bug with numbering of nested subexpressions (see GitHub issue)" 138 | ] 139 | }, 140 | { 141 | "label": "February 11, 2015 Release", 142 | "changes": [ 143 | "Various adjustments to analytics: tracking expression rendering time and JS errors", 144 | "Escape sequences that match to a specific character now display their hexadecimal code (actually done on January 25, but I forgot to update the changelog)", 145 | "Fixing styling issue with header links (see GitHub issue)" 146 | ] 147 | }, 148 | { 149 | "label": "December 30, 2014 Release", 150 | "changes": [ 151 | "Fixing bug that prevented rendering empty subexpressions", 152 | "Fixing minor styling bug when permalink is disabled", 153 | "Cleaning up some duplicated styles and JS" 154 | ] 155 | }, 156 | { 157 | "label": "December 29, 2014 Release", 158 | "changes": [ 159 | "Tweaking analytics data to help with addressing issues in deployed code (work will likely continue on this)", 160 | "Added progress bars on the documentation page", 161 | "Removed the loading spinner everywhere", 162 | "Animated the progress bars" 163 | ] 164 | }, 165 | { 166 | "label": "December 26, 2014 Release", 167 | "changes": [ 168 | "Freshened up design", 169 | "Multiline regular expression input field (press Shift-Enter to render)", 170 | "Added a changelog", 171 | "Added documentation", 172 | "All parsing and rendering happens client-side (using Canopy and Snap.svg)", 173 | "Added Download link (not available in older browsers)", 174 | "Added display of regular expression flags (ignore case, global, multiline)", 175 | "Added indicator of quantifier greedy-ness", 176 | "Various improvements to parsing of regular expression", 177 | "Rendering of a regular expression can be canceled by pressing Escape" 178 | ] 179 | } 180 | ] 181 | -------------------------------------------------------------------------------- /src/sass/main.scss: -------------------------------------------------------------------------------- 1 | @import 'base'; 2 | @import 'reset'; 3 | 4 | body { 5 | font-family: sans-serif; 6 | font-size: $base-font-size; 7 | line-height: $base-line-height; 8 | background: $gray; 9 | margin-bottom: rhythm(1); 10 | } 11 | 12 | a { 13 | color: $black; 14 | } 15 | 16 | .inline-icon { 17 | svg { 18 | margin-right: rhythm(1/4); 19 | width: 1em; 20 | height: 1em; 21 | vertical-align: middle; 22 | background: transparent; 23 | } 24 | } 25 | 26 | h1 { 27 | @include adjust-font-size-to(48px, 2); 28 | } 29 | 30 | ul.inline-list { 31 | @include adjust-font-size-to(14px, 2/3); 32 | @include clearfix; 33 | 34 | li { 35 | list-style-type: none; 36 | display: inline-block; 37 | white-space: nowrap; 38 | 39 | &::after { 40 | content: '//'; 41 | padding: 0 rhythm(1/4); 42 | } 43 | 44 | &:last-child::after { 45 | content: ''; 46 | } 47 | } 48 | } 49 | 50 | .svg-container { 51 | min-width: 200px; 52 | 53 | &.loading .svg { 54 | position: absolute; 55 | top: -10000px; 56 | } 57 | } 58 | 59 | header { 60 | background: $green; 61 | background: linear-gradient(to bottom, $green 0%, $dark-green 100%); 62 | padding: rhythm(1); 63 | @include box-shadow; 64 | @include clearfix; 65 | 66 | .logo { 67 | display: inline-block; 68 | 69 | span { 70 | color: $gray; 71 | } 72 | } 73 | 74 | h1 { 75 | font-family: 'Bangers', 'cursive'; 76 | } 77 | 78 | nav { 79 | @include adjust-font-size-to(18px, 1); 80 | display: inline-block; 81 | margin-left: rhythm(1/4); 82 | padding-left: rhythm(1/4); 83 | } 84 | 85 | a { 86 | text-decoration: inherit; 87 | 88 | &:active, &:focus { 89 | text-decoration: underline; 90 | } 91 | } 92 | } 93 | 94 | #content { 95 | padding: rhythm(1); 96 | display: block; 97 | 98 | .copy { 99 | background-color: $tan; 100 | padding: rhythm(1/2); 101 | } 102 | 103 | .changelog { 104 | dt { 105 | font-weight: bold; 106 | } 107 | 108 | dd { 109 | &::before { 110 | content: '\00BB'; 111 | font-weight: bold; 112 | margin-right: rhythm(1/2); 113 | } 114 | } 115 | } 116 | 117 | .error { 118 | overflow: hidden; 119 | 120 | h1 { 121 | @include adjust-font-size-to($base-font-size * 2); 122 | font-weight: bold; 123 | float: left; 124 | } 125 | 126 | blockquote { 127 | background-color: $green; 128 | position: relative; 129 | padding: rhythm(1); 130 | display: inline-block; 131 | font-style: italic; 132 | float: right; 133 | 134 | &::before { 135 | @include adjust-font-size-to($base-font-size * 4); 136 | content: '\201c'; 137 | position: absolute; 138 | left: 0; 139 | top: 0; 140 | font-style: normal; 141 | } 142 | 143 | &::after { 144 | @include adjust-font-size-to($base-font-size * 4); 145 | content: '\201d'; 146 | position: absolute; 147 | right: 0; 148 | bottom: -0.5em; 149 | font-style: normal; 150 | } 151 | } 152 | 153 | p { 154 | clear: left; 155 | } 156 | } 157 | 158 | .documentation { 159 | h1 { 160 | @include adjust-font-size-to($base-font-size * 2); 161 | font-weight: bold; 162 | } 163 | 164 | h2 { 165 | @include adjust-font-size-to($base-font-size); 166 | font-weight: bold; 167 | } 168 | 169 | h3 { 170 | @include adjust-font-size-to($base-font-size); 171 | 172 | &::before { 173 | content: '\00BB'; 174 | font-weight: bold; 175 | margin-right: rhythm(1/4); 176 | } 177 | } 178 | 179 | h1, h2, h3 { 180 | clear: both; 181 | } 182 | 183 | h2, h3 { 184 | margin-bottom: rhythm(1); 185 | } 186 | 187 | section, div.section { 188 | margin: rhythm(1) 0; 189 | overflow: hidden; 190 | } 191 | 192 | p { 193 | margin: rhythm(1) 0; 194 | } 195 | 196 | figure { 197 | line-height: 0; 198 | background: $white; 199 | margin: rhythm(1/4); 200 | @include box-shadow; 201 | 202 | &.shift-right { 203 | float: right; 204 | margin-left: rhythm(1/2); 205 | } 206 | 207 | &.shift-left { 208 | float: left; 209 | margin-right: rhythm(1/2); 210 | } 211 | 212 | .svg { 213 | margin: 0; 214 | text-align: center; 215 | } 216 | 217 | figcaption { 218 | @include adjust-font-size-to($base-font-size); 219 | background: $green; 220 | font-weight: bold; 221 | padding: 0 rhythm(1/4); 222 | } 223 | } 224 | } 225 | 226 | .application { 227 | position: relative; 228 | @include clearfix; 229 | 230 | form { 231 | overflow: hidden; 232 | } 233 | 234 | textarea { 235 | @include adjust-font-size-to($base-font-size); 236 | border: 0 none; 237 | outline: none; 238 | background: $tan; 239 | padding: 0 0.5em; 240 | margin-bottom: 0.25em; 241 | width: 100% !important; // "!important" prevents user changing width 242 | box-sizing: border-box; 243 | font-family:Consolas,Monaco,Lucida Console,Liberation Mono,DejaVu Sans Mono,Bitstream Vera Sans Mono,Courier New, monospace; 244 | 245 | @include input-placeholder { 246 | color: $gray; 247 | } 248 | } 249 | 250 | button { 251 | @include adjust-font-size-to($base-font-size); 252 | width: 100px; 253 | border: 0 none; 254 | background: $green; 255 | background: linear-gradient(to bottom, $green 0%, $dark-green 100%); 256 | float: left; 257 | cursor: pointer; 258 | } 259 | 260 | ul { 261 | float: right; 262 | display: none; 263 | 264 | body.has-results & { 265 | display: inline-block; 266 | } 267 | 268 | &.hide-download-png.hide-permalink .download-svg:after, 269 | &.hide-permalink .download-png:after { 270 | display: none; 271 | } 272 | 273 | &.hide-permalink .permalink, 274 | &.hide-download-svg .download-svg, 275 | &.hide-download-png .download-png { 276 | display: none; 277 | } 278 | } 279 | } 280 | 281 | .results { 282 | margin-top: rhythm(1); 283 | display: none; 284 | 285 | body.has-results &, body.has-error &, body.is-loading & { 286 | display: block; 287 | } 288 | } 289 | } 290 | 291 | .progress { 292 | width: 50%; 293 | height: rhythm(1/2); 294 | border: 1px solid $dark-green; 295 | overflow: hidden; 296 | margin: rhythm(1) auto; 297 | 298 | div { 299 | background: $green; 300 | background: linear-gradient(135deg, $green 25%, $light-green 25%, $light-green 50%, $green 50%, $green 75%, $light-green 75%, $light-green 100%); 301 | background-size: rhythm(2) rhythm(2); 302 | background-repeat: repeat-x; 303 | height: 100%; 304 | animation: progress 1s infinite linear 305 | } 306 | } 307 | 308 | @keyframes progress { 309 | 0% { background-position-x: rhythm(2); } 310 | 100% { background-position-x: 0; } 311 | } 312 | 313 | #error { 314 | background: $red; 315 | color: $white; 316 | padding: 0 0.5em; 317 | white-space: pre; 318 | font-family: monospace; 319 | font-weight: bold; 320 | display: none; 321 | overflow-x: auto; 322 | 323 | body.has-error & { 324 | display: block; 325 | } 326 | } 327 | 328 | #warnings { 329 | @include adjust-font-size-to($base-font-size, 1); 330 | font-weight: bold; 331 | background-color: $yellow; 332 | display: none; 333 | 334 | li { 335 | margin: rhythm(1/4); 336 | } 337 | 338 | body.has-results & { 339 | display: block; 340 | } 341 | } 342 | 343 | #regexp-render { 344 | background: $white; 345 | width: 100%; 346 | overflow: auto; 347 | text-align: center; 348 | display: none; 349 | 350 | body.is-loading &, 351 | body.has-results & { 352 | display: block; 353 | } 354 | } 355 | 356 | #open-iconic { 357 | display: none; 358 | 359 | path { 360 | stroke: none; 361 | fill-opacity: 1; 362 | } 363 | } 364 | 365 | footer { 366 | padding: 0 rhythm(1); 367 | 368 | img { 369 | vertical-align: middle; 370 | width: 80px; 371 | height: 15px; 372 | } 373 | } 374 | -------------------------------------------------------------------------------- /spec/parser/javascript/repeat_spec.js: -------------------------------------------------------------------------------- 1 | import javascript from '../../../src/js/parser/javascript/parser.js'; 2 | import _ from 'lodash'; 3 | import Snap from 'snapsvg'; 4 | 5 | describe('parser/javascript/repeat.js', function() { 6 | 7 | _.forIn({ 8 | '*': { 9 | minimum: 0, 10 | maximum: -1, 11 | greedy: true, 12 | hasSkip: true, 13 | hasLoop: true 14 | }, 15 | '*?': { 16 | minimum: 0, 17 | maximum: -1, 18 | greedy: false, 19 | hasSkip: true, 20 | hasLoop: true 21 | }, 22 | '+': { 23 | minimum: 1, 24 | maximum: -1, 25 | greedy: true, 26 | hasSkip: false, 27 | hasLoop: true 28 | }, 29 | '+?': { 30 | minimum: 1, 31 | maximum: -1, 32 | greedy: false, 33 | hasSkip: false, 34 | hasLoop: true 35 | }, 36 | '?': { 37 | minimum: 0, 38 | maximum: 1, 39 | greedy: true, 40 | hasSkip: true, 41 | hasLoop: false 42 | }, 43 | '??': { 44 | minimum: 0, 45 | maximum: 1, 46 | greedy: false, 47 | hasSkip: true, 48 | hasLoop: false 49 | }, 50 | '{1}': { 51 | minimum: 1, 52 | maximum: 1, 53 | greedy: true, 54 | hasSkip: false, 55 | hasLoop: false 56 | }, 57 | '{0}': { 58 | minimum: 0, 59 | maximum: 0, 60 | greedy: true, 61 | hasSkip: true, 62 | hasLoop: false 63 | }, 64 | '{1}?': { 65 | minimum: 1, 66 | maximum: 1, 67 | greedy: false, 68 | hasSkip: false, 69 | hasLoop: false 70 | }, 71 | '{2}': { 72 | minimum: 2, 73 | maximum: 2, 74 | greedy: true, 75 | hasSkip: false, 76 | hasLoop: true 77 | }, 78 | '{2}?': { 79 | minimum: 2, 80 | maximum: 2, 81 | greedy: false, 82 | hasSkip: false, 83 | hasLoop: true 84 | }, 85 | '{0,}': { 86 | minimum: 0, 87 | maximum: -1, 88 | greedy: true, 89 | hasSkip: true, 90 | hasLoop: true 91 | }, 92 | '{0,}?': { 93 | minimum: 0, 94 | maximum: -1, 95 | greedy: false, 96 | hasSkip: true, 97 | hasLoop: true 98 | }, 99 | '{1,}': { 100 | minimum: 1, 101 | maximum: -1, 102 | greedy: true, 103 | hasSkip: false, 104 | hasLoop: true 105 | }, 106 | '{1,}?': { 107 | minimum: 1, 108 | maximum: -1, 109 | greedy: false, 110 | hasSkip: false, 111 | hasLoop: true 112 | }, 113 | '{0,1}': { 114 | minimum: 0, 115 | maximum: 1, 116 | greedy: true, 117 | hasSkip: true, 118 | hasLoop: false 119 | }, 120 | '{0,1}?': { 121 | minimum: 0, 122 | maximum: 1, 123 | greedy: false, 124 | hasSkip: true, 125 | hasLoop: false 126 | }, 127 | '{0,2}': { 128 | minimum: 0, 129 | maximum: 2, 130 | greedy: true, 131 | hasSkip: true, 132 | hasLoop: true 133 | }, 134 | '{0,2}?': { 135 | minimum: 0, 136 | maximum: 2, 137 | greedy: false, 138 | hasSkip: true, 139 | hasLoop: true 140 | }, 141 | '{1,2}': { 142 | minimum: 1, 143 | maximum: 2, 144 | greedy: true, 145 | hasSkip: false, 146 | hasLoop: true 147 | }, 148 | '{1,2}?': { 149 | minimum: 1, 150 | maximum: 2, 151 | greedy: false, 152 | hasSkip: false, 153 | hasLoop: true 154 | } 155 | }, (content, str) => { 156 | it(`parses "${str}" as a Repeat`, function() { 157 | var parser = new javascript.Parser(str); 158 | expect(parser.__consume__repeat()).toEqual(jasmine.objectContaining(content)); 159 | }); 160 | }); 161 | 162 | describe('contentPosition property', function() { 163 | 164 | beforeEach(function() { 165 | this.node = new javascript.Parser('*').__consume__repeat(); 166 | }); 167 | 168 | _.each([ 169 | { 170 | hasLoop: false, 171 | hasSkip: false, 172 | translate: { x: 0, y: 0 } 173 | }, 174 | { 175 | hasLoop: true, 176 | hasSkip: false, 177 | translate: { x: 10, y: 0 } 178 | }, 179 | { 180 | hasLoop: false, 181 | hasSkip: true, 182 | translate: { x: 15, y: 10 } 183 | }, 184 | { 185 | hasLoop: true, 186 | hasSkip: true, 187 | translate: { x: 15, y: 10 } 188 | } 189 | ], t => { 190 | it(`translates to [${t.translate.x}, ${t.translate.y}] when hasLoop is ${t.hasLoop} and hasSkip is ${t.hasSkip}`, function() { 191 | this.node.hasLoop = t.hasLoop; 192 | this.node.hasSkip = t.hasSkip; 193 | expect(this.node.contentPosition).toEqual(Snap.matrix() 194 | .translate(t.translate.x, t.translate.y)); 195 | }); 196 | }); 197 | 198 | }); 199 | 200 | describe('label property', function() { 201 | 202 | beforeEach(function() { 203 | this.node = new javascript.Parser('*').__consume__repeat(); 204 | }); 205 | 206 | _.each([ 207 | { 208 | minimum: 1, 209 | maximum: -1, 210 | label: undefined 211 | }, 212 | { 213 | minimum: 0, 214 | maximum: 0, 215 | label: undefined 216 | }, 217 | { 218 | minimum: 2, 219 | maximum: -1, 220 | label: '1+ times' 221 | }, 222 | { 223 | minimum: 3, 224 | maximum: -1, 225 | label: '2+ times' 226 | }, 227 | { 228 | minimum: 0, 229 | maximum: 2, 230 | label: 'at most once' 231 | }, 232 | { 233 | minimum: 0, 234 | maximum: 3, 235 | label: 'at most 2 times' 236 | }, 237 | { 238 | minimum: 2, 239 | maximum: 2, 240 | label: 'once' 241 | }, 242 | { 243 | minimum: 3, 244 | maximum: 3, 245 | label: '2 times' 246 | }, 247 | { 248 | minimum: 2, 249 | maximum: 3, 250 | label: '1\u20262 times' 251 | }, 252 | { 253 | minimum: 3, 254 | maximum: 4, 255 | label: '2\u20263 times' 256 | } 257 | 258 | ], t => { 259 | it(`is "${t.label}" when minimum=${t.minimum} and maximum=${t.maximum}`, function() { 260 | this.node.minimum = t.minimum; 261 | this.node.maximum = t.maximum; 262 | expect(this.node.label).toEqual(t.label); 263 | }); 264 | }); 265 | 266 | }); 267 | 268 | describe('tooltip property', function() { 269 | 270 | beforeEach(function() { 271 | this.node = new javascript.Parser('*').__consume__repeat(); 272 | }); 273 | 274 | _.each([ 275 | { 276 | minimum: 1, 277 | maximum: -1, 278 | tooltip: undefined 279 | }, 280 | { 281 | minimum: 0, 282 | maximum: 0, 283 | tooltip: undefined 284 | }, 285 | { 286 | minimum: 2, 287 | maximum: -1, 288 | tooltip: 'repeats 2+ times in total' 289 | }, 290 | { 291 | minimum: 3, 292 | maximum: -1, 293 | tooltip: 'repeats 3+ times in total' 294 | }, 295 | { 296 | minimum: 0, 297 | maximum: 2, 298 | tooltip: 'repeats at most 2 times in total' 299 | }, 300 | { 301 | minimum: 0, 302 | maximum: 3, 303 | tooltip: 'repeats at most 3 times in total' 304 | }, 305 | { 306 | minimum: 2, 307 | maximum: 2, 308 | tooltip: 'repeats 2 times in total' 309 | }, 310 | { 311 | minimum: 3, 312 | maximum: 3, 313 | tooltip: 'repeats 3 times in total' 314 | }, 315 | { 316 | minimum: 2, 317 | maximum: 3, 318 | tooltip: 'repeats 2\u20263 times in total' 319 | }, 320 | { 321 | minimum: 3, 322 | maximum: 4, 323 | tooltip: 'repeats 3\u20264 times in total' 324 | } 325 | 326 | ], t => { 327 | it(`is "${t.tooltip}" when minimum=${t.minimum} and maximum=${t.maximum}`, function() { 328 | this.node.minimum = t.minimum; 329 | this.node.maximum = t.maximum; 330 | expect(this.node.tooltip).toEqual(t.tooltip); 331 | }); 332 | }); 333 | 334 | }); 335 | 336 | describe('#skipPath', function() { 337 | 338 | beforeEach(function() { 339 | this.node = new javascript.Parser('*').__consume__repeat(); 340 | 341 | this.box = { 342 | y: 11, 343 | ay: 22, 344 | width: 33 345 | }; 346 | }); 347 | 348 | it('returns nothing when there is no skip', function() { 349 | this.node.hasSkip = false; 350 | expect(this.node.skipPath(this.box)).toEqual([]); 351 | }); 352 | 353 | it('returns a path when there is a skip', function() { 354 | this.node.hasSkip = true; 355 | this.node.greedy = true; 356 | expect(this.node.skipPath(this.box)).toEqual([ 357 | 'M0,22q10,0 10,-10v-1q0,-10 10,-10h23q10,0 10,10v1q0,10 10,10' 358 | ]); 359 | }); 360 | 361 | it('returns a path with arrow when there is a non-greedy skip', function() { 362 | this.node.hasSkip = true; 363 | this.node.greedy = false; 364 | expect(this.node.skipPath(this.box)).toEqual([ 365 | 'M0,22q10,0 10,-10v-1q0,-10 10,-10h23q10,0 10,10v1q0,10 10,10', 366 | 'M10,7l5,5m-5,-5l-5,5' 367 | ]); 368 | }); 369 | 370 | }); 371 | 372 | describe('#loopPath', function() { 373 | 374 | beforeEach(function() { 375 | this.node = new javascript.Parser('*').__consume__repeat(); 376 | 377 | this.box = { 378 | x: 11, 379 | x2: 22, 380 | ay: 33, 381 | y2: 44, 382 | width: 55 383 | }; 384 | }); 385 | 386 | it('returns nothing when there is no loop', function() { 387 | this.node.hasLoop = false; 388 | expect(this.node.loopPath(this.box)).toEqual([]); 389 | }); 390 | 391 | it('returns a path when there is a loop', function() { 392 | this.node.hasLoop = true; 393 | this.node.greedy = false; 394 | expect(this.node.loopPath(this.box)).toEqual([ 395 | 'M11,33q-10,0 -10,10v1q0,10 10,10h55q10,0 10,-10v-1q0,-10 -10,-10' 396 | ]); 397 | }); 398 | 399 | it('returns a path with arrow when there is a greedy loop', function() { 400 | this.node.hasLoop = true; 401 | this.node.greedy = true; 402 | expect(this.node.loopPath(this.box)).toEqual([ 403 | 'M11,33q-10,0 -10,10v1q0,10 10,10h55q10,0 10,-10v-1q0,-10 -10,-10', 404 | 'M32,48l5,-5m-5,5l-5,-5' 405 | ]); 406 | }); 407 | 408 | }); 409 | 410 | }); 411 | -------------------------------------------------------------------------------- /spec/parser/javascript/regexp_spec.js: -------------------------------------------------------------------------------- 1 | import javascript from '../../../src/js/parser/javascript/parser.js'; 2 | import util from '../../../src/js/util.js'; 3 | import _ from 'lodash'; 4 | import Snap from 'snapsvg'; 5 | 6 | describe('parser/javascript/regexp.js', function() { 7 | 8 | _.forIn({ 9 | 'test': { 10 | proxy: jasmine.objectContaining({ textValue: 'test' }) 11 | }, 12 | 'part 1|part 2': { 13 | matches: [ 14 | jasmine.objectContaining({ textValue: 'part 1' }), 15 | jasmine.objectContaining({ textValue: 'part 2' }) 16 | ] 17 | } 18 | }, (content, str) => { 19 | it(`parses "${str}" as a Regexp`, function() { 20 | var parser = new javascript.Parser(str); 21 | expect(parser.__consume__regexp()).toEqual(jasmine.objectContaining(content)); 22 | }); 23 | }); 24 | 25 | describe('#_render', function() { 26 | 27 | beforeEach(function() { 28 | var counter = 0; 29 | 30 | this.node = new javascript.Parser('a|b').__consume__regexp(); 31 | 32 | this.node.container = jasmine.createSpyObj('container', [ 33 | 'addClass', 34 | 'group', 35 | 'prepend', 36 | 'path' 37 | ]); 38 | 39 | this.group = jasmine.createSpyObj('group', [ 40 | 'addClass', 41 | 'transform', 42 | 'group', 43 | 'prepend', 44 | 'path', 45 | 'getBBox' 46 | ]); 47 | this.node.container.group.and.returnValue(this.group); 48 | this.group.addClass.and.returnValue(this.group); 49 | this.group.transform.and.returnValue(this.group); 50 | this.group.getBBox.and.returnValue('group bbox'); 51 | this.group.group.and.callFake(function() { 52 | return `group ${counter++}`; 53 | }); 54 | 55 | this.node.matches = [ 56 | jasmine.createSpyObj('match', ['render']), 57 | jasmine.createSpyObj('match', ['render']), 58 | jasmine.createSpyObj('match', ['render']) 59 | ]; 60 | 61 | this.matchDeferred = [ 62 | this.testablePromise(), 63 | this.testablePromise(), 64 | this.testablePromise() 65 | ]; 66 | 67 | this.node.matches[0].render.and.returnValue(this.matchDeferred[0].promise); 68 | this.node.matches[1].render.and.returnValue(this.matchDeferred[1].promise); 69 | this.node.matches[2].render.and.returnValue(this.matchDeferred[2].promise); 70 | 71 | spyOn(this.node, 'getBBox').and.returnValue('container bbox'); 72 | spyOn(this.node, 'makeCurve').and.returnValue('curve'); 73 | spyOn(this.node, 'makeSide').and.returnValue('side'); 74 | spyOn(this.node, 'makeConnector').and.returnValue('connector'); 75 | 76 | spyOn(util, 'spaceVertically'); 77 | }); 78 | 79 | it('creates a container for the match nodes', function() { 80 | this.node._render(); 81 | expect(this.node.container.group).toHaveBeenCalled(); 82 | expect(this.group.addClass).toHaveBeenCalledWith('regexp-matches'); 83 | expect(this.group.transform).toHaveBeenCalledWith(Snap.matrix() 84 | .translate(20, 0)); 85 | }); 86 | 87 | it('renders each match node', function() { 88 | this.node._render(); 89 | expect(this.node.matches[0].render).toHaveBeenCalledWith('group 0'); 90 | expect(this.node.matches[1].render).toHaveBeenCalledWith('group 1'); 91 | expect(this.node.matches[2].render).toHaveBeenCalledWith('group 2'); 92 | }); 93 | 94 | describe('positioning of the match nodes', function() { 95 | 96 | beforeEach(function() { 97 | this.matchDeferred[0].resolve(); 98 | this.matchDeferred[1].resolve(); 99 | this.matchDeferred[2].resolve(); 100 | }); 101 | 102 | it('spaces the nodes vertically', function(done) { 103 | this.node._render() 104 | .then(() => { 105 | expect(util.spaceVertically).toHaveBeenCalledWith(this.node.matches, { padding: 5 }); 106 | done(); 107 | }); 108 | }); 109 | 110 | it('renders the sides and curves into the container', function(done) { 111 | this.node._render() 112 | .then(() => { 113 | expect(this.node.makeCurve).toHaveBeenCalledWith('container bbox', this.node.matches[0]); 114 | expect(this.node.makeCurve).toHaveBeenCalledWith('container bbox', this.node.matches[1]); 115 | expect(this.node.makeCurve).toHaveBeenCalledWith('container bbox', this.node.matches[2]); 116 | expect(this.node.makeSide).toHaveBeenCalledWith('container bbox', this.node.matches[0]); 117 | expect(this.node.makeSide).toHaveBeenCalledWith('container bbox', this.node.matches[2]); 118 | expect(this.node.container.path).toHaveBeenCalledWith('curvecurvecurvesideside'); 119 | done(); 120 | }); 121 | }); 122 | 123 | it('renders the connectors into the match container', function(done) { 124 | this.node._render() 125 | .then(() => { 126 | expect(this.node.makeConnector).toHaveBeenCalledWith('group bbox', this.node.matches[0]); 127 | expect(this.node.makeConnector).toHaveBeenCalledWith('group bbox', this.node.matches[1]); 128 | expect(this.node.makeConnector).toHaveBeenCalledWith('group bbox', this.node.matches[2]); 129 | expect(this.group.path).toHaveBeenCalledWith('connectorconnectorconnector'); 130 | done(); 131 | }); 132 | }); 133 | 134 | }); 135 | 136 | }); 137 | 138 | describe('#madeSide', function() { 139 | 140 | beforeEach(function() { 141 | this.node = new javascript.Parser('a|b').__consume__regexp(); 142 | 143 | this.containerBox = { 144 | cy: 50, 145 | width: 30 146 | }; 147 | this.matchBox = { 148 | }; 149 | 150 | this.match = jasmine.createSpyObj('match', ['getBBox']); 151 | this.match.getBBox.and.returnValue(this.matchBox); 152 | }); 153 | 154 | describe('when the match node is 15px or more from the centerline', function() { 155 | 156 | describe('when the match node is above the centerline', function() { 157 | 158 | beforeEach(function() { 159 | this.matchBox.ay = 22; 160 | }); 161 | 162 | it('returns the vertical sideline to the match node', function() { 163 | expect(this.node.makeSide(this.containerBox, this.match)).toEqual([ 164 | 'M0,50q10,0 10,-10V32', 165 | 'M70,50q-10,0 -10,-10V32' 166 | ]); 167 | }); 168 | 169 | }); 170 | 171 | describe('when the match node is below the centerline', function() { 172 | 173 | beforeEach(function() { 174 | this.matchBox.ay = 88; 175 | }); 176 | 177 | it('returns the vertical sideline to the match node', function() { 178 | expect(this.node.makeSide(this.containerBox, this.match)).toEqual([ 179 | 'M0,50q10,0 10,10V78', 180 | 'M70,50q-10,0 -10,10V78' 181 | ]); 182 | }); 183 | 184 | }); 185 | 186 | }); 187 | 188 | describe('when the match node is less than 15px from the centerline', function() { 189 | 190 | beforeEach(function() { 191 | this.matchBox.ay = 44; 192 | }); 193 | 194 | it('returns nothing', function() { 195 | expect(this.node.makeSide(this.containerBox, this.match)).toBeUndefined(); 196 | }); 197 | 198 | }); 199 | 200 | }); 201 | 202 | describe('#makeCurve', function() { 203 | 204 | beforeEach(function() { 205 | this.node = new javascript.Parser('a|b').__consume__regexp(); 206 | 207 | this.containerBox = { 208 | cy: 50, 209 | width: 30 210 | }; 211 | this.matchBox = {}; 212 | 213 | this.match = jasmine.createSpyObj('match', ['getBBox']); 214 | this.match.getBBox.and.returnValue(this.matchBox); 215 | }); 216 | 217 | describe('when the match node is 15px or more from the centerline', function() { 218 | 219 | describe('when the match node is above the centerline', function() { 220 | 221 | beforeEach(function() { 222 | this.matchBox.ay = 22; 223 | }); 224 | 225 | it('returns the curve to the match node', function() { 226 | expect(this.node.makeCurve(this.containerBox, this.match)).toEqual([ 227 | 'M10,32q0,-10 10,-10', 228 | 'M60,32q0,-10 -10,-10' 229 | ]); 230 | }); 231 | 232 | }); 233 | 234 | describe('when the match node is below the centerline', function() { 235 | 236 | beforeEach(function() { 237 | this.matchBox.ay = 88; 238 | }); 239 | 240 | it('returns the curve to the match node', function() { 241 | expect(this.node.makeCurve(this.containerBox, this.match)).toEqual([ 242 | 'M10,78q0,10 10,10', 243 | 'M60,78q0,10 -10,10' 244 | ]); 245 | }); 246 | 247 | }); 248 | 249 | }); 250 | 251 | describe('when the match node is less than 15px from the centerline', function() { 252 | 253 | describe('when the match node is above the centerline', function() { 254 | 255 | beforeEach(function() { 256 | this.matchBox.ay = 44; 257 | }); 258 | 259 | it('returns the curve to the match node', function() { 260 | expect(this.node.makeCurve(this.containerBox, this.match)).toEqual([ 261 | 'M0,50c10,0 10,-6 20,-6', 262 | 'M70,50c-10,0 -10,-6 -20,-6' 263 | ]); 264 | }); 265 | 266 | }); 267 | 268 | describe('when the match node is below the centerline', function() { 269 | 270 | beforeEach(function() { 271 | this.matchBox.ay = 55; 272 | }); 273 | 274 | it('returns the curve to the match node', function() { 275 | expect(this.node.makeCurve(this.containerBox, this.match)).toEqual([ 276 | 'M0,50c10,0 10,5 20,5', 277 | 'M70,50c-10,0 -10,5 -20,5' 278 | ]); 279 | }); 280 | 281 | }); 282 | 283 | }); 284 | 285 | }); 286 | 287 | describe('#makeConnector', function() { 288 | 289 | beforeEach(function() { 290 | this.node = new javascript.Parser('a|b').__consume__regexp(); 291 | 292 | this.containerBox = { 293 | width: 4 294 | }; 295 | this.matchBox = { 296 | ay: 1, 297 | ax: 2, 298 | ax2: 3 299 | }; 300 | 301 | this.match = jasmine.createSpyObj('match', ['getBBox']); 302 | this.match.getBBox.and.returnValue(this.matchBox); 303 | }); 304 | 305 | it('returns a line from the curve to the match node', function() { 306 | expect(this.node.makeConnector(this.containerBox, this.match)).toEqual('M0,1h2M3,1H4'); 307 | }); 308 | 309 | }); 310 | 311 | }); 312 | -------------------------------------------------------------------------------- /src/js/regexper.js: -------------------------------------------------------------------------------- 1 | // The Regexper class manages the top-level behavior for the entire 2 | // application. This includes event handlers for all user interactions. 3 | 4 | import util from './util.js'; 5 | import Parser from './parser/javascript.js'; 6 | import _ from 'lodash'; 7 | 8 | export default class Regexper { 9 | constructor(root) { 10 | this.root = root; 11 | this.buggyHash = false; 12 | this.form = root.querySelector('#regexp-form'); 13 | this.field = root.querySelector('#regexp-input'); 14 | this.error = root.querySelector('#error'); 15 | this.warnings = root.querySelector('#warnings'); 16 | 17 | this.links = this.form.querySelector('ul'); 18 | this.permalink = this.links.querySelector('a[data-action="permalink"]'); 19 | this.downloadSvg = this.links.querySelector('a[data-action="download-svg"]'); 20 | this.downloadPng = this.links.querySelector('a[data-action="download-png"]'); 21 | 22 | this.svgContainer = root.querySelector('#regexp-render'); 23 | } 24 | 25 | // Event handler for key presses in the regular expression form field. 26 | keypressListener(event) { 27 | // Pressing Shift-Enter displays the expression. 28 | if (event.shiftKey && event.keyCode === 13) { 29 | event.returnValue = false; 30 | if (event.preventDefault) { 31 | event.preventDefault(); 32 | } 33 | 34 | this.form.dispatchEvent(util.customEvent('submit')); 35 | } 36 | } 37 | 38 | // Event handler for key presses while focused anywhere in the application. 39 | documentKeypressListener(event) { 40 | // Pressing escape will cancel a currently running render. 41 | if (event.keyCode === 27 && this.running) { 42 | this.running.cancel(); 43 | } 44 | } 45 | 46 | // Event handler for submission of the regular expression. Changes the URL 47 | // hash which leads to the expression being rendered. 48 | submitListener(event) { 49 | event.returnValue = false; 50 | if (event.preventDefault) { 51 | event.preventDefault(); 52 | } 53 | 54 | try { 55 | this._setHash(this.field.value); 56 | } 57 | catch(e) { 58 | // Failed to set the URL hash (probably because the expression is too 59 | // long). Turn off display of the permalink and just show the expression. 60 | this.permalinkEnabled = false; 61 | this.showExpression(this.field.value); 62 | } 63 | } 64 | 65 | // Event handler for URL hash changes. Starts rendering of the expression. 66 | hashchangeListener() { 67 | let expr = this._getHash(); 68 | 69 | if (expr instanceof Error) { 70 | this.state = 'has-error'; 71 | this.error.innerHTML = 'Malformed expression in URL'; 72 | util.track('send', 'event', 'visualization', 'malformed URL'); 73 | } else { 74 | this.permalinkEnabled = true; 75 | this.showExpression(expr); 76 | } 77 | } 78 | 79 | // Binds all event listeners. 80 | bindListeners() { 81 | this.field.addEventListener('keypress', this.keypressListener.bind(this)); 82 | this.form.addEventListener('submit', this.submitListener.bind(this)); 83 | this.root.addEventListener('keyup', this.documentKeypressListener.bind(this)); 84 | window.addEventListener('hashchange', this.hashchangeListener.bind(this)); 85 | } 86 | 87 | // Detect if https://bugzilla.mozilla.org/show_bug.cgi?id=483304 is in effect 88 | detectBuggyHash() { 89 | if (typeof window.URL === 'function') { 90 | try { 91 | let url = new URL('http://regexper.com/#%25'); 92 | this.buggyHash = (url.hash === '#%'); 93 | } 94 | catch(e) { 95 | this.buggyHash = false; 96 | } 97 | } 98 | } 99 | 100 | // Set the URL hash. This method exists to facilitate automated testing 101 | // (since changing the URL can throw off most JavaScript testing tools). 102 | _setHash(hash) { 103 | location.hash = encodeURIComponent(hash) 104 | .replace(/\(/g, '%28') 105 | .replace(/\)/g, '%29'); 106 | } 107 | 108 | // Retrieve the current URL hash. This method is also mostly for supporting 109 | // automated testing, but also does some basic error handling for malformed 110 | // URLs. 111 | _getHash() { 112 | try { 113 | let hash = location.hash.slice(1) 114 | return this.buggyHash ? hash : decodeURIComponent(hash); 115 | } 116 | catch(e) { 117 | return e; 118 | } 119 | } 120 | 121 | // Currently state of the application. Useful values are: 122 | // - `''` - State of the application when the page initially loads 123 | // - `'is-loading'` - Displays the loading indicator 124 | // - `'has-error'` - Displays the error message 125 | // - `'has-results'` - Displays rendered results 126 | set state(state) { 127 | this.root.className = state; 128 | } 129 | 130 | get state() { 131 | return this.root.className; 132 | } 133 | 134 | // Start the rendering of a regular expression. 135 | // 136 | // - __expression__ - Regular expression to display. 137 | showExpression(expression) { 138 | this.field.value = expression; 139 | this.state = ''; 140 | 141 | if (expression !== '') { 142 | this.renderRegexp(expression).catch(util.exposeError); 143 | } 144 | } 145 | 146 | // Creates a blob URL for linking to a rendered regular expression image. 147 | // 148 | // - __content__ - SVG image markup. 149 | buildBlobURL(content) { 150 | // Blob object has to stick around for IE, so the instance is stored on the 151 | // `window` object. 152 | window.blob = new Blob([content], { type: 'image/svg+xml' }); 153 | return URL.createObjectURL(window.blob); 154 | } 155 | 156 | // Update the URLs of the 'download' and 'permalink' links. 157 | updateLinks() { 158 | let classes = _.without(this.links.className.split(' '), ['hide-download-svg', 'hide-permalink']); 159 | let svg = this.svgContainer.querySelector('.svg'); 160 | 161 | // Create the SVG 'download' image URL. 162 | try { 163 | this.downloadSvg.parentNode.style.display = null; 164 | this.downloadSvg.href = this.buildBlobURL(svg.innerHTML); 165 | } 166 | catch(e) { 167 | // Blobs or URLs created from a blob URL don't work in the current 168 | // browser. Giving up on the download link. 169 | classes.push('hide-download-svg'); 170 | } 171 | 172 | //Create the PNG 'download' image URL. 173 | try { 174 | let canvas = document.createElement('canvas'); 175 | let context = canvas.getContext('2d'); 176 | let loader = new Image; 177 | 178 | loader.width = canvas.width = Number(svg.querySelector('svg').getAttribute('width')); 179 | loader.height = canvas.height = Number(svg.querySelector('svg').getAttribute('height')); 180 | loader.onload = () => { 181 | try { 182 | context.drawImage(loader, 0, 0, loader.width, loader.height); 183 | canvas.toBlob(blob => { 184 | try { 185 | window.pngBlob = blob; 186 | this.downloadPng.href = URL.createObjectURL(window.pngBlob); 187 | this.links.className = this.links.className.replace(/\bhide-download-png\b/, ''); 188 | } 189 | catch(e) {} 190 | }, 'image/png'); 191 | } 192 | catch(e) {} 193 | }; 194 | loader.src = 'data:image/svg+xml,' + encodeURIComponent(svg.innerHTML); 195 | classes.push('hide-download-png'); 196 | } 197 | catch(e) {} 198 | 199 | // Create the 'permalink' URL. 200 | if (this.permalinkEnabled) { 201 | this.permalink.parentNode.style.display = null; 202 | this.permalink.href = location.toString(); 203 | } else { 204 | classes.push('hide-permalink'); 205 | } 206 | 207 | this.links.className = classes.join(' '); 208 | } 209 | 210 | // Display any warnings that were generated while rendering a regular expression. 211 | // 212 | // - __warnings__ - Array of warning messages to display. 213 | displayWarnings(warnings) { 214 | this.warnings.innerHTML = _.map(warnings, warning => ( 215 | `
  • ${util.icon("#warning")}${warning}
  • ` 216 | )).join(''); 217 | } 218 | 219 | // Render regular expression 220 | // 221 | // - __expression__ - Regular expression to render 222 | renderRegexp(expression) { 223 | let parseError = false, 224 | startTime, endTime; 225 | 226 | // When a render is already in progress, cancel it and try rendering again 227 | // after a short delay (canceling a render is not instantaneous). 228 | if (this.running) { 229 | this.running.cancel(); 230 | 231 | return util.wait(10).then(() => this.renderRegexp(expression)); 232 | } 233 | 234 | this.state = 'is-loading'; 235 | util.track('send', 'event', 'visualization', 'start'); 236 | startTime = new Date().getTime(); 237 | 238 | this.running = new Parser(this.svgContainer); 239 | 240 | return this.running 241 | // Parse the expression. 242 | .parse(expression) 243 | // Display any error messages from the parser and abort the render. 244 | .catch(message => { 245 | this.state = 'has-error'; 246 | this.error.innerHTML = ''; 247 | this.error.appendChild(document.createTextNode(message)); 248 | 249 | parseError = true; 250 | 251 | throw message; 252 | }) 253 | // When parsing is successful, render the parsed expression. 254 | .then(parser => parser.render()) 255 | // Once rendering is complete: 256 | // - Update links 257 | // - Display any warnings 258 | // - Track the completion of the render and how long it took 259 | .then(() => { 260 | this.state = 'has-results'; 261 | this.updateLinks(); 262 | this.displayWarnings(this.running.warnings); 263 | util.track('send', 'event', 'visualization', 'complete'); 264 | 265 | endTime = new Date().getTime(); 266 | util.track('send', 'timing', 'visualization', 'total time', endTime - startTime); 267 | }) 268 | // Handle any errors that happened during the rendering pipeline. 269 | // Swallows parse errors and render cancellations. Any other exceptions 270 | // are allowed to continue on to be tracked by the global error handler. 271 | .catch(message => { 272 | if (message === 'Render cancelled') { 273 | util.track('send', 'event', 'visualization', 'cancelled'); 274 | this.state = ''; 275 | } else if (parseError) { 276 | util.track('send', 'event', 'visualization', 'parse error'); 277 | } else { 278 | throw message; 279 | } 280 | }) 281 | // Finally, mark rendering as complete (and pass along any exceptions 282 | // that were thrown). 283 | .then( 284 | () => { 285 | this.running = false; 286 | }, 287 | message => { 288 | this.running = false; 289 | throw message; 290 | } 291 | ); 292 | } 293 | } 294 | -------------------------------------------------------------------------------- /spec/parser/javascript/node_spec.js: -------------------------------------------------------------------------------- 1 | import Node from '../../../src/js/parser/javascript/node.js'; 2 | import Snap from 'snapsvg'; 3 | 4 | describe('parser/javascript/node.js', function() { 5 | 6 | beforeEach(function() { 7 | Node.state = {}; 8 | this.node = new Node(); 9 | }); 10 | 11 | it('references the state from Node.state', function() { 12 | Node.state.example = 'example state'; 13 | expect(this.node.state.example).toEqual('example state'); 14 | }); 15 | 16 | describe('module setter', function() { 17 | 18 | it('extends the node with the module', function() { 19 | this.node.module = { example: 'value' }; 20 | expect(this.node.example).toEqual('value'); 21 | }); 22 | 23 | it('calls the module #setup method', function() { 24 | var setup = jasmine.createSpy('setup'); 25 | this.node.module = { setup }; 26 | expect(setup).toHaveBeenCalled(); 27 | }); 28 | 29 | it('sets up any defined properties and removes \'definedProperties\' field', function() { 30 | this.node.module = { 31 | definedProperties: { 32 | example: { 33 | get: function() { 34 | return 'value'; 35 | } 36 | } 37 | } 38 | }; 39 | expect(this.node.example).toEqual('value'); 40 | expect(this.node.definedProperties).toBeUndefined(); 41 | }); 42 | 43 | }); 44 | 45 | describe('container setter', function() { 46 | 47 | it('adds a class to the container element', function() { 48 | var container = jasmine.createSpyObj('container', ['addClass']); 49 | this.node.type = 'example type'; 50 | this.node.container = container; 51 | expect(container.addClass).toHaveBeenCalledWith('example type'); 52 | }); 53 | 54 | }); 55 | 56 | describe('anchor getter', function() { 57 | 58 | describe('when a proxy node is used', function() { 59 | 60 | it('returns the anchor from the proxy', function() { 61 | this.node.proxy = { anchor: 'example anchor' }; 62 | expect(this.node.anchor).toEqual('example anchor'); 63 | }); 64 | 65 | }); 66 | 67 | describe('when a proxy node is not used', function() { 68 | 69 | it('returns _anchor of the node', function() { 70 | this.node._anchor = { example: 'value' }; 71 | expect(this.node.anchor).toEqual({ 72 | example: 'value' 73 | }); 74 | }); 75 | 76 | }); 77 | 78 | }); 79 | 80 | describe('#getBBox', function() { 81 | 82 | it('returns the normalized bbox of the container merged with the anchor', function() { 83 | this.node.proxy = { 84 | anchor: { 85 | anchor: 'example anchor' 86 | } 87 | }; 88 | this.node.container = jasmine.createSpyObj('container', ['addClass', 'getBBox']); 89 | this.node.container.getBBox.and.returnValue({ 90 | bbox: 'example bbox', 91 | x: 'left', 92 | x2: 'right', 93 | cy: 'center' 94 | }); 95 | expect(this.node.getBBox()).toEqual({ 96 | bbox: 'example bbox', 97 | anchor: 'example anchor', 98 | x: 'left', 99 | x2: 'right', 100 | cy: 'center', 101 | ax: 'left', 102 | ax2: 'right', 103 | ay: 'center' 104 | }); 105 | }); 106 | 107 | }); 108 | 109 | describe('#transform', function() { 110 | 111 | it('returns the result of calling transform on the container', function() { 112 | this.node.container = jasmine.createSpyObj('container', ['addClass', 'transform']); 113 | this.node.container.transform.and.returnValue('transform result'); 114 | expect(this.node.transform('matrix')).toEqual('transform result'); 115 | expect(this.node.container.transform).toHaveBeenCalledWith('matrix'); 116 | }); 117 | 118 | }); 119 | 120 | describe('#deferredStep', function() { 121 | 122 | it('resolves the returned promise when the render is not canceled', function(done) { 123 | var resolve = jasmine.createSpy('resolve'), 124 | reject = jasmine.createSpy('reject'); 125 | 126 | this.node.deferredStep('result') 127 | .then(resolve, reject) 128 | .then(() => { 129 | expect(resolve).toHaveBeenCalledWith('result'); 130 | expect(reject).not.toHaveBeenCalled(); 131 | done(); 132 | }); 133 | }); 134 | 135 | it('rejects the returned promise when the render is canceled', function(done) { 136 | var resolve = jasmine.createSpy('resolve'), 137 | reject = jasmine.createSpy('reject'); 138 | 139 | this.node.state.cancelRender = true; 140 | this.node.deferredStep('result', 'value') 141 | .then(resolve, reject) 142 | .then(() => { 143 | expect(resolve).not.toHaveBeenCalled(); 144 | expect(reject).toHaveBeenCalledWith('Render cancelled'); 145 | done(); 146 | }); 147 | }); 148 | 149 | }); 150 | 151 | describe('#renderLabel', function() { 152 | 153 | beforeEach(function() { 154 | this.group = jasmine.createSpyObj('group', ['addClass', 'rect', 'text']); 155 | this.group.addClass.and.returnValue(this.group); 156 | 157 | this.node.container = jasmine.createSpyObj('container', ['addClass', 'group']); 158 | this.node.container.group.and.returnValue(this.group); 159 | }); 160 | 161 | it('adds a "label" class to the group', function() { 162 | this.node.renderLabel('example label'); 163 | expect(this.group.addClass).toHaveBeenCalledWith('label'); 164 | }); 165 | 166 | it('creates a rect element', function() { 167 | this.node.renderLabel('example label'); 168 | expect(this.group.rect).toHaveBeenCalled(); 169 | }); 170 | 171 | it('creates a text element', function() { 172 | this.node.renderLabel('example label'); 173 | expect(this.group.text).toHaveBeenCalledWith(0, 0, ['example label']); 174 | }); 175 | 176 | describe('positioning of label elements', function() { 177 | 178 | beforeEach(function() { 179 | this.text = jasmine.createSpyObj('text', ['getBBox', 'transform']); 180 | this.rect = jasmine.createSpyObj('rect', ['attr']); 181 | 182 | this.text.getBBox.and.returnValue({ 183 | width: 42, 184 | height: 24 185 | }); 186 | 187 | this.group.text.and.returnValue(this.text); 188 | this.group.rect.and.returnValue(this.rect); 189 | }); 190 | 191 | it('transforms the text element', function(done) { 192 | this.node.renderLabel('example label') 193 | .then(() => { 194 | expect(this.text.transform).toHaveBeenCalledWith(Snap.matrix() 195 | .translate(5, 22)); 196 | done(); 197 | }); 198 | }); 199 | 200 | it('sets the dimensions of the rect element', function(done) { 201 | this.node.renderLabel('example label') 202 | .then(() => { 203 | expect(this.rect.attr).toHaveBeenCalledWith({ 204 | width: 52, 205 | height: 34 206 | }); 207 | done(); 208 | }); 209 | }); 210 | 211 | it('resolves with the group element', function(done) { 212 | this.node.renderLabel('example label') 213 | .then(group => { 214 | expect(group).toEqual(this.group); 215 | done(); 216 | }); 217 | }); 218 | 219 | }); 220 | 221 | }); 222 | 223 | describe('#render', function() { 224 | 225 | beforeEach(function() { 226 | this.container = jasmine.createSpyObj('container', ['addClass']); 227 | }); 228 | 229 | describe('when a proxy node is used', function() { 230 | 231 | beforeEach(function() { 232 | this.node.proxy = jasmine.createSpyObj('proxy', ['render']); 233 | this.node.proxy.render.and.returnValue('example proxy result'); 234 | }); 235 | 236 | it('sets the container', function() { 237 | this.node.render(this.container); 238 | expect(this.node.container).toEqual(this.container); 239 | }); 240 | 241 | it('calls the proxy render method', function() { 242 | expect(this.node.render(this.container)).toEqual('example proxy result'); 243 | expect(this.node.proxy.render).toHaveBeenCalledWith(this.container); 244 | }); 245 | 246 | }); 247 | 248 | describe('when a proxy node is not used', function() { 249 | 250 | beforeEach(function() { 251 | this.deferred = this.testablePromise(); 252 | this.node._render = jasmine.createSpy('_render').and.returnValue(this.deferred.promise); 253 | }); 254 | 255 | it('sets the container', function() { 256 | this.node.render(this.container); 257 | expect(this.node.container).toEqual(this.container); 258 | }); 259 | 260 | it('increments the renderCounter', function() { 261 | this.node.state.renderCounter = 0; 262 | this.node.render(this.container); 263 | expect(this.node.state.renderCounter).toEqual(1); 264 | }); 265 | 266 | it('calls #_render', function() { 267 | this.node.render(this.container); 268 | expect(this.node._render).toHaveBeenCalled(); 269 | }); 270 | 271 | describe('when #_render is complete', function() { 272 | 273 | it('decrements the renderCounter', function(done) { 274 | this.node.render(this.container) 275 | .then(() => { 276 | expect(this.node.state.renderCounter).toEqual(41); 277 | done(); 278 | }); 279 | this.node.state.renderCounter = 42; 280 | this.deferred.resolve(); 281 | }); 282 | 283 | it('ultimately resolves with the node instance', function(done) { 284 | this.deferred.resolve(); 285 | this.node.render(this.container) 286 | .then(result => { 287 | expect(result).toEqual(this.node); 288 | done(); 289 | }); 290 | }); 291 | 292 | }); 293 | 294 | }); 295 | 296 | }); 297 | 298 | describe('#renderLabeledBox', function() { 299 | 300 | beforeEach(function() { 301 | var svg = Snap(document.createElement('svg')); 302 | 303 | this.text = svg.text(); 304 | this.rect = svg.rect(); 305 | this.content = svg.rect(); 306 | 307 | this.node.container = jasmine.createSpyObj('container', ['addClass', 'text', 'rect', 'prepend']); 308 | this.node.container.text.and.returnValue(this.text); 309 | this.node.container.rect.and.returnValue(this.rect); 310 | 311 | this.node.type = 'example-type'; 312 | }); 313 | 314 | it('creates a text element', function() { 315 | this.node.renderLabeledBox('example label', this.content, { padding: 5 }); 316 | expect(this.node.container.text).toHaveBeenCalledWith(0, 0, ['example label']); 317 | }); 318 | 319 | it('sets the class on the text element', function() { 320 | spyOn(this.text, 'addClass').and.callThrough(); 321 | this.node.renderLabeledBox('example label', this.content, { padding: 5 }); 322 | expect(this.text.addClass).toHaveBeenCalledWith('example-type-label'); 323 | }); 324 | 325 | it('creates a rect element', function() { 326 | this.node.renderLabeledBox('example label', this.content, { padding: 5 }); 327 | expect(this.node.container.rect).toHaveBeenCalled(); 328 | }); 329 | 330 | it('sets the class on the rect element', function() { 331 | spyOn(this.rect, 'addClass').and.callThrough(); 332 | this.node.renderLabeledBox('example label', this.content, { padding: 5 }); 333 | expect(this.rect.addClass).toHaveBeenCalledWith('example-type-box'); 334 | }); 335 | 336 | it('sets the corner radius on the rect element', function() { 337 | spyOn(this.rect, 'attr').and.callThrough(); 338 | this.node.renderLabeledBox('example label', this.content, { padding: 5 }); 339 | expect(this.rect.attr).toHaveBeenCalledWith({ 340 | rx: 3, 341 | ry: 3 342 | }); 343 | }); 344 | 345 | describe('positioning of elements', function() { 346 | 347 | beforeEach(function() { 348 | spyOn(this.text, 'getBBox').and.returnValue({ 349 | width: 100, 350 | height: 20 351 | }); 352 | spyOn(this.content, 'getBBox').and.returnValue({ 353 | width: 200, 354 | height: 100, 355 | cx: 100 356 | }); 357 | }); 358 | 359 | it('positions the text element', function(done) { 360 | spyOn(this.text, 'transform').and.callThrough(); 361 | this.node.renderLabeledBox('example label', this.content, { padding: 5 }) 362 | .then(() => { 363 | expect(this.text.transform).toHaveBeenCalledWith(Snap.matrix() 364 | .translate(0, 20)); 365 | done(); 366 | }); 367 | }); 368 | 369 | it('positions the rect element', function(done) { 370 | spyOn(this.rect, 'transform').and.callThrough(); 371 | this.node.renderLabeledBox('example label', this.content, { padding: 5 }) 372 | .then(() => { 373 | expect(this.rect.transform).toHaveBeenCalledWith(Snap.matrix() 374 | .translate(0, 20)); 375 | done(); 376 | }); 377 | }); 378 | 379 | it('sets the dimensions of the rect element', function(done) { 380 | spyOn(this.rect, 'attr').and.callThrough(); 381 | this.node.renderLabeledBox('example label', this.content, { padding: 5 }) 382 | .then(() => { 383 | expect(this.rect.attr).toHaveBeenCalledWith({ 384 | width: 210, 385 | height: 110 386 | }); 387 | done(); 388 | }); 389 | }); 390 | 391 | it('sets the dimensions of the rect element (based on the text element)', function(done) { 392 | this.content.getBBox.and.returnValue({ 393 | width: 50, 394 | height: 100, 395 | cx: 25 396 | }); 397 | spyOn(this.rect, 'attr').and.callThrough(); 398 | this.node.renderLabeledBox('example label', this.content, { padding: 5 }) 399 | .then(() => { 400 | expect(this.rect.attr).toHaveBeenCalledWith({ 401 | width: 100, 402 | height: 110 403 | }); 404 | done(); 405 | }); 406 | }); 407 | 408 | it('positions the content element', function(done) { 409 | spyOn(this.content, 'transform').and.callThrough(); 410 | this.node.renderLabeledBox('example label', this.content, { padding: 5 }) 411 | .then(() => { 412 | expect(this.content.transform).toHaveBeenCalledWith(Snap.matrix() 413 | .translate(5, 25)); 414 | done(); 415 | }); 416 | }); 417 | 418 | }); 419 | 420 | }); 421 | 422 | }); 423 | --------------------------------------------------------------------------------