diff --git a/Gruntfile.js b/Gruntfile.js index d7b84ec577..7c1a41bce3 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -183,8 +183,16 @@ var path = require('path'), src: ['core/test/unit/**/api*_spec.js'] }, - frontend: { - src: ['core/test/unit/**/frontend*_spec.js'] + client: { + src: ['core/test/unit/**/client*_spec.js'] + }, + + server: { + src: ['core/test/unit/**/server*_spec.js'] + }, + + shared: { + src: ['core/test/unit/**/shared*_spec.js'] }, perm: { diff --git a/core/client/assets/vendor/codemirror/addon/mode/overlay.js b/core/client/assets/vendor/codemirror/addon/mode/overlay.js new file mode 100644 index 0000000000..b7928a7bbf --- /dev/null +++ b/core/client/assets/vendor/codemirror/addon/mode/overlay.js @@ -0,0 +1,59 @@ +// Utility function that allows modes to be combined. The mode given +// as the base argument takes care of most of the normal mode +// functionality, but a second (typically simple) mode is used, which +// can override the style of text. Both modes get to parse all of the +// text, but when both assign a non-null style to a piece of code, the +// overlay wins, unless the combine argument was true, in which case +// the styles are combined. + +// overlayParser is the old, deprecated name +CodeMirror.overlayMode = CodeMirror.overlayParser = function(base, overlay, combine) { + return { + startState: function() { + return { + base: CodeMirror.startState(base), + overlay: CodeMirror.startState(overlay), + basePos: 0, baseCur: null, + overlayPos: 0, overlayCur: null + }; + }, + copyState: function(state) { + return { + base: CodeMirror.copyState(base, state.base), + overlay: CodeMirror.copyState(overlay, state.overlay), + basePos: state.basePos, baseCur: null, + overlayPos: state.overlayPos, overlayCur: null + }; + }, + + token: function(stream, state) { + if (stream.start == state.basePos) { + state.baseCur = base.token(stream, state.base); + state.basePos = stream.pos; + } + if (stream.start == state.overlayPos) { + stream.pos = stream.start; + state.overlayCur = overlay.token(stream, state.overlay); + state.overlayPos = stream.pos; + } + stream.pos = Math.min(state.basePos, state.overlayPos); + if (stream.eol()) state.basePos = state.overlayPos = 0; + + if (state.overlayCur == null) return state.baseCur; + if (state.baseCur != null && combine) return state.baseCur + " " + state.overlayCur; + else return state.overlayCur; + }, + + indent: base.indent && function(state, textAfter) { + return base.indent(state.base, textAfter); + }, + electricChars: base.electricChars, + + innerMode: function(state) { return {state: state.base, mode: base}; }, + + blankLine: function(state) { + if (base.blankLine) base.blankLine(state.base); + if (overlay.blankLine) overlay.blankLine(state.overlay); + } + }; +}; diff --git a/core/client/assets/vendor/codemirror/mode/gfm/gfm.js b/core/client/assets/vendor/codemirror/mode/gfm/gfm.js new file mode 100644 index 0000000000..1179b53dc6 --- /dev/null +++ b/core/client/assets/vendor/codemirror/mode/gfm/gfm.js @@ -0,0 +1,96 @@ +CodeMirror.defineMode("gfm", function(config) { + var codeDepth = 0; + function blankLine(state) { + state.code = false; + return null; + } + var gfmOverlay = { + startState: function() { + return { + code: false, + codeBlock: false, + ateSpace: false + }; + }, + copyState: function(s) { + return { + code: s.code, + codeBlock: s.codeBlock, + ateSpace: s.ateSpace + }; + }, + token: function(stream, state) { + // Hack to prevent formatting override inside code blocks (block and inline) + if (state.codeBlock) { + if (stream.match(/^```/)) { + state.codeBlock = false; + return null; + } + stream.skipToEnd(); + return null; + } + if (stream.sol()) { + state.code = false; + } + if (stream.sol() && stream.match(/^```/)) { + stream.skipToEnd(); + state.codeBlock = true; + return null; + } + // If this block is changed, it may need to be updated in Markdown mode + if (stream.peek() === '`') { + stream.next(); + var before = stream.pos; + stream.eatWhile('`'); + var difference = 1 + stream.pos - before; + if (!state.code) { + codeDepth = difference; + state.code = true; + } else { + if (difference === codeDepth) { // Must be exact + state.code = false; + } + } + return null; + } else if (state.code) { + stream.next(); + return null; + } + // Check if space. If so, links can be formatted later on + if (stream.eatSpace()) { + state.ateSpace = true; + return null; + } + if (stream.sol() || state.ateSpace) { + state.ateSpace = false; + if(stream.match(/^(?:[a-zA-Z0-9\-_]+\/)?(?:[a-zA-Z0-9\-_]+@)?(?:[a-f0-9]{7,40}\b)/)) { + // User/Project@SHA + // User@SHA + // SHA + return "link"; + } else if (stream.match(/^(?:[a-zA-Z0-9\-_]+\/)?(?:[a-zA-Z0-9\-_]+)?#[0-9]+\b/)) { + // User/Project#Num + // User#Num + // #Num + return "link"; + } + } + if (stream.match(/^((?:[a-z][\w-]+:(?:\/{1,3}|[a-z0-9%])|www\d{0,3}[.]|[a-z0-9.\-]+[.][a-z]{2,4}\/)(?:[^\s()<>]+|\([^\s()<>]*\))+(?:\([^\s()<>]*\)|[^\s`!()\[\]{};:'".,<>?«»“”‘’]))/i)) { + // URLs + // Taken from http://daringfireball.net/2010/07/improved_regex_for_matching_urls + // And then (issue #1160) simplified to make it not crash the Chrome Regexp engine + return "link"; + } + stream.next(); + return null; + }, + blankLine: blankLine + }; + CodeMirror.defineMIME("gfmBase", { + name: "markdown", + underscoresBreakWords: false, + taskLists: true, + fencedCodeBlocks: true + }); + return CodeMirror.overlayMode(CodeMirror.getMode(config, "gfmBase"), gfmOverlay); +}, "markdown"); diff --git a/core/client/assets/vendor/codemirror/mode/gfm/index.html b/core/client/assets/vendor/codemirror/mode/gfm/index.html new file mode 100644 index 0000000000..826a96d2de --- /dev/null +++ b/core/client/assets/vendor/codemirror/mode/gfm/index.html @@ -0,0 +1,74 @@ + + + + + CodeMirror: GFM mode + + + + + + + + + + + + + + + + + +

CodeMirror: GFM mode

+ +
+ + + +

Optionally depends on other modes for properly highlighted code blocks.

+ +

Parsing/Highlighting Tests: normal, verbose.

+ + + diff --git a/core/client/assets/vendor/codemirror/mode/gfm/test.js b/core/client/assets/vendor/codemirror/mode/gfm/test.js new file mode 100644 index 0000000000..3ccaec5010 --- /dev/null +++ b/core/client/assets/vendor/codemirror/mode/gfm/test.js @@ -0,0 +1,112 @@ +(function() { + var mode = CodeMirror.getMode({tabSize: 4}, "gfm"); + function MT(name) { test.mode(name, mode, Array.prototype.slice.call(arguments, 1)); } + + MT("emInWordAsterisk", + "foo[em *bar*]hello"); + + MT("emInWordUnderscore", + "foo_bar_hello"); + + MT("emStrongUnderscore", + "[strong __][em&strong _foo__][em _] bar"); + + MT("fencedCodeBlocks", + "[comment ```]", + "[comment foo]", + "", + "[comment ```]", + "bar"); + + MT("fencedCodeBlockModeSwitching", + "[comment ```javascript]", + "[variable foo]", + "", + "[comment ```]", + "bar"); + + MT("taskListAsterisk", + "[variable-2 * []] foo]", // Invalid; must have space or x between [] + "[variable-2 * [ ]]bar]", // Invalid; must have space after ] + "[variable-2 * [x]]hello]", // Invalid; must have space after ] + "[variable-2 * ][meta [ ]]][variable-2 [world]]]", // Valid; tests reference style links + " [variable-3 * ][property [x]]][variable-3 foo]"); // Valid; can be nested + + MT("taskListPlus", + "[variable-2 + []] foo]", // Invalid; must have space or x between [] + "[variable-2 + [ ]]bar]", // Invalid; must have space after ] + "[variable-2 + [x]]hello]", // Invalid; must have space after ] + "[variable-2 + ][meta [ ]]][variable-2 [world]]]", // Valid; tests reference style links + " [variable-3 + ][property [x]]][variable-3 foo]"); // Valid; can be nested + + MT("taskListDash", + "[variable-2 - []] foo]", // Invalid; must have space or x between [] + "[variable-2 - [ ]]bar]", // Invalid; must have space after ] + "[variable-2 - [x]]hello]", // Invalid; must have space after ] + "[variable-2 - ][meta [ ]]][variable-2 [world]]]", // Valid; tests reference style links + " [variable-3 - ][property [x]]][variable-3 foo]"); // Valid; can be nested + + MT("taskListNumber", + "[variable-2 1. []] foo]", // Invalid; must have space or x between [] + "[variable-2 2. [ ]]bar]", // Invalid; must have space after ] + "[variable-2 3. [x]]hello]", // Invalid; must have space after ] + "[variable-2 4. ][meta [ ]]][variable-2 [world]]]", // Valid; tests reference style links + " [variable-3 1. ][property [x]]][variable-3 foo]"); // Valid; can be nested + + MT("SHA", + "foo [link be6a8cc1c1ecfe9489fb51e4869af15a13fc2cd2] bar"); + + MT("shortSHA", + "foo [link be6a8cc] bar"); + + MT("tooShortSHA", + "foo be6a8c bar"); + + MT("longSHA", + "foo be6a8cc1c1ecfe9489fb51e4869af15a13fc2cd22 bar"); + + MT("badSHA", + "foo be6a8cc1c1ecfe9489fb51e4869af15a13fc2cg2 bar"); + + MT("userSHA", + "foo [link bar@be6a8cc1c1ecfe9489fb51e4869af15a13fc2cd2] hello"); + + MT("userProjectSHA", + "foo [link bar/hello@be6a8cc1c1ecfe9489fb51e4869af15a13fc2cd2] world"); + + MT("num", + "foo [link #1] bar"); + + MT("badNum", + "foo #1bar hello"); + + MT("userNum", + "foo [link bar#1] hello"); + + MT("userProjectNum", + "foo [link bar/hello#1] world"); + + MT("vanillaLink", + "foo [link http://www.example.com/] bar"); + + MT("vanillaLinkPunctuation", + "foo [link http://www.example.com/]. bar"); + + MT("vanillaLinkExtension", + "foo [link http://www.example.com/index.html] bar"); + + MT("notALink", + "[comment ```css]", + "[tag foo] {[property color][operator :][keyword black];}", + "[comment ```][link http://www.example.com/]"); + + MT("notALink", + "[comment ``foo `bar` http://www.example.com/``] hello"); + + MT("notALink", + "[comment `foo]", + "[link http://www.example.com/]", + "[comment `foo]", + "", + "[link http://www.example.com/]"); +})(); diff --git a/core/client/assets/vendor/showdown/extensions/ghostdown.js b/core/client/assets/vendor/showdown/extensions/ghostdown.js index edc996b9a2..242d337b90 100644 --- a/core/client/assets/vendor/showdown/extensions/ghostdown.js +++ b/core/client/assets/vendor/showdown/extensions/ghostdown.js @@ -5,12 +5,22 @@ { type: 'lang', filter: function (text) { - return text.replace(/\n?!\[([^\n\]]*)\](?:\(([^\n\)]*)\))?/gi, function (match, alt, src) { + var defRegex = /^ *\[([^\]]+)\]: *]+)>?(?: +["(]([^\n]+)[")])? *(?:\n+|$)/gim, + match, + defUrls = {}; + + while ((match = defRegex.exec(text)) !== null) { + defUrls[match[1]] = match; + } + + return text.replace(/^!(?:\[([^\n\]]*)\])(?:\[([^\n\]]*)\]|\(([^\n\]]*)\))?$/gim, function (match, alt, id, src) { var result = ""; /* regex from isURL in node-validator. Yum! */ if (src && src.match(/^(?!mailto:)(?:(?:https?|ftp):\/\/)?(?:\S+(?::\S*)?@)?(?:(?:(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[0-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z\u00a1-\uffff0-9]+-?)*[a-z\u00a1-\uffff0-9]+)(?:\.(?:[a-z\u00a1-\uffff0-9]+-?)*[a-z\u00a1-\uffff0-9]+)*(?:\.(?:[a-z\u00a1-\uffff]{2,})))|localhost)(?::\d{2,5})?(?:\/[^\s]*)?$/i)) { result = ''; + } else if (id && defUrls.hasOwnProperty(id)) { + result = ''; } return '
' + result + '
Add image of ' + alt + '
' + diff --git a/core/client/views/editor.js b/core/client/views/editor.js index e98d6b2766..24cf0b8a7b 100644 --- a/core/client/views/editor.js +++ b/core/client/views/editor.js @@ -344,9 +344,9 @@ initMarkdown: function () { var self = this; - this.converter = new Showdown.converter({extensions: ['ghostdown']}); + this.converter = new Showdown.converter({extensions: ['ghostdown', 'github']}); this.editor = CodeMirror.fromTextArea(document.getElementById('entry-markdown'), { - mode: 'markdown', + mode: 'gfm', tabMode: 'indent', tabindex: "2", lineWrapping: true, diff --git a/core/server/models/post.js b/core/server/models/post.js index 21307162a2..a8aec027cb 100644 --- a/core/server/models/post.js +++ b/core/server/models/post.js @@ -5,7 +5,8 @@ var Post, when = require('when'), errors = require('../errorHandling'), Showdown = require('showdown'), - converter = new Showdown.converter(), + github = require('../../shared/vendor/showdown/extensions/github'), + converter = new Showdown.converter({extensions: [github]}), User = require('./user').User, GhostBookshelf = require('./base'); diff --git a/core/server/views/default.hbs b/core/server/views/default.hbs index 060a5970b1..9100a93db5 100644 --- a/core/server/views/default.hbs +++ b/core/server/views/default.hbs @@ -52,9 +52,12 @@ + + + diff --git a/core/shared/vendor/showdown/extensions/github.js b/core/shared/vendor/showdown/extensions/github.js new file mode 100644 index 0000000000..e8d81f564a --- /dev/null +++ b/core/shared/vendor/showdown/extensions/github.js @@ -0,0 +1,102 @@ +// +// Github Extension (WIP) +// ~~strike-through~~ -> strike-through +// + +(function () { + var github = function (converter) { + return [ + { + // strike-through + // NOTE: showdown already replaced "~" with "~T", so we need to adjust accordingly. + type : 'lang', + regex : '(~T){2}([^~]+)(~T){2}', + replace : function (match, prefix, content, suffix) { + return '' + content + ''; + } + }, + { + // GFM newline and underscore modifications + type : 'lang', + filter : function (text) { + var extractions = {}, + hashID = 0; + + function hashId() { + return hashID++; + } + + // Extract pre blocks + text = text.replace(/
[\s\S]*?<\/pre>/gim, function (x) {
+                        var hash = hashId();
+                        extractions[hash] = x;
+                        return "{gfm-js-extract-pre-" + hash + "}";
+                    }, 'm');
+
+                    // prevent foo_bar_baz from ending up with an italic word in the middle
+                    text = text.replace(/(^(?! {4}|\t)\w+_\w+_\w[\w_]*)/gm, function (x) {
+                        return x.replace(/_/gm, '\\_');
+                    });
+
+                    // in very clear cases, let newlines become 
tags + text = text.replace(/^[\w\<][^\n]*\n+/gm, function (x) { + return x.match(/\n{2}/) ? x : x.trim() + " \n"; + }); + + text = text.replace(/\{gfm-js-extract-pre-([0-9]+)\}/gm, function (x, y) { + return "\n\n" + extractions[y]; + }); + + + return text; + } + }, + { + // Auto-link URLs and emails + type : 'lang', + filter : function (text) { + var extractions = {}, + hashID = 0; + + function hashId() { + return hashID++; + } + + // filter out def urls + text = text.replace(/^ *\[([^\]]+)\]: *]+)>?(?: +["(]([^\n]+)[")])? *(?:\n+|$)/gim, + function (x) { + var hash = hashId(); + extractions[hash] = x; + return "{gfm-js-extract-ref-url-" + hash + "}"; + }); + + // taken from https://gist.github.com/jorilallo/1283095#L158 + text = text.replace(/https?\:\/\/[^"\s\<\>]*[^.,;'">\:\s\<\>\)\]\!]/g, function (wholeMatch, matchIndex) { + var left = text.slice(0, matchIndex), right = text.slice(matchIndex), + href; + if (left.match(/<[^>]+$/) && right.match(/^[^>]*>/)) { + return wholeMatch; + } + href = wholeMatch.replace(/^http:\/\/github.com\//, "https://github.com/"); + return "" + wholeMatch + ""; + }); + + text = text.replace(/[a-z0-9_\-+=.]+@[a-z0-9\-]+(\.[a-z0-9-]+)+/ig, function (wholeMatch) { + return "" + wholeMatch + ""; + }); + + text = text.replace(/\{gfm-js-extract-ref-url-([0-9]+)\}/gm, function (x, y) { + return "\n\n" + extractions[y]; + }); + + return text; + } + } + ]; + }; + + // Client-side export + if (typeof window !== 'undefined' && window.Showdown && window.Showdown.extensions) { window.Showdown.extensions.github = github; } + // Server-side export + if (typeof module !== 'undefined') module.exports = github; +}()); \ No newline at end of file diff --git a/core/test/unit/client_ghostdown_spec.js b/core/test/unit/client_ghostdown_spec.js new file mode 100644 index 0000000000..b81debd75c --- /dev/null +++ b/core/test/unit/client_ghostdown_spec.js @@ -0,0 +1,69 @@ +/** + * Test the ghostdown extension + * + * Only ever runs on the client (i.e in the editor) + * Server processes showdown without it so there can never be an image upload form in a post. + */ + +/*globals describe, it */ +var gdPath = "../../client/assets/vendor/showdown/extensions/ghostdown.js", + should = require('should'), + ghostdown = require(gdPath); + +describe("Ghostdown showdown extensions", function () { + + it("should export an array of methods for processing", function () { + + ghostdown.should.be.a("function"); + ghostdown().should.be.an.instanceof(Array); + + ghostdown().forEach(function (processor) { + processor.should.be.a("object"); + processor.should.have.property("type"); + processor.should.have.property("filter"); + processor.type.should.be.a("string"); + processor.filter.should.be.a("function"); + }); + }); + + it("should accurately detect images in markdown", function () { + [ + "![]", + "![]()", + "![image and another,/ image]", + "![image and another,/ image]()", + "![image and another,/ image](http://dsurl.stuff)", + "![](http://dsurl.stuff)", + "![][]", + "![image and another,/ image][stuff]", + "![][stuff]", + "![image and another,/ image][]" + ] + .forEach(function (imageMarkup) { + var processedMarkup = + ghostdown().reduce(function (prev, processor) { + return processor.filter(prev); + }, imageMarkup); + + // The image is the entire markup, so the image box should be too + processedMarkup.should.match(/^\n*$/); + }); + }); + + it("should correctly include an image", function () { + [ + "![image and another,/ image](http://dsurl.stuff)", + "![](http://dsurl.stuff)", + "![image and another,/ image][test]\n\n[test]: http://dsurl.stuff", + "![][test]\n\n[test]: http://dsurl.stuff" + ] + .forEach(function (imageMarkup) { + var processedMarkup = + ghostdown().reduce(function (prev, processor) { + return processor.filter(prev); + }, imageMarkup); + + processedMarkup.should.match(/foo_bar<\/del><\/p>$/}, + processedMarkup = converter.makeHtml(testPhrase.input); + + // The image is the entire markup, so the image box should be too + processedMarkup.should.match(testPhrase.output); + }); + + it("should not touch single underscores inside words", function () { + var testPhrase = {input: "foo_bar", output: /^

foo_bar<\/p>$/}, + processedMarkup = converter.makeHtml(testPhrase.input); + + processedMarkup.should.match(testPhrase.output); + }); + + it("should not touch underscores in code blocks", function () { + var testPhrase = {input: " foo_bar_baz", output: /^

foo_bar_baz\n<\/code><\/pre>$/},
+            processedMarkup = converter.makeHtml(testPhrase.input);
+
+        processedMarkup.should.match(testPhrase.output);
+    });
+
+    it("should not touch underscores in pre blocks", function () {
+        var testPhrases = [
+                {input: "
\nfoo_bar_baz\n
", output: /^
\nfoo_bar_baz\n<\/pre>$/},
+                {input: "
foo_bar_baz
", output: /^
foo_bar_baz<\/pre>$/}
+            ],
+            processedMarkup;
+
+        testPhrases.forEach(function (testPhrase) {
+            processedMarkup = converter.makeHtml(testPhrase.input);
+            processedMarkup.should.match(testPhrase.output);
+        });
+    });
+
+    it("should not treat pre blocks with pre-text differently", function () {
+        var testPhrases = [
+                {input: "
\nthis is `a\\_test` and this\\_too and finally_this_is\n
", output: /^
\nthis is `a\\_test` and this\\_too and finally_this_is\n<\/pre>$/},
+                {input: "hmm
\nthis is `a\\_test` and this\\_too and finally_this_is\n
", output: /^

hmm<\/p>\n\n

\nthis is `a\\_test` and this\\_too and finally_this_is\n<\/pre>$/}
+            ],
+            processedMarkup;
+
+        testPhrases.forEach(function (testPhrase) {
+            processedMarkup = converter.makeHtml(testPhrase.input);
+            processedMarkup.should.match(testPhrase.output);
+        });
+    });
+
+    it("should escape two or more underscores inside words", function () {
+        var testPhrases = [
+                {input: "foo_bar_baz", output: /^

foo_bar_baz<\/p>$/}, + {input: "foo_bar_baz_bat", output: /^

foo_bar_baz_bat<\/p>$/}, + {input: "foo_bar_baz_bat_boo", output: /^

foo_bar_baz_bat_boo<\/p>$/}, + {input: "FOO_BAR", output: /^

FOO_BAR<\/p>$/}, + {input: "FOO_BAR_BAZ", output: /^

FOO_BAR_BAZ<\/p>$/}, + {input: "FOO_bar_BAZ_bat", output: /^

FOO_bar_BAZ_bat<\/p>$/}, + {input: "FOO_bar_BAZ_bat_BOO", output: /^

FOO_bar_BAZ_bat_BOO<\/p>$/}, + {input: "foo_BAR_baz_BAT_boo", output: /^

foo_BAR_baz_BAT_boo<\/p>$/} + ], + processedMarkup; + + testPhrases.forEach(function (testPhrase) { + processedMarkup = converter.makeHtml(testPhrase.input); + processedMarkup.should.match(testPhrase.output); + }); + }); + + it("should turn newlines into br tags in simple cases", function () { + var testPhrases = [ + {input: "fizz\nbuzz", output: /^

fizz
\nbuzz<\/p>$/}, + {input: "Hello world\nIt's a fine day", output: /^

Hello world
\nIt\'s a fine day<\/p>$/} + ], + processedMarkup; + + testPhrases.forEach(function (testPhrase) { + processedMarkup = converter.makeHtml(testPhrase.input); + processedMarkup.should.match(testPhrase.output); + }); + }); + + it("should convert newlines in all groups", function () { + var testPhrases = [ + {input: "ruby\npython\nerlang", output: /^

ruby
\npython
\nerlang<\/p>$/}, + {input: "Hello world\nIt's a fine day\nout", output: /^

Hello world
\nIt\'s a fine day
\nout<\/p>$/} + ], + processedMarkup; + + testPhrases.forEach(function (testPhrase) { + processedMarkup = converter.makeHtml(testPhrase.input); + processedMarkup.should.match(testPhrase.output); + }); + }); + + it("should convert newlines in even long groups", function () { + var testPhrases = [ + {input: "ruby\npython\nerlang\ngo", output: /^

ruby
\npython
\nerlang
\ngo<\/p>$/}, + { + input: "Hello world\nIt's a fine day\noutside\nthe window", + output: /^

Hello world
\nIt\'s a fine day
\noutside
\nthe window<\/p>$/ + } + ], + processedMarkup; + + testPhrases.forEach(function (testPhrase) { + processedMarkup = converter.makeHtml(testPhrase.input); + processedMarkup.should.match(testPhrase.output); + }); + }); + + it("should not convert newlines in lists", function () { + var testPhrases = [ + {input: "#fizz\n# buzz\n### baz", output: /^

fizz<\/h1>\n\n

buzz<\/h1>\n\n

baz<\/h3>$/}, + {input: "* foo\n* bar", output: /^