diff --git a/app/assets/javascripts/pretty-text/engines/discourse-markdown-it.js.es6 b/app/assets/javascripts/pretty-text/engines/discourse-markdown-it.js.es6
index 781a186d843..5ff68ef7959 100644
--- a/app/assets/javascripts/pretty-text/engines/discourse-markdown-it.js.es6
+++ b/app/assets/javascripts/pretty-text/engines/discourse-markdown-it.js.es6
@@ -227,11 +227,10 @@ export function setup(opts, siteSettings, state) {
opts.markdownIt = true;
opts.setup = true;
- if (!opts.discourse.sanitizer || !opts.sanitizer) {
+ if (!opts.discourse.sanitizer) {
const whiteLister = new WhiteLister(opts.discourse);
opts.sanitizer = opts.discourse.sanitizer = (!!opts.discourse.sanitize) ? a=>sanitize(a, whiteLister) : a=>a;
}
-
}
export function cook(raw, opts) {
diff --git a/app/assets/javascripts/pretty-text/engines/discourse-markdown.js.es6 b/app/assets/javascripts/pretty-text/engines/discourse-markdown.js.es6
new file mode 100644
index 00000000000..3303cc34b9d
--- /dev/null
+++ b/app/assets/javascripts/pretty-text/engines/discourse-markdown.js.es6
@@ -0,0 +1,597 @@
+import guid from 'pretty-text/guid';
+import { default as WhiteLister, whiteListFeature } from 'pretty-text/white-lister';
+import { escape } from 'pretty-text/sanitizer';
+
+var parser = window.BetterMarkdown,
+ MD = parser.Markdown,
+ DialectHelpers = parser.DialectHelpers,
+ hoisted;
+
+let currentOpts;
+
+const emitters = [];
+const preProcessors = [];
+const parseNodes = [];
+
+function findEndPos(text, start, stop, args, offset) {
+ let endPos, nextStart;
+ do {
+ endPos = text.indexOf(stop, offset);
+ if (endPos === -1) { return -1; }
+ nextStart = text.indexOf(start, offset);
+ offset = endPos + stop.length;
+ } while (nextStart !== -1 && nextStart < endPos);
+ return endPos;
+}
+
+class DialectHelper {
+ constructor() {
+ this._dialect = MD.dialects.Discourse = DialectHelpers.subclassDialect(MD.dialects.Gruber);
+ this._setup = false;
+ }
+
+ escape(str) {
+ return escape(str);
+ }
+
+ getOptions() {
+ return currentOpts;
+ }
+
+ registerInlineFeature(featureName, start, fn) {
+ this._dialect.inline[start] = function() {
+ if (!currentOpts.features[featureName]) { return; }
+ return fn.apply(this, arguments);
+ };
+ }
+
+ addPreProcessorFeature(featureName, fn) {
+ preProcessors.push(raw => {
+ if (!currentOpts.features[featureName]) { return raw; }
+ return fn(raw, hoister);
+ });
+ }
+
+ /**
+ The simplest kind of replacement possible. Replace a stirng token with JsonML.
+
+ For example to replace all occurrances of :) with a smile image:
+
+ ```javascript
+ helper.inlineReplace(':)', text => ['img', {src: '/images/smile.png'}]);
+ ```
+ **/
+ inlineReplaceFeature(featureName, token, emitter) {
+ this.registerInline(token, (text, match, prev) => {
+ if (!currentOpts.features[featureName]) { return; }
+ return [token.length, emitter.call(this, token, match, prev)];
+ });
+ }
+
+ /**
+ After the parser has been executed, change the contents of a HTML tag.
+
+ Let's say you want to replace the contents of all code tags to prepend
+ "EVIL TROUT HACKED YOUR CODE!":
+
+ ```javascript
+ helper.postProcessTag('code', contents => `EVIL TROUT HACKED YOUR CODE!\n\n${contents}`);
+ ```
+ **/
+ postProcessTagFeature(featureName, tag, emitter) {
+ this.onParseNode(event => {
+ if (!currentOpts.features[featureName]) { return; }
+ const node = event.node;
+ if (node[0] === tag) {
+ node[node.length-1] = emitter(node[node.length-1]);
+ }
+ });
+ }
+
+ /**
+ Matches inline using a regular expression. The emitter function is passed
+ the matches from the regular expression.
+
+ For example, this auto links URLs:
+
+ ```javascript
+ helper.inlineRegexp({
+ matcher: /((?:https?:(?:\/{1,3}|[a-z0-9%])|www\d{0,3}[.])(?:[^\s()<>]+|\([^\s()<>]+\))+(?:\([^\s()<>]+\)|[^`!()\[\]{};:'".,<>?«»“”‘’\s]))/gm,
+ spaceBoundary: true,
+ start: 'http',
+
+ emitter(matches) {
+ const url = matches[1];
+ return ['a', {href: url}, url];
+ }
+ });
+ ```
+ **/
+ inlineRegexpFeature(featureName, args) {
+ this.registerInline(args.start, function(text, match, prev) {
+ if (!currentOpts.features[featureName]) { return; }
+ if (invalidBoundary(args, prev)) { return; }
+
+ args.matcher.lastIndex = 0;
+ const m = args.matcher.exec(text);
+ if (m) {
+ const result = args.emitter.call(this, m);
+ if (result) {
+ return [m[0].length, result];
+ }
+ }
+ });
+ }
+
+ /**
+ Handles inline replacements surrounded by tokens.
+
+ For example, to handle markdown style bold. Note we use `concat` on the array because
+ the contents are JsonML too since we didn't pass `rawContents` as true. This supports
+ recursive markup.
+
+ ```javascript
+ helper.inlineBetween({
+ between: '**',
+ wordBoundary: true.
+ emitter(contents) {
+ return ['strong'].concat(contents);
+ }
+ });
+ ```
+ **/
+ inlineBetweenFeature(featureName, args) {
+ const start = args.start || args.between;
+ const stop = args.stop || args.between;
+ const startLength = start.length;
+
+ this.registerInline(start, function(text, match, prev) {
+ if (!currentOpts.features[featureName]) { return; }
+ if (invalidBoundary(args, prev)) { return; }
+
+ const endPos = findEndPos(text, start, stop, args, startLength);
+ if (endPos === -1) { return; }
+ var between = text.slice(startLength, endPos);
+
+ // If rawcontents is set, don't process inline
+ if (!args.rawContents) {
+ between = this.processInline(between);
+ }
+
+ var contents = args.emitter.call(this, between);
+ if (contents) {
+ return [endPos+stop.length, contents];
+ }
+ });
+ }
+
+ /**
+ Replaces a block of text between a start and stop. As opposed to inline, these
+ might span multiple lines.
+
+ Here's an example that takes the content between `[code]` ... `[/code]` and
+ puts them inside a `pre` tag:
+
+ ```javascript
+ helper.replaceBlock({
+ start: /(\[code\])([\s\S]*)/igm,
+ stop: '[/code]',
+ rawContents: true,
+
+ emitter(blockContents) {
+ return ['p', ['pre'].concat(blockContents)];
+ }
+ });
+ ```
+ **/
+ replaceBlockFeature(featureName, args) {
+ function blockFunc(block, next) {
+ if (!currentOpts.features[featureName]) { return; }
+
+ const linebreaks = currentOpts.traditionalMarkdownLinebreaks;
+ if (linebreaks && args.skipIfTradtionalLinebreaks) { return; }
+
+ args.start.lastIndex = 0;
+ const result = [];
+ const match = (args.start).exec(block);
+ if (!match) { return; }
+
+ const lastChance = () => !next.some(blk => blk.match(args.stop));
+
+ // shave off start tag and leading text, if any.
+ const pos = args.start.lastIndex - match[0].length;
+ const leading = block.slice(0, pos);
+ const trailing = match[2] ? match[2].replace(/^\n*/, "") : "";
+
+ // The other leading block should be processed first! eg a code block wrapped around a code block.
+ if (args.withoutLeading && args.withoutLeading.test(leading)) {
+ return;
+ }
+
+ // just give up if there's no stop tag in this or any next block
+ args.stop.lastIndex = block.length - trailing.length;
+ if (!args.stop.exec(block) && lastChance()) { return; }
+ if (leading.length > 0) {
+ var parsedLeading = this.processBlock(MD.mk_block(leading), []);
+ if (parsedLeading && parsedLeading[0]) {
+ result.push(parsedLeading[0]);
+ }
+ }
+ if (trailing.length > 0) {
+ next.unshift(MD.mk_block(trailing, block.trailing,
+ block.lineNumber + countLines(leading) + (match[2] ? match[2].length : 0) - trailing.length));
+ }
+
+ // go through the available blocks to find the matching stop tag.
+ const contentBlocks = [];
+ let nesting = 0;
+ let actualEndPos = -1;
+ let currentBlock;
+
+ blockloop:
+ while (currentBlock = next.shift()) {
+
+ // collect all the start and stop tags in the current block
+ args.start.lastIndex = 0;
+ const startPos = [];
+ let m;
+ while (m = (args.start).exec(currentBlock)) {
+ startPos.push(args.start.lastIndex - m[0].length);
+ args.start.lastIndex = args.start.lastIndex - (m[2] ? m[2].length : 0);
+ }
+ args.stop.lastIndex = 0;
+ const endPos = [];
+ while (m = (args.stop).exec(currentBlock)) {
+ endPos.push(args.stop.lastIndex - m[0].length);
+ }
+
+ // go through the available end tags:
+ let ep = 0;
+ let sp = 0;
+ while (ep < endPos.length) {
+ if (sp < startPos.length && startPos[sp] < endPos[ep]) {
+ // there's an end tag, but there's also another start tag first. we need to go deeper.
+ sp++; nesting++;
+ } else if (nesting > 0) {
+ // found an end tag, but we must go up a level first.
+ ep++; nesting--;
+ } else {
+ // found an end tag and we're at the top: done! -- or: start tag and end tag are
+ // identical, (i.e. startPos[sp] == endPos[ep]), so we don't do nesting at all.
+ actualEndPos = endPos[ep];
+ break blockloop;
+ }
+ }
+
+ if (lastChance()) {
+ // when lastChance() becomes true the first time, currentBlock contains the last
+ // end tag available in the input blocks but it's not on the right nesting level
+ // or we would have terminated the loop already. the only thing we can do is to
+ // treat the last available end tag as tho it were matched with our start tag
+ // and let the emitter figure out how to render the garbage inside.
+ actualEndPos = endPos[endPos.length - 1];
+ break;
+ }
+
+ // any left-over start tags still increase the nesting level
+ nesting += startPos.length - sp;
+ contentBlocks.push(currentBlock);
+ }
+
+ const stopLen = currentBlock.match(args.stop)[0].length;
+ const before = currentBlock.slice(0, actualEndPos).replace(/\n*$/, "");
+ const after = currentBlock.slice(actualEndPos + stopLen).replace(/^\n*/, "");
+ if (before.length > 0) contentBlocks.push(MD.mk_block(before, "", currentBlock.lineNumber));
+ if (after.length > 0) next.unshift(MD.mk_block(after, currentBlock.trailing, currentBlock.lineNumber + countLines(before)));
+
+ const emitterResult = args.emitter.call(this, contentBlocks, match);
+ if (emitterResult) { result.push(emitterResult); }
+ return result;
+ };
+
+ if (args.priority) {
+ blockFunc.priority = args.priority;
+ }
+
+ this.registerBlock(args.start.toString(), blockFunc);
+ }
+
+ /**
+ After the parser has been executed, post process any text nodes in the HTML document.
+ This is useful if you want to apply a transformation to the text.
+
+ If you are generating HTML from the text, it is preferable to use the replacer
+ functions and do it in the parsing part of the pipeline. This function is best for
+ simple transformations or transformations that have to happen after all earlier
+ processing is done.
+
+ For example, to convert all text to upper case:
+
+ ```javascript
+ helper.postProcessText(function (text) {
+ return text.toUpperCase();
+ });
+ ```
+ **/
+ postProcessTextFeature(featureName, fn) {
+ emitters.push(function () {
+ if (!currentOpts.features[featureName]) { return; }
+ return fn.apply(this, arguments);
+ });
+ }
+
+ onParseNodeFeature(featureName, fn) {
+ parseNodes.push(function () {
+ if (!currentOpts.features[featureName]) { return; }
+ return fn.apply(this, arguments);
+ });
+ }
+
+ registerBlockFeature(featureName, name, fn) {
+ const blockFunc = function() {
+ if (!currentOpts.features[featureName]) { return; }
+ return fn.apply(this, arguments);
+ };
+
+ blockFunc.priority = fn.priority;
+ this._dialect.block[name] = blockFunc;
+ }
+
+ applyFeature(featureName, module) {
+ helper.registerInline = (code, fn) => helper.registerInlineFeature(featureName, code, fn);
+ helper.replaceBlock = args => helper.replaceBlockFeature(featureName, args);
+ helper.addPreProcessor = fn => helper.addPreProcessorFeature(featureName, fn);
+ helper.inlineReplace = (token, emitter) => helper.inlineReplaceFeature(featureName, token, emitter);
+ helper.postProcessTag = (token, emitter) => helper.postProcessTagFeature(featureName, token, emitter);
+ helper.inlineRegexp = args => helper.inlineRegexpFeature(featureName, args);
+ helper.inlineBetween = args => helper.inlineBetweenFeature(featureName, args);
+ helper.postProcessText = fn => helper.postProcessTextFeature(featureName, fn);
+ helper.onParseNode = fn => helper.onParseNodeFeature(featureName, fn);
+ helper.registerBlock = (name, fn) => helper.registerBlockFeature(featureName, name, fn);
+
+ module.setup(this);
+ }
+
+ setup() {
+ if (this._setup) { return; }
+ this._setup = true;
+
+ Object.keys(require._eak_seen).forEach(entry => {
+ if (entry.indexOf('discourse-markdown') !== -1) {
+ const module = requirejs(entry);
+ if (module && module.setup) {
+ const featureName = entry.split('/').reverse()[0];
+ helper.whiteList = info => whiteListFeature(featureName, info);
+
+ this.applyFeature(featureName, module);
+ helper.whiteList = undefined;
+ }
+ }
+ });
+
+ MD.buildBlockOrder(this._dialect.block);
+ var index = this._dialect.block.__order__.indexOf("code");
+ if (index > -1) {
+ this._dialect.block.__order__.splice(index, 1);
+ this._dialect.block.__order__.unshift("code");
+ }
+ MD.buildInlinePatterns(this._dialect.inline);
+ }
+};
+
+const helper = new DialectHelper();
+
+export function cook(raw, opts) {
+ currentOpts = opts;
+
+ hoisted = {};
+
+ if (!currentOpts.enableExperimentalMarkdownIt) {
+ raw = hoistCodeBlocksAndSpans(raw);
+ preProcessors.forEach(p => raw = p(raw));
+ }
+
+ const whiteLister = new WhiteLister(opts);
+
+ let result;
+
+ if (currentOpts.enableExperimentalMarkdownIt) {
+ result = opts.sanitizer(
+ requirejs('pretty-text/engines/markdown-it/instance').default(opts).render(raw),
+ whiteLister
+ );
+ } else {
+ const tree = parser.toHTMLTree(raw, 'Discourse');
+ result = opts.sanitizer(parser.renderJsonML(parseTree(tree, opts)), whiteLister);
+ }
+
+ // If we hoisted out anything, put it back
+ const keys = Object.keys(hoisted);
+ if (keys.length) {
+ let found = true;
+
+ const unhoist = function(key) {
+ result = result.replace(new RegExp(key, "g"), function() {
+ found = true;
+ return hoisted[key];
+ });
+ };
+
+ while (found) {
+ found = false;
+ keys.forEach(unhoist);
+ }
+ }
+
+ return result.trim();
+}
+
+export function setup() {
+ helper.setup();
+}
+
+function processTextNodes(node, event, emitter) {
+ if (node.length < 2) { return; }
+
+ if (node[0] === '__RAW') {
+ const hash = guid();
+ hoisted[hash] = node[1];
+ node[1] = hash;
+ return;
+ }
+
+ for (var j=1; j...
code blocks
+ text = text.replace(/(\s|^)([\s\S]*?)<\/pre>/ig, function(_, before, content) {
+ const hash = guid();
+ hoisted[hash] = escape(showBackslashEscapedCharacters(removeEmptyLines(content)));
+ return before + "
" + hash + "
";
+ });
+
+ // code spans (double & single `)
+ ["``", "`"].forEach(function(delimiter) {
+ var regexp = new RegExp("(^|[^`])" + delimiter + "([^`\\n]+?)" + delimiter + "([^`]|$)", "g");
+ text = text.replace(regexp, function(_, before, content, after) {
+ const hash = guid();
+ hoisted[hash] = escape(showBackslashEscapedCharacters(content.trim()));
+ return before + delimiter + hash + delimiter + after;
+ });
+ });
+
+ // replace back all weird character with "\`"
+ return showBackslashEscapedCharacters(text);
+}
diff --git a/app/assets/javascripts/pretty-text/engines/discourse-markdown/bbcode-inline.js.es6 b/app/assets/javascripts/pretty-text/engines/discourse-markdown/bbcode-inline.js.es6
index a2dfa571437..71ce50462af 100644
--- a/app/assets/javascripts/pretty-text/engines/discourse-markdown/bbcode-inline.js.es6
+++ b/app/assets/javascripts/pretty-text/engines/discourse-markdown/bbcode-inline.js.es6
@@ -57,7 +57,6 @@ function tokanizeBBCode(state, silent, ruler) {
let token = state.push('text', '' , 0);
token.content = state.src.slice(pos, pos+tagInfo.length);
- token.meta = 'bbcode';
state.delimiters.push({
bbInfo: tagInfo,
@@ -106,15 +105,10 @@ function processBBCode(state, silent) {
let tag, className;
if (typeof tagInfo.rule.wrap === 'function') {
- let content = "";
- for (let j = startDelim.token+1; j < endDelim.token; j++) {
- let inner = state.tokens[j];
- if (inner.type === 'text' && inner.meta !== 'bbcode') {
- content += inner.content;
- }
+ if (!tagInfo.rule.wrap(token, tagInfo)) {
+ return false;
}
- tagInfo.rule.wrap(token, state.tokens[endDelim.token], tagInfo, content);
- continue;
+ tag = token.tag;
} else {
let split = tagInfo.rule.wrap.split('.');
tag = split[0];
@@ -166,35 +160,19 @@ export function setup(helper) {
}
});
- const simpleUrlRegex = /^http[s]?:\/\//;
ruler.push('url', {
tag: 'url',
- wrap: function(startToken, endToken, tagInfo, content) {
+ replace: function(state, tagInfo, content) {
+ let token;
- const url = (tagInfo.attrs['_default'] || content).trim();
+ token = state.push('link_open', 'a', 1);
+ token.attrs = [['href', content], ['data-bbcode', 'true']];
- if (simpleUrlRegex.test(url)) {
- startToken.type = 'link_open';
- startToken.tag = 'a';
- startToken.attrs = [['href', url], ['data-bbcode', 'true']];
- startToken.content = '';
- startToken.nesting = 1;
+ token = state.push('text', '', 0);
+ token.content = content;
- endToken.type = 'link_close';
- endToken.tag = 'a';
- endToken.content = '';
- endToken.nesting = -1;
- } else {
- // just strip the bbcode tag
- endToken.content = '';
- startToken.content = '';
-
- // edge case, we don't want this detected as a onebox if auto linked
- // this ensures it is not stripped
- startToken.type = 'html_inline';
- }
-
- return false;
+ token = state.push('link_close', 'a', -1);
+ return true;
}
});
@@ -202,10 +180,9 @@ export function setup(helper) {
tag: 'email',
replace: function(state, tagInfo, content) {
let token;
- let email = tagInfo.attrs['_default'] || content;
token = state.push('link_open', 'a', 1);
- token.attrs = [['href', 'mailto:' + email], ['data-bbcode', 'true']];
+ token.attrs = [['href', 'mailto:' + content], ['data-bbcode', 'true']];
token = state.push('text', '', 0);
token.content = content;
diff --git a/app/assets/javascripts/pretty-text/engines/discourse-markdown/onebox.js.es6 b/app/assets/javascripts/pretty-text/engines/discourse-markdown/onebox.js.es6
index 788cdaed14b..5f04b72b1d0 100644
--- a/app/assets/javascripts/pretty-text/engines/discourse-markdown/onebox.js.es6
+++ b/app/assets/javascripts/pretty-text/engines/discourse-markdown/onebox.js.es6
@@ -22,7 +22,6 @@ function applyOnebox(state, silent) {
if (j === 0 && token.leading_space) {
continue;
} else if (j > 0) {
-
let prevSibling = token.children[j-1];
if (prevSibling.tag !== 'br' || prevSibling.leading_space) {
@@ -46,12 +45,8 @@ function applyOnebox(state, silent) {
continue;
}
- // edge case ... what if this is not http or protocoless?
- if (!/^http|^\/\//i.test(attrs[0][1])) {
- continue;
- }
-
// we already know text matches cause it is an auto link
+
if (!close || close.type !== "link_close") {
continue;
}
@@ -76,7 +71,6 @@ function applyOnebox(state, silent) {
} else {
// decorate...
attrs.push(["class", "onebox"]);
- attrs.push(["target", "_blank"]);
}
}
}
diff --git a/app/assets/javascripts/pretty-text/engines/discourse-markdown/quotes.js.es6 b/app/assets/javascripts/pretty-text/engines/discourse-markdown/quotes.js.es6
index ba892ef7067..05afd6bc9ef 100644
--- a/app/assets/javascripts/pretty-text/engines/discourse-markdown/quotes.js.es6
+++ b/app/assets/javascripts/pretty-text/engines/discourse-markdown/quotes.js.es6
@@ -26,7 +26,7 @@ const rule = {
continue;
}
- if (/full:\s*true/.test(split[i])) {
+ if (split[i].indexOf(/full:\s*true/) === 0) {
full = true;
continue;
}
diff --git a/app/assets/javascripts/pretty-text/pretty-text.js.es6 b/app/assets/javascripts/pretty-text/pretty-text.js.es6
index 32064527aed..bfd6eaf904f 100644
--- a/app/assets/javascripts/pretty-text/pretty-text.js.es6
+++ b/app/assets/javascripts/pretty-text/pretty-text.js.es6
@@ -1,9 +1,12 @@
import { cook as cookIt, setup as setupIt } from 'pretty-text/engines/discourse-markdown-it';
+import { sanitize } from 'pretty-text/sanitizer';
+import WhiteLister from 'pretty-text/white-lister';
-export function registerOption() {
- if (window.console) {
- window.console.log("registerOption is deprecated");
- }
+const _registerFns = [];
+const identity = value => value;
+
+export function registerOption(fn) {
+ _registerFns.push(fn);
}
export function buildOptions(state) {
@@ -21,7 +24,7 @@ export function buildOptions(state) {
emojiUnicodeReplacer
} = state;
- let features = {
+ const features = {
'bold-italics': true,
'auto-link': true,
'mentions': true,
@@ -33,10 +36,6 @@ export function buildOptions(state) {
'newline': !siteSettings.traditional_markdown_linebreaks
};
- if (state.features) {
- features = _.merge(features, state.features);
- }
-
const options = {
sanitize: true,
getURL,
@@ -55,8 +54,6 @@ export function buildOptions(state) {
markdownIt: true
};
- // note, this will mutate options due to the way the API is designed
- // may need a refactor
setupIt(options, siteSettings, state);
return options;
@@ -64,14 +61,9 @@ export function buildOptions(state) {
export default class {
constructor(opts) {
- if (!opts) {
- opts = buildOptions({ siteSettings: {}});
- }
- this.opts = opts;
- }
-
- disableSanitizer() {
- this.opts.sanitizer = this.opts.discourse.sanitizer = ident => ident;
+ this.opts = opts || {};
+ this.opts.features = this.opts.features || {};
+ this.opts.sanitizer = (!!this.opts.sanitize) ? (this.opts.sanitizer || sanitize) : identity;
}
cook(raw) {
@@ -83,6 +75,6 @@ export default class {
}
sanitize(html) {
- return this.opts.sanitizer(html).trim();
+ return this.opts.sanitizer(html, new WhiteLister(this.opts));
}
};
diff --git a/lib/pretty_text.rb b/lib/pretty_text.rb
index fd1e73c68a7..0fefc15b6e3 100644
--- a/lib/pretty_text.rb
+++ b/lib/pretty_text.rb
@@ -175,14 +175,12 @@ module PrettyText
buffer << "__textOptions = __buildOptions(__optInput);\n"
-
- buffer << ("__pt = new __PrettyText(__textOptions);")
-
# Be careful disabling sanitization. We allow for custom emails
if opts[:sanitize] == false
- buffer << ('__pt.disableSanitizer();')
+ buffer << ('__textOptions.sanitize = false;')
end
+ buffer << ("__pt = new __PrettyText(__textOptions);")
opts = context.eval(buffer)
DiscourseEvent.trigger(:markdown_context, context)
diff --git a/spec/components/pretty_text_spec.rb b/spec/components/pretty_text_spec.rb
index b3aa2468c92..49bf5db18ad 100644
--- a/spec/components/pretty_text_spec.rb
+++ b/spec/components/pretty_text_spec.rb
@@ -61,7 +61,7 @@ describe PrettyText do
[/quote]
MD
html = <<~HTML
-
evil\n\n
trout
", "it doesn't insertDerpy: http://derp.com?__test=1
', "works with double underscores in urls"); + assert.cooked("Derpy: http://derp.com?_test_=1", + 'Derpy: http://derp.com?_test_=1
', + "works with underscores in urls"); + assert.cooked("Atwood: www.codinghorror.com", 'Atwood: www.codinghorror.com
', "autolinks something that begins with www"); @@ -146,11 +136,11 @@ QUnit.test("Links", assert => { "autolinks a URL with parentheses (like Wikipedia)"); assert.cooked("Here's a tweet:\nhttps://twitter.com/evil_trout/status/345954894420787200", - "Here's a tweet:
\nhttps://twitter.com/evil_trout/status/345954894420787200
Here's a tweet:
https://twitter.com/evil_trout/status/345954894420787200
Link (with an outer "description")
", + "Link (with an outer \"description\")
", "it doesn't consume closing parens as part of the url"); assert.cooked("A link inside parentheses (http://www.example.com)", @@ -198,76 +188,50 @@ QUnit.test("Links", assert => { }); QUnit.test("simple quotes", assert => { - assert.cooked("> nice!", "\n", "it supports simple quotes"); - assert.cooked(" > nice!", "nice!
\n
\n", "it allows quotes with preceding spaces"); + assert.cooked("> nice!", "nice!
\n
", "it supports simple quotes"); + assert.cooked(" > nice!", "nice!
", "it allows quotes with preceding spaces"); assert.cooked("> level 1\n> > level 2", - "nice!
\n", + "level 1
\n\n\nlevel 2
\n
", "it allows nesting of blockquotes"); assert.cooked("> level 1\n> > level 2", - "level 1
level 2
\n", + "level 1
\n\n\nlevel 2
\n
", "it allows nesting of blockquotes with spaces"); assert.cooked("- hello\n\n > world\n > eviltrout", -`level 1
level 2
hello
---world
-
-eviltrout
", "it allows quotes within a list."); assert.cooked("-world
eviltrout
eviltrout
", - "eviltrout
eviltrout
\n", "allow multiple spaces to indent"); + assert.cooked(" > indent 1\n > indent 2", "indent 1
\n
\nindent 2
", "allow multiple spaces to indent"); }); QUnit.test("Quotes", assert => { - assert.cookedOptions("[quote=\"eviltrout, post: 1\"]\na quote\n\nsecond line\n\nthird line\n[/quote]", + assert.cookedOptions("[quote=\"eviltrout, post: 1\"]\na quote\n\nsecond line\n\nthird line[/quote]", { topicId: 2 }, - `indent 1
indent 2
--a quote
-second line
-third line
-
" + + "a quote
second line
third line
1
\n\nmy quote
2
", + "handles quotes properly"); - assert.cookedOptions("[quote=\"bob, post:1\"]\nmy quote\n[/quote]", + assert.cookedOptions("1[quote=\"bob, post:1\"]my quote[/quote]2", { topicId: 2, lookupAvatar: function() { } }, - `--my quote
-
1
\n\nmy quote
2
", "includes no avatar if none is found"); assert.cooked(`[quote]\na\n\n[quote]\nb\n[/quote]\n[/quote]`, - `--a
-- ---b
-
a b
hanzo55@yahoo.com
", "won't be affected by email addresses that have a number before the @ symbol"); assert.cooked("@EvilTrout yo", @@ -305,7 +269,7 @@ QUnit.test("Mentions", assert => { "it handles mentions at the beginning of a string"); assert.cooked("yo\n@EvilTrout", - "yo
\n@EvilTrout
yo
@EvilTrout
blocks");
assert.cooked("```\na @test\n```",
- "a @test\n
",
+ "a @test
",
"should not do mentions within a code block.");
assert.cooked("> foo bar baz @eviltrout",
- "\nfoo bar baz @eviltrout
\n
",
+ "foo bar baz @eviltrout
",
"handles mentions in simple quotes");
assert.cooked("> foo bar baz @eviltrout ohmagerd\nlook at this",
- "\nfoo bar baz @eviltrout ohmagerd
\nlook at this
\n
",
+ "foo bar baz @eviltrout ohmagerd
look at this
",
"does mentions properly with trailing text within a simple quote");
assert.cooked("`code` is okay before @mention",
@@ -345,7 +309,7 @@ QUnit.test("Mentions", assert => {
"you can have a mention in an inline code block following a real mention.");
assert.cooked("1. this is a list\n\n2. this is an @eviltrout mention\n",
- "\n- \n
this is a list
\n \n- \n
this is an @eviltrout mention
\n \n
",
+ "this is a list
this is an @eviltrout mention
",
"it mentions properly in a list.");
assert.cooked("Hello @foo/@bar",
@@ -377,11 +341,11 @@ QUnit.test("Category hashtags", assert => {
"it does not translate category hashtag within links");
assert.cooked("```\n# #category-hashtag\n```",
- "# #category-hashtag\n
",
+ "# #category-hashtag
",
"it does not translate category hashtags to links in code blocks");
assert.cooked("># #category-hashtag\n",
- "\n#category-hashtag
\n
",
+ "#category-hashtag
",
"it handles category hashtags in simple quotes");
assert.cooked("# #category-hashtag",
@@ -392,6 +356,10 @@ QUnit.test("Category hashtags", assert => {
"don't #category-hashtag
",
"it does not mention in an inline code block");
+ assert.cooked("test #hashtag1/#hashtag2",
+ "test #hashtag1/#hashtag2
",
+ "it does not convert category hashtag not bounded by spaces");
+
assert.cooked("#category-hashtag",
"#category-hashtag
",
"it works between HTML tags");
@@ -403,12 +371,14 @@ QUnit.test("Heading", assert => {
});
QUnit.test("bold and italics", assert => {
- assert.cooked("a \"**hello**\"", "a "hello"
", "bolds in quotes");
+ assert.cooked("a \"**hello**\"", "a \"hello\"
", "bolds in quotes");
assert.cooked("(**hello**)", "(hello)
", "bolds in parens");
- assert.cooked("**hello**\nworld", "hello
\nworld
", "allows newline after bold");
- assert.cooked("**hello**\n**world**", "hello
\nworld
", "newline between two bolds");
+ assert.cooked("**hello**\nworld", "hello
world
", "allows newline after bold");
+ assert.cooked("**hello**\n**world**", "hello
world
", "newline between two bolds");
+ assert.cooked("**a*_b**", "a*_b
", "allows for characters within bold");
assert.cooked("** hello**", "** hello**
", "does not bold on a space boundary");
assert.cooked("**hello **", "**hello **
", "does not bold on a space boundary");
+ assert.cooked("你**hello**", "你**hello**
", "does not bold chinese intra word");
assert.cooked("**你hello**", "你hello
", "allows bolded chinese");
});
@@ -418,11 +388,10 @@ QUnit.test("Escaping", assert => {
});
QUnit.test("New Lines", assert => {
- // historically we would not continue inline em or b across lines,
- // however commonmark gives us no switch to do so and we would be very non compliant.
- // turning softbreaks into a newline is just a renderer option, not a parser switch.
- assert.cooked("_abc\ndef_", "abc
\ndef
", "it does allow inlines to span new lines");
- assert.cooked("_abc\n\ndef_", "_abc
\ndef_
", "it does not allow inlines to span new paragraphs");
+ // Note: This behavior was discussed and we determined it does not make sense to do this
+ // unless you're using traditional line breaks
+ assert.cooked("_abc\ndef_", "_abc
def_
", "it does not allow markup to span new lines");
+ assert.cooked("_abc\n\ndef_", "_abc
\n\ndef_
", "it does not allow markup to span new paragraphs");
});
QUnit.test("Oneboxing", assert => {
@@ -439,9 +408,9 @@ QUnit.test("Oneboxing", assert => {
assert.ok(!matches("http://test.com bob", /onebox/), "doesn't onebox links that have trailing text");
assert.ok(!matches("[Tom Cruise](http://www.tomcruise.com/)", "onebox"), "Markdown links with labels are not oneboxed");
- assert.ok(!matches("[http://www.tomcruise.com/](http://www.tomcruise.com/)",
+ assert.ok(matches("[http://www.tomcruise.com/](http://www.tomcruise.com/)",
"onebox"),
- "Markdown links where the label is the same as the url but link is explicit");
+ "Markdown links where the label is the same as the url are oneboxed");
assert.cooked("http://en.wikipedia.org/wiki/Homicide:_Life_on_the_Street",
" {
QUnit.test("Code Blocks", assert => {
assert.cooked("\nhello\n
\n",
- "\nhello\n
",
+ "hello
",
"pre blocks don't include extra lines");
assert.cooked("```\na\nb\nc\n\nd\n```",
- "a\nb\nc\n\nd\n
",
+ "a\nb\nc\n\nd
",
"it treats new lines properly");
assert.cooked("```\ntest\n```",
- "test\n
",
+ "test
",
"it supports basic code blocks");
assert.cooked("```json\n{hello: 'world'}\n```\ntrailing",
- "{hello: 'world'}\n
\ntrailing
",
+ "{hello: 'world'}
\n\ntrailing
",
"It does not truncate text after a code block.");
assert.cooked("```json\nline 1\n\nline 2\n\n\nline3\n```",
- "line 1\n\nline 2\n\n\nline3\n
",
+ "line 1\n\nline 2\n\n\nline3
",
"it maintains new lines inside a code block.");
assert.cooked("hello\nworld\n```json\nline 1\n\nline 2\n\n\nline3\n```",
- "hello
\nworld
\nline 1\n\nline 2\n\n\nline3\n
",
+ "hello
world
\n\nline 1\n\nline 2\n\n\nline3
",
"it maintains new lines inside a code block with leading content.");
assert.cooked("```ruby\nhello \n```",
- "<header>hello</header>\n
",
+ "<header>hello</header>
",
"it escapes code in the code block");
assert.cooked("```text\ntext\n```",
- "text\n
",
+ "text
",
"handles text by adding nohighlight");
assert.cooked("```ruby\n# cool\n```",
- "# cool\n
",
+ "# cool
",
"it supports changing the language");
assert.cooked(" ```\n hello\n ```",
- "```\nhello\n```
",
+ "```\nhello\n```
",
"only detect ``` at the beginning of lines");
assert.cooked("```ruby\ndef self.parse(text)\n\n text\nend\n```",
- "def self.parse(text)\n\n text\nend\n
",
+ "def self.parse(text)\n\n text\nend
",
"it allows leading spaces on lines in a code block.");
assert.cooked("```ruby\nhello `eviltrout`\n```",
- "hello `eviltrout`\n
",
+ "hello `eviltrout`
",
"it allows code with backticks in it");
assert.cooked("```eviltrout\nhello\n```",
- "hello\n
",
+ "hello
",
"it doesn't not whitelist all classes");
assert.cooked("```\n[quote=\"sam, post:1, topic:9441, full:true\"]This is `` a bug.[/quote]\n```",
- "[quote="sam, post:1, topic:9441, full:true"]This is `<not>` a bug.[/quote]\n
",
+ "[quote="sam, post:1, topic:9441, full:true"]This is `<not>` a bug.[/quote]
",
"it allows code with backticks in it");
assert.cooked(" hello\ntest
",
- "hello\n
\ntest
",
+ "hello
\n\ntest
",
"it allows an indented code block to by followed by a ``");
assert.cooked("``` foo bar ```",
@@ -523,7 +492,7 @@ QUnit.test("Code Blocks", assert => {
"it tolerates misuse of code block tags as inline code");
assert.cooked("```\nline1\n```\n```\nline2\n\nline3\n```",
- "line1\n
\nline2\n\nline3\n
",
+ "line1
\n\nline2\n\nline3
",
"it does not consume next block's trailing newlines");
assert.cooked(" test
",
@@ -535,22 +504,22 @@ QUnit.test("Code Blocks", assert => {
"it does not parse other block types in markdown code blocks");
assert.cooked("## a\nb\n```\nc\n```",
- "a
\nb
\nc\n
",
+ "a
\n\nc
",
"it handles headings with code blocks after them.");
});
QUnit.test("URLs in BBCode tags", assert => {
assert.cooked("[img]http://eviltrout.com/eviltrout.png[/img][img]http://samsaffron.com/samsaffron.png[/img]",
- "",
+ "",
"images are properly parsed");
assert.cooked("[url]http://discourse.org[/url]",
- "",
+ "",
"links are properly parsed");
assert.cooked("[url=http://discourse.org]discourse[/url]",
- "",
+ "",
"named links are properly parsed");
});
@@ -561,39 +530,39 @@ QUnit.test("images", assert => {
"It allows images with links around them");
assert.cooked("",
- "\n
",
+ "",
"It allows data images");
});
QUnit.test("censoring", assert => {
assert.cooked("aw shucks, golly gee whiz.",
- "aw ■■■■■■, golly gee ■■■■.
",
+ "aw ■■■■■■, golly gee ■■■■.
",
"it censors words in the Site Settings");
assert.cooked("you are a whizzard! I love cheesewhiz. Whiz.",
- "you are a whizzard! I love cheesewhiz. ■■■■.
",
+ "you are a whizzard! I love cheesewhiz. ■■■■.
",
"it doesn't censor words unless they have boundaries.");
assert.cooked("you are a whizzer! I love cheesewhiz. Whiz.",
- "you are a ■■■■■■■! I love cheesewhiz. ■■■■.
",
+ "you are a ■■■■■■■! I love cheesewhiz. ■■■■.
",
"it censors words even if previous partial matches exist.");
assert.cooked("The link still works. [whiz](http://www.whiz.com)",
- "The link still works. ■■■■
",
+ "The link still works. ■■■■
",
"it won't break links by censoring them.");
assert.cooked("Call techapj the computer whiz at 555-555-1234 for free help.",
- "Call ■■■■■■■ the computer ■■■■ at 555-■■■■■■■■ for free help.
",
+ "Call ■■■■■■■ the computer ■■■■ at 555-■■■■■■■■ for free help.
",
"uses both censored words and patterns from site settings");
assert.cooked("I have a pen, I have an a**le",
- "I have a pen, I have an ■■■■■
",
+ "I have a pen, I have an ■■■■■
",
"it escapes regexp chars");
});
QUnit.test("code blocks/spans hoisting", assert => {
assert.cooked("```\n\n some code\n```",
- "\n some code\n
",
+ " some code
",
"it works when nesting standard markdown code blocks within a fenced code block");
assert.cooked("`$&`",
@@ -606,42 +575,47 @@ QUnit.test('basic bbcode', assert => {
assert.cookedPara("[i]emphasis[/i]", "emphasis", "italics text");
assert.cookedPara("[u]underlined[/u]", "underlined", "underlines text");
assert.cookedPara("[s]strikethrough[/s]", "strikethrough", "strikes-through text");
- assert.cookedPara("[img]http://eviltrout.com/eviltrout.png[/img]", "", "links images");
- assert.cookedPara("[email]eviltrout@mailinator.com[/email]", "eviltrout@mailinator.com", "supports [email] without a title");
+ assert.cookedPara("[img]http://eviltrout.com/eviltrout.png[/img]", "", "links images");
+ assert.cookedPara("[email]eviltrout@mailinator.com[/email]", "eviltrout@mailinator.com", "supports [email] without a title");
assert.cookedPara("[b]evil [i]trout[/i][/b]",
"evil trout",
"allows embedding of tags");
- assert.cookedPara("[EMAIL]eviltrout@mailinator.com[/EMAIL]", "eviltrout@mailinator.com", "supports upper case bbcode");
+ assert.cookedPara("[EMAIL]eviltrout@mailinator.com[/EMAIL]", "eviltrout@mailinator.com", "supports upper case bbcode");
assert.cookedPara("[b]strong [b]stronger[/b][/b]", "strong stronger", "accepts nested bbcode tags");
});
QUnit.test('urls', assert => {
assert.cookedPara("[url]not a url[/url]", "not a url", "supports [url] that isn't a url");
- assert.cookedPara("[url]abc.com[/url]", "abc.com", "it magically links using linkify");
- assert.cookedPara("[url]http://bettercallsaul.com[/url]", "http://bettercallsaul.com", "supports [url] without parameter");
- assert.cookedPara("[url=http://example.com]example[/url]", "example", "supports [url] with given href");
+ assert.cookedPara("[url]abc.com[/url]", "abc.com", "no error when a url has no protocol and begins with a");
+ assert.cookedPara("[url]http://bettercallsaul.com[/url]", "http://bettercallsaul.com", "supports [url] without parameter");
+ assert.cookedPara("[url=http://example.com]example[/url]", "example", "supports [url] with given href");
assert.cookedPara("[url=http://www.example.com][img]http://example.com/logo.png[/img][/url]",
- "",
+ "",
"supports [url] with an embedded [img]");
});
QUnit.test('invalid bbcode', assert => {
- assert.cooked("[code]I am not closed\n\nThis text exists.",
- "[code]I am not closed
\nThis text exists.
",
- "does not raise an error with an open bbcode tag.");
+ const result = new PrettyText({ lookupAvatar: false }).cook("[code]I am not closed\n\nThis text exists.");
+ assert.equal(result, "[code]I am not closed
\n\nThis text exists.
", "does not raise an error with an open bbcode tag.");
});
QUnit.test('code', assert => {
- assert.cooked("[code]\nx++\n[/code]", "x++
", "makes code into pre");
- assert.cooked("[code]\nx++\ny++\nz++\n[/code]", "x++\ny++\nz++
", "makes code into pre");
- assert.cooked("[code]\nabc\n#def\n[/code]", 'abc\n#def
', 'it handles headings in a [code] block');
- assert.cooked("[code]\n s\n[/code]",
+ assert.cookedPara("[code]\nx++\n[/code]", "x++
", "makes code into pre");
+ assert.cookedPara("[code]\nx++\ny++\nz++\n[/code]", "x++\ny++\nz++
", "makes code into pre");
+ assert.cookedPara("[code]abc\n#def\n[/code]", 'abc\n#def
', 'it handles headings in a [code] block');
+ assert.cookedPara("[code]\n s[/code]",
" s
",
"it doesn't trim leading whitespace");
});
+QUnit.test('lists', assert => {
+ assert.cookedPara("[ul][li]option one[/li][/ul]", "- option one
", "creates an ul");
+ assert.cookedPara("[ol][li]option one[/li][/ol]", "- option one
", "creates an ol");
+ assert.cookedPara("[ul]\n[li]option one[/li]\n[li]option two[/li]\n[/ul]", "- option one
- option two
", "suppresses empty lines in lists");
+});
+
QUnit.test('tags with arguments', assert => {
- assert.cookedPara("[url=http://bettercallsaul.com]better call![/url]", "better call!", "supports [url] with a title");
- assert.cookedPara("[email=eviltrout@mailinator.com]evil trout[/email]", "evil trout", "supports [email] with a title");
+ assert.cookedPara("[url=http://bettercallsaul.com]better call![/url]", "better call!", "supports [url] with a title");
+ assert.cookedPara("[email=eviltrout@mailinator.com]evil trout[/email]", "evil trout", "supports [email] with a title");
assert.cookedPara("[u][i]abc[/i][/u]", "abc", "can nest tags");
assert.cookedPara("[b]first[/b] [b]second[/b]", "first second", "can bold two things on the same line");
});
@@ -681,140 +655,70 @@ QUnit.test("quotes", assert => {
"[quote=\"eviltrout, post:1, topic:2\"]\nthis is <not> a bug\n[/quote]\n\n",
"it escapes the contents of the quote");
- assert.cooked("[quote]\ntest\n[/quote]",
- "\n\ntest
\n
\n ",
+ assert.cookedPara("[quote]test[/quote]",
+ "test
",
"it supports quotes without params");
- assert.cooked("[quote]\n*test*\n[/quote]",
- "\n\ntest
\n
\n ",
+ assert.cookedPara("[quote]\n*test*\n[/quote]",
+ "test
",
"it doesn't insert a new line for italics");
- assert.cooked("[quote=,script='a'>", "hello
", "it sanitizes while cooking");
cooked("disney reddit",
"",
"we can embed proper links");
- cooked("hello ", "hello", "it does not allow centering");
- cooked("a\n
\n", "a\n
", "it does not double sanitize");
+ cooked("hello ", "hello
", "it does not allow centering");
+ cooked("hello
\nafter", "after
", "it does not allow tables");
+ cooked("a\n
\n", "a\n\n
\n\n
", "it does not double sanitize");
cooked("", "", "it does not allow most iframes");
@@ -38,9 +38,9 @@ QUnit.test("sanitize", assert => {
assert.equal(pt.sanitize("